diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index 4499e3fdc..2a8144a5b 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -29,7 +29,7 @@ permissions: jobs: windows-arm64: # Immutable supporting workflow revision until the new install contract lands upstream. - uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f + uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@fa5ef18780cf5e7d5d3b8de068de56cffc83cac5 with: app-name: effectcraft app-dir: EffectCraft diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dfa28c9f0..74d946f01 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,12 +7,36 @@ work apply to everyone, people and AI agents alike, and they are not negotiable. You need [Rust](https://rustup.rs/) 1.95 or newer. +On Linux the desktop app plays preview sound through `cpal`, so the build needs a usable ALSA +pkg-config package: an `alsa.pc` that pkg-config can find, naming the linkable ALSA runtime +library (usually `libasound.so.2`). `alsa-sys` runs pkg-config at build time; it does not compile +ALSA's headers. Without a usable package the build fails at link time with an unrelated-looking +error; `cargo xtask ci` probes pkg-config up front and names the package: + +```sh +sudo dnf install alsa-lib-devel # Fedora +sudo apt install libasound2-dev # Debian / Ubuntu +sudo pacman -S alsa-lib # Arch +``` + +If you cannot install the package, point `PKG_CONFIG_PATH` at a directory holding an `alsa.pc` +whose `Libs` line names your system's `libasound` (usually `libasound.so.2`); every CI run needs +that variable set. + ```sh cargo run -p effectcraft -- --demo # the desktop app, with the demo project cargo test --workspace cargo xtask ci # what every commit must pass ``` +The default `dev` profile keeps debug info to line tables: builds are fast and panics/backtraces +still name the file and line, but a debugger cannot show local variables. For an interactive +debugger session use the `dev-full` profile, which is the same build with full debug info: + +```sh +cargo run --profile dev-full -p effectcraft -- --demo +``` + ## Ground rules - **Clean room.** Work from After Effects' public documentation and its observable behaviour only. diff --git a/Cargo.lock b/Cargo.lock index a6611ffba..a48f2d44b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1623,6 +1623,7 @@ name = "effectcraft-media" version = "0.18.0" dependencies = [ "craft-docpath", + "effectcraft-color", "effectcraft-model", "effectcraft-pdf", "effectcraft-project", @@ -7199,6 +7200,7 @@ name = "xtask" version = "0.18.0" dependencies = [ "chrono", + "pkg-config", "serde_json", "tempfile", ] diff --git a/Cargo.toml b/Cargo.toml index aa917fc63..dee46306d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -153,6 +153,13 @@ opt-level = 1 # Panics and backtraces keep file and line; full debug info only made the many test binaries large. debug = "line-tables-only" +# Same settings as `dev`, but with full debug info for interactive debugger sessions: local +# variables, parameters and column breakpoints work. Use `--profile dev-full` when debugging; +# everything else stays on the fast default. See CONTRIBUTING.md, "Setup". +[profile.dev-full] +inherits = "dev" +debug = true + # Pixel hot loops: keep debug builds interactive. [profile.dev.package.effectcraft-raster] opt-level = 3 diff --git a/README.md b/README.md index c1f895176..12970b8ba 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@

A free, open-source compositor: compositions, layers, - keyframes, 306 effects, layer styles, expressions, 3D cameras and lights, and a render queue, native on macOS, + keyframes, 308 effects, layer styles, expressions, 3D cameras and lights, and a render queue, native on macOS, Windows and Linux, and in the browser. Young, moving fast, and already usable.

@@ -84,9 +84,10 @@ a few places where it matters to us: without a plugin. - **A project file you can read.** Projects are versioned JSON (`.ecproj`), so they diff cleanly in version control. -- **Everything is scriptable.** Every menu item, timeline drag and property edit goes through one - command registry, so the same actions are reachable from a command line, a JSON control - channel and an MCP server for agents. +- **Everything is scriptable.** Menu actions, timeline edits and property changes are exposed as + commands. Engine commands are available through the CLI, JSON control channel and MCP; + UI-only commands use the frontend dispatcher and can be invoked through the control channel + or an attached MCP bridge. - **No FFmpeg.** Video and audio decoding and encoding are pure Rust: FilmCraft's codecs and EffectCraft's own VP9, AV1, HEVC and Opus encoders. @@ -126,7 +127,7 @@ or into the Timeline, between layers and at the time you point to. ## Effects -306 built-in effects across its categories, each with its parameter names, order and +308 built-in effects across its categories, each with its parameter names, order and defaults: blur and sharpen, channel, color correction (Curves, Levels, Hue/Saturation, a full color-grading panel…), distort (Warp, Bulge, Turbulent Displace, CC Power Pin…), generate (Fractal Noise, Gradient Ramp, Stroke, Write-on, Audio Spectrum…), keying, matte, noise and grain, perspective, @@ -173,8 +174,10 @@ cannot express; File ▸ Import ▸ Lottie… opens one as a new composition. ## Built for agents -Everything you can do from a menu is a command with an id, and agents can reach every one of -them: +Engine commands are available through the CLI, JSON control channel and MCP. UI-only commands +need the running app's frontend dispatcher: complete menu access requires the app's control +channel or an attached MCP bridge. Headless MCP sessions expose supported engine commands, +but not UI-only commands or bridge-only `ui_*` tools. - **MCP server:** `effectcraft-cli mcp` speaks the Model Context Protocol over stdio, headless or bridged to the running app (`--bridge 9877`). Tools cover commands, the project and property @@ -228,7 +231,7 @@ tests, layering, asset attribution and the WebAssembly build). See [CONTRIBUTING ## Where it stands EffectCraft is young: its first commit was on 1 October 2026. A comprehensive feature set -exists, including 306 effects, but breadth is not the same as full maturity across every edge +exists, including 308 effects, but breadth is not the same as full maturity across every edge case. Today it's a good place to try things and to tell us what breaks. It isn't yet recommended for production client work. In particular: diff --git a/ROADMAP.md b/ROADMAP.md index 1ce9d9bb2..f3e6750ed 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~30 points of "ready for real work" and ~350–620 h away -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (open-issue count, crash list and localization share re-audited against `upstream/main` 470ea7a3) · **Target:** Adobe After Effects 2026 (26.5.0.89) +> **Last reviewed:** 2026-10-22 · **Last updated:** 2026-10-30 · **Change:** minor (localization: 303 more panel/dialog rows in Japanese and the Chinese catalogs; recounted after #1774: Japanese 27% → 46%, Simplified Chinese 29% → 46%, Traditional Chinese 29% → 38%, overall 18% → 24%) · **Target:** Adobe After Effects 2026 (26.5.0.89) EffectCraft aims to do what After Effects does, with the same panels, menus and behaviour, written from scratch in Rust. This page is the one-page summary; the evidence is in @@ -51,7 +51,7 @@ Linux/Windows reliability (30–50 h) and depth in animation, masks, effects and | UI/UX fidelity | 65% | 60–120 | [ui-parity.md](docs/ui-parity.md) | | File formats | 50% | 130–230 | [file-format-parity.md](docs/file-format-parity.md) | | Hardware | 40% | 60–120 | [hardware-parity.md](docs/hardware-parity.md) | -| Localization | 18% | 60–110 + review | [localization-parity.md](docs/localization-parity.md) | +| Localization | 24% | 60–110 + review | [localization-parity.md](docs/localization-parity.md) | | Performance | 35% | 50–100 | [gaps.md](docs/gaps.md#g5-performance-at-real-world-scale) | | Stability | 45% | 40–80 | [gaps.md](docs/gaps.md#g3-stability-and-a-green-main) | | Platforms | 65% | 30–60 | [gaps.md](docs/gaps.md#g2-real-user-reliability-on-every-platform) | @@ -80,24 +80,24 @@ Weights and evidence per row: [target-app-parity.md](docs/target-app-parity.md#b ## Languages -UI strings translated out of ≈ 3,900 (detail: [localization-parity.md](docs/localization-parity.md)). +UI strings translated out of ≈ 4,200 (detail: [localization-parity.md](docs/localization-parity.md)). | Language | Status | % | |---|---|---:| | English | full | 100% | -| Simplified Chinese | partial (menus, part of the panels) | 29% | -| Spanish | menus only | 16% | +| Simplified Chinese | partial (menus, most of the panels) | 46% | +| Spanish | menus only | 29% | | Hindi | none | 0% | | Arabic | none | 0% | | French | menus only | 16% | -| Portuguese (Brazil) | partial (menus, part of the panels) | 26% | +| Portuguese (Brazil) | partial (menus, part of the panels) | 34% | | Indonesian | none | 0% | -| Japanese | partial (menus, part of the panels) | 27% | +| Japanese | partial (menus, most of the panels) | 46% | | German | menus only | 16% | | Korean | none | 0% | | Vietnamese | none | 0% | -Also shipped: Traditional Chinese (partial, 29%), Ukrainian (partial, 29%), Czech (partial, 29%), Georgian (partial, 38%; native-speaker reviewed) and Russian (menus only, 16%). No other language has had +Also shipped: Traditional Chinese (partial, 38%), Ukrainian (partial, 29%), Czech (partial, 29%), Polish (partial, 34%), Georgian (partial, 39%; native-speaker reviewed) and Russian (menus only, 15%). No other language has had native-speaker review. ## Upcoming @@ -120,10 +120,22 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft). - 2026-10-28: A shape layer's Rectangle and Ellipse Size get the Constrain Proportions chain that Scale has, so a circle stays a circle while its size is typed, scrubbed or keyed; it starts off, so existing shapes behave as before (#2489). +- 2026-10-24: Shape layers have the Wiggle Transform path operation (Contents ▸ Add, or + `layer.addShapeItem {kind: "wiggler"}`): it wiggles the anchor point, position, scale and + rotation of the paths above it by random amounts that change over time, up to the Transform + values it holds. The copies of a Repeater above it wiggle independently as far as its + Correlation allows; with the Repeater below it, they wiggle together. +- 2026-10-22: Localization: the panel/dialog catalogs gain 303 rows in Japanese, Simplified + Chinese and Traditional Chinese — panel tabs, form and dialog labels, panel context menus, + tracker/graph/text-panel controls, File-panel footers and credits. Recounted after #1774: + Japanese is 1,923 of ≈ 4,200 interface strings (46%, with 297 effect names); the Chinese + panel catalogs hold 998 (Simplified) and 951 (Traditional) rows, and Simplified Chinese is + 46% with its 307 effect names; the overall share is ≈ 24%. - 2026-10-16: Convert To Bezier Path turns a shape layer's Rectangle, Ellipse or Polystar path into an editable Path 1 with the same geometry, as After Effects does; it used to apply to mask RotoBezier only, so a parametric shape path could not be converted at all and its vertices could not be edited or animated (#932). +- 2026-10-14: Georgian (`ka`): every menu row, every panel-catalog row the Chinese catalogs have plus 195 Georgian-only ones, and the effect names and categories; Match System for `ka` / `ka-GE` / `ka_GE.UTF-8` locales. Reviewed by a native speaker. - 2026-10-12: Roto Brush span edits report strokes outside a trimmed layer without panicking; strokes and undo history are preserved so restoring the timing allows the span edit again. - 2026-10-11: Ukrainian panels caught up with the Chinese catalogs. The 95 rows the panel @@ -138,7 +150,6 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft). follow the shared standard: `docs/parity.md` became `docs/target-app-parity.md`; new `docs/roadmap.md`, `localization-parity.md`, `file-format-parity.md`, `hardware-parity.md`, `ui-parity.md`, `effects-parity.md` and `scripting-parity.md`. -- 2026-10-14: Georgian (`ka`): every menu row, every panel-catalog row the Chinese catalogs have plus 195 Georgian-only ones, and the effect names and categories; Match System for `ka` / `ka-GE` / `ka_GE.UTF-8` locales. Reviewed by a native speaker. - 2026-10-10: Spanish (#460) and Brazilian Portuguese (#217) menu translations. - 2026-10-10: Japanese now translates the interface below the menu bar too: `crates/ui-egui/src/i18n/ui.rs` has a Japanese table with the same 396 rows as the Chinese ones (panels, dialogs, buttons and tooltips; the shape test keeps the three catalogs listing the same source strings). Menus were already Japanese. - 2026-10-09: Ukrainian now covers all 396 current panel-catalog strings, including dialogs, buttons and tooltips, alongside its menu translation. The catalog keeps the same source keys and placeholder counts as the Chinese catalogs; missing or engine-generated text still falls back to English. @@ -158,11 +169,12 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft). | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | Localization: 300 more panel/dialog rows in Japanese and the Chinese catalogs, plus the review's three missing rows (Basic, Advanced, Play/Stop) — panel tabs, form and dialog labels, context menus, tracker/graph/text-panel controls, File-panel footers, credits; language shares recounted after #1774 (Japanese 27% → 46% with 297 effect names, Simplified Chinese 29% → 46% with 307 effect names, Traditional Chinese 29% → 38%, overall 18% → 24%) | | 2026-10-12 | minor | Open-issue count (134 to 216), crash list and localization share (12% to 18%) updated to match docs/gaps.md | | 2026-10-12 | patch | Roto Brush span validation after trimming; command regressions for both layer edges | +| 2026-10-12 | minor | Shared PDF object/xref parsing with bounded app-owned conversion and preserved stream budgets; drawing, EPS and image/font policy remain unchanged | | 2026-10-10 | minor | Hours to ≈ 95% for each audience | | 2026-10-10 | minor | Headline adds mainstream practitioner (≈ 47%) and essentials user (≈ 52%) | -| 2026-10-12 | minor | Shared PDF object/xref parsing with bounded app-owned conversion and preserved stream budgets; drawing, EPS and image/font policy remain unchanged | -| 2026-10-10 | minor | Stage checked against the new core-workflow gate (all six pass; still alpha) | | 2026-10-10 | major | Rewritten to the shared progress-docs standard: stage, two numbers, dimensions, features, languages, upcoming, progress log; milestones moved to docs/roadmap.md | +| 2026-10-10 | minor | Stage checked against the new core-workflow gate (all six pass; still alpha) | | 2026-10-05 | major | Two numbers (breadth vs real use) and workstreams G1–G9 | diff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs index 5aedfad58..db6394b21 100644 --- a/apps/effectcraft-cli/src/main.rs +++ b/apps/effectcraft-cli/src/main.rs @@ -1296,7 +1296,7 @@ fn adv3d_bench_comp(p: &mut effectcraft_engine::project::Project) -> Option) { /// The eframe app: the shared EffectCraft UI plus the browser host's per-frame duties. pub struct WebApp { pub app: EffectcraftApp, - /// Keep the session snapshot (off with `?empty`). + /// Keep the session snapshot (off with `?empty`; off with an explicit `"demo"` startup until + /// the user's own work begins, see `demo_start`). pub persist: bool, + /// Started on the demo by the config: the snapshot stays off, so the demo never replaces the + /// last visit's project, until the user opens a project from a file or edits the demo. + pub demo_start: bool, } impl eframe::App for WebApp { @@ -117,6 +122,11 @@ impl eframe::App for WebApp { api::poll_replies(); // Render Queue outputs written this frame (here or by a worker) → downloads. files::flush_downloads(); + // The user's own work after a demo start is worth keeping too: turn the snapshot on. + if self.demo_start && (self.app.session.path.is_some() || self.app.session.is_dirty()) { + self.demo_start = false; + self.persist = true; + } // What a reload comes back to. if self.persist { // The app's monotonic clock: `ctx.input(|i| i.time)` is stale in a logic-only tick. @@ -223,6 +233,11 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> { }; api::set_info("storage", json!({"backend": persist::backend(), "entries": stored, "loadMs": t0.elapsed().as_secs_f64() * 1000.0})); audio::install_unlock(); + // `./effectcraft-config.json`: what to open on load. + // `?empty` and `?demo` decide by themselves, so they skip the request. + let startup = if empty || query_flag("demo") { Startup::default() } else { config_file::startup().await }; + // An explicit `"demo"` start leaves the saved session alone until the user opens or edits something. + let keep_session = !empty && startup.keeps_session(); browse::restore(); diskcache::init(); storage::refresh(); @@ -248,7 +263,7 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> { // The canvas can't present any more (#225): the page says so over it and // offers a reload, which brings the session back when browser storage keeps it. let lost = gpu_failures.clone(); - let kept = !empty && persist::backend() != "memory"; + let kept = keep_session && persist::backend() != "memory"; rs.device.set_device_lost_callback(move |reason, message| { let message = format!("GPU device lost ({reason:?}): {message}"); lost.report(&message, true); @@ -257,11 +272,14 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> { } let mut session = session(); session.load_settings(); + // Scripts in Scripts/Startup run before a project opens. + let _ = session.execute("file.runStartupScripts", json!({})); let recovery = session.begin_recovery(); - // `?empty`: a blank project; `?demo`: the demo project; otherwise the last visit's - // project (the demo on a first visit). - let restored = !empty && !query_flag("demo") && persist::restore(&mut session); - if !restored && !empty { + // `?empty`: a blank project; `?demo`: the demo project; otherwise what the config's + // `startup` says (default: the last visit's project, the demo on a first visit). + let demo = query_flag("demo"); + let restored = !empty && !demo && startup.restores() && persist::restore(&mut session); + if !restored && !empty && (demo || startup.demo_when_nothing_restored()) { let _ = session.execute("file.openDemoProject", json!({})); } api::set_info("restored", json!(restored)); @@ -287,7 +305,7 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> { app = app.with_control(rx); let backend = cc.wgpu_render_state.as_ref().map(|rs| format!("{:?}", rs.adapter.get_info().backend)); api::set_info("backend", json!(backend)); - Ok(Box::new(WebApp { app, persist: !empty })) + Ok(Box::new(WebApp { app, persist: keep_session, demo_start: !empty && !keep_session })) }), ) .await?; diff --git a/apps/effectcraft-web/src/config_file.rs b/apps/effectcraft-web/src/config_file.rs new file mode 100644 index 000000000..614d7d4cb --- /dev/null +++ b/apps/effectcraft-web/src/config_file.rs @@ -0,0 +1,73 @@ +//! `./effectcraft-config.json`, fetched once per page load and shared by whoever reads a key. + +use std::cell::RefCell; + +use wasm_bindgen::{JsCast, closure::Closure}; +use wasm_bindgen_futures::JsFuture; + +use crate::startup::{self, Startup}; + +/// Longest the request and the body read may take (ms); a stalled server must not hold up start-up. +const TIMEOUT_MS: i32 = 5_000; + +thread_local! { + /// `None`: not fetched yet; `Some(None)`: no usable file. + static FILE: RefCell>>> = const { RefCell::new(None) }; +} + +/// The config file's bytes, or `None` when it is missing, too large, unreadable or the request +/// takes longer than [`TIMEOUT_MS`] (the timer aborts the request and covers the body read). +async fn fetch() -> Option> { + let win = web_sys::window()?; + let ctl = web_sys::AbortController::new().ok()?; + let init = web_sys::RequestInit::new(); + // Always ask the server: the file is the operator's switch, never a cached copy. + init.set_cache(web_sys::RequestCache::NoCache); + init.set_signal(Some(&ctl.signal())); + let on_timeout = Closure::once(move || ctl.abort()); + let timer = win.set_timeout_with_callback_and_timeout_and_arguments_0(on_timeout.as_ref().unchecked_ref(), TIMEOUT_MS).ok()?; + let out = read(&win, &init).await; + win.clear_timeout_with_handle(timer); + out +} + +/// Fetch the file and read its body chunk by chunk, giving up as soon as it passes `CONFIG_MAX`. +async fn read(win: &web_sys::Window, init: &web_sys::RequestInit) -> Option> { + let req = web_sys::Request::new_with_str_and_init("effectcraft-config.json", init).ok()?; + let resp: web_sys::Response = JsFuture::from(win.fetch_with_request(&req)).await.ok()?.dyn_into().ok()?; + if resp.status() != 200 { + return None; + } + let reader: web_sys::ReadableStreamDefaultReader = resp.body()?.get_reader().unchecked_into(); + let mut out = Vec::new(); + loop { + let chunk = JsFuture::from(reader.read()).await.ok()?; + if js_sys::Reflect::get(&chunk, &"done".into()).ok()?.as_bool() == Some(true) { + return Some(out); + } + let bytes: js_sys::Uint8Array = js_sys::Reflect::get(&chunk, &"value".into()).ok()?.dyn_into().ok()?; + if out.len().saturating_add(bytes.length() as usize) > startup::CONFIG_MAX { + // Stop the download: the limit is enforced while reading, not after the whole body arrived. + let _ = reader.cancel(); + return None; + } + out.extend_from_slice(&bytes.to_vec()); + } +} + +/// The config file's bytes (`None` when there is none), fetched on the first call. +pub async fn bytes() -> Option> { + if let Some(done) = FILE.with(|f| f.borrow().clone()) { + return done; + } + let got = fetch().await; + FILE.with(|f| *f.borrow_mut() = Some(got.clone())); + got +} + +/// The configured startup behaviour. +pub async fn startup() -> Startup { + let s = bytes().await.map(|b| startup::parse(&b)).unwrap_or_default(); + log::info!("startup: {s:?}"); + s +} diff --git a/apps/effectcraft-web/src/lib.rs b/apps/effectcraft-web/src/lib.rs index 97d2e887f..e1bf85894 100644 --- a/apps/effectcraft-web/src/lib.rs +++ b/apps/effectcraft-web/src/lib.rs @@ -18,10 +18,11 @@ //! | cpal audio output | Web Audio: an AudioWorklet fed from the preview mixdown (`audio`) | //! | TCP control channel / MCP | `window.effectcraft` JavaScript API (`api`) | //! -//! [`store`] is portable (unit-tested natively); everything else is `wasm32`-only. +//! [`store`] and [`startup`] are portable (unit-tested natively); everything else is `wasm32`-only. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +pub mod startup; pub mod store; #[cfg(target_arch = "wasm32")] @@ -33,6 +34,8 @@ pub mod audio; #[cfg(target_arch = "wasm32")] pub mod browse; #[cfg(target_arch = "wasm32")] +pub mod config_file; +#[cfg(target_arch = "wasm32")] pub mod diskcache; #[cfg(target_arch = "wasm32")] pub mod files; diff --git a/apps/effectcraft-web/src/startup.rs b/apps/effectcraft-web/src/startup.rs new file mode 100644 index 000000000..4759096f3 --- /dev/null +++ b/apps/effectcraft-web/src/startup.rs @@ -0,0 +1,55 @@ +//! The `startup` key of `./effectcraft-config.json`: which project the page opens on load. +//! +//! | Value | On load | +//! |---|---| +//! | `"last"` (and absent) | the last visit's project; the demo project on a first visit | +//! | `"empty"` | the last visit's project; a blank project on a first visit, never the demo | +//! | `"demo"` | the demo project, whatever the last visit left | +//! +//! The `?empty` and `?demo` URL flags still win. Anything else (a missing key, another type, an +//! unknown value, a config that is not JSON) is the default, `"last"`. + +use serde_json::Value; + +/// What the page opens on load. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Startup { + #[default] + Last, + Empty, + Demo, +} + +/// The largest config file read (bytes). +pub const CONFIG_MAX: usize = 64 * 1024; + +/// The `startup` key of a config file's bytes. +pub fn parse(bytes: &[u8]) -> Startup { + if bytes.len() > CONFIG_MAX { + return Startup::default(); + } + let Ok(v) = serde_json::from_slice::(bytes) else { return Startup::default() }; + match v.get("startup").and_then(Value::as_str) { + Some("empty") => Startup::Empty, + Some("demo") => Startup::Demo, + _ => Startup::Last, + } +} + +impl Startup { + /// Whether the last visit's project is restored (off for the demo). + pub fn restores(self) -> bool { + self != Startup::Demo + } + + /// Whether the session snapshot keeps being saved (off for the demo, so it never replaces the + /// project the last visit left). + pub fn keeps_session(self) -> bool { + self != Startup::Demo + } + + /// Whether the demo project opens when nothing was restored (off for a blank start). + pub fn demo_when_nothing_restored(self) -> bool { + self != Startup::Empty + } +} diff --git a/apps/effectcraft-web/tests/it/main.rs b/apps/effectcraft-web/tests/it/main.rs new file mode 100644 index 000000000..a4d242ba5 --- /dev/null +++ b/apps/effectcraft-web/tests/it/main.rs @@ -0,0 +1 @@ +mod startup_tests; diff --git a/apps/effectcraft-web/tests/it/startup_tests.rs b/apps/effectcraft-web/tests/it/startup_tests.rs new file mode 100644 index 000000000..3ce90569c --- /dev/null +++ b/apps/effectcraft-web/tests/it/startup_tests.rs @@ -0,0 +1,52 @@ +//! The `startup` key of the config file. + +use effectcraft_web::startup::{CONFIG_MAX, Startup, parse}; + +/// The three accepted values parse. +#[test] +fn the_three_values() { + assert_eq!(parse(br#"{"startup":"empty"}"#), Startup::Empty); + assert_eq!(parse(br#"{"startup":"demo"}"#), Startup::Demo); + assert_eq!(parse(br#"{"startup":"last"}"#), Startup::Last); +} + +/// A missing key, a wrong type, an unknown value or a non-object file gives `Last`. +#[test] +fn absent_or_invalid_is_the_default() { + for bytes in + [&b"{}"[..], b"", b"not json", b"[]", b"null", br#"{"startup":1}"#, br#"{"startup":"Empty"}"#, br#"{"startup":null}"#, br#"{"startup":["empty"]}"#] + { + assert_eq!(parse(bytes), Startup::Last, "{:?}", String::from_utf8_lossy(bytes)); + } +} + +/// Other keys do not hide `startup`. +#[test] +fn a_config_with_other_keys_still_has_its_startup() { + assert_eq!(parse(br#"{"remote":{"mounts":[]},"startup":"empty"}"#), Startup::Empty); + assert_eq!(parse(br#"{"startup":"demo","unknown":{"a":1}}"#), Startup::Demo); +} + +/// A file over `CONFIG_MAX` is ignored. +#[test] +fn an_oversized_file_is_the_default() { + let mut big = br#"{"startup":"empty","pad":""#.to_vec(); + big.extend(std::iter::repeat_n(b'x', CONFIG_MAX)); + big.extend_from_slice(br#""}"#); + assert_eq!(parse(&big), Startup::Last); +} + +/// Which project each value restores or opens. +#[test] +fn what_each_value_does_on_load() { + assert!(Startup::Last.restores() && Startup::Last.demo_when_nothing_restored()); + assert!(Startup::Empty.restores() && !Startup::Empty.demo_when_nothing_restored()); + assert!(!Startup::Demo.restores() && Startup::Demo.demo_when_nothing_restored()); +} + +/// Only the explicit demo start stops saving the session. +#[test] +fn only_the_demo_start_leaves_the_saved_session_alone() { + assert!(Startup::Last.keeps_session() && Startup::Empty.keeps_session()); + assert!(!Startup::Demo.keeps_session()); +} diff --git a/apps/effectcraft-web/web/sw.js b/apps/effectcraft-web/web/sw.js index 4acbeeeeb..8f48a93bc 100644 --- a/apps/effectcraft-web/web/sw.js +++ b/apps/effectcraft-web/web/sw.js @@ -3,7 +3,8 @@ // `cargo xtask web` fills in the build's file list and version. Every file is precached into a // cache named after the version, served cache-first (responses keep the server's headers, so // the page stays cross-origin isolated: COOP/COEP), and old versions are dropped once the new -// one activates. Anything else same-origin is fetched from the network and cached for next time. +// one activates. Anything else same-origin is fetched from the network and cached for next time, +// except the config file, which must stay live. const VERSION = "__EC_VERSION__"; const FILES = __EC_FILES__; const CACHE = `effectcraft-${VERSION}`; @@ -30,6 +31,7 @@ self.addEventListener("fetch", (event) => { const req = event.request; const url = new URL(req.url); if (req.method !== "GET" || url.origin !== self.location.origin) return; + if (url.pathname.endsWith("/effectcraft-config.json")) return; event.respondWith( (async () => { const cache = await caches.open(CACHE).catch(() => null); diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index 452196224..bdbdcda94 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -263,6 +263,9 @@ fn main() -> eframe::Result { // Fit the window to its monitor, or put it back where it was (still hidden). let saved_window = session.config.as_deref().and_then(window::load); let opened = cc.winit_window().map(|w| window::restore(w, saved_window)); + // Scripts in Scripts/Startup run before a project opens (a failing one is reported in + // a toast). + let _ = session.execute("file.runStartupScripts", json!({})); let recovery = if control_port.is_none() { session.begin_recovery() } else { None }; let project = files.iter().find(|f| is_project_file(f)).cloned(); if let Some(p) = project { @@ -495,6 +498,8 @@ impl eframe::App for Desktop { self.launch.exited(); self.window.save(self.app.session.config.as_deref()); self.app.on_exit(); + // Scripts in Scripts/Shutdown run with the project closed. + let _ = self.app.session.execute("file.runShutdownScripts", json!({})); } fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) { diff --git a/contributors/people.toml b/contributors/people.toml index 8654c7a10..c985dbc2e 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -22,6 +22,11 @@ real_name = "Brandon Thomas" display_name = "Brandon Thomas" sync_github_name = false +[people.lewandowskit] +real_name = "Tomasz Lewandowski" +display_name = "Tomasz Lewandowski" +sync_github_name = false + [people.switch87] real_name = "Gert Pellin" display_name = "Gert Pellin" diff --git a/crates/automation/src/confine.rs b/crates/automation/src/confine.rs index 67e5aef98..2b0795c93 100644 --- a/crates/automation/src/confine.rs +++ b/crates/automation/src/confine.rs @@ -30,10 +30,9 @@ pub struct Confinement { write: Vec, } -/// Commands that run code, act outside the project or open other applications. +/// Commands that run code, act outside the project or open other applications, besides the +/// commands that run scripts ([`effectcraft_engine::commands::scripts::RUNS_SCRIPTS`]). const REFUSED: &[&str] = &[ - "script.run", - "file.runScript", "file.installScript", "file.installScriptUIPanel", "file.uninstallScript", @@ -167,7 +166,7 @@ impl Confinement { /// Check engine command `id` with `params` before it runs. Returns what must still be looked /// up and passed to [`Confinement::check_lookup`] for commands that write implicitly. pub fn check_command(&self, id: &str, params: &Value) -> Result> { - if REFUSED.contains(&id) { + if REFUSED.contains(&id) || effectcraft_engine::commands::scripts::RUNS_SCRIPTS.contains(&id) { return Err(Error::BadArgs(format!("`{id}` is not available while file access is confined (--automation-read-root/--automation-write-root)"))); } // Scripts can't grant themselves the access the user withheld @@ -397,6 +396,7 @@ mod tests { let classified = keys_of(READS, id).is_some() || keys_of(WRITES, id).is_some() || REFUSED.contains(&id) + || effectcraft_engine::commands::scripts::RUNS_SCRIPTS.contains(&id) || IMPLICIT_WRITES.contains(&id) || id.starts_with("renderQueue.") || id == "render.addOutputModule" diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index 560a527c7..519c7c433 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -1412,6 +1412,18 @@ fn automation_roots_confine_file_access() { // Scripts can read and write anything: refused. denied(&mut s, "run_script", json!({"code": "1 + 1"})); denied(&mut s, "execute_command", json!({"command": "file.runScript", "params": {"path": p(&read, "x.jsx")}})); + for (id, params) in [ + ("file.runStartupScripts", json!({})), + ("file.runShutdownScripts", json!({})), + // ScriptUI panels run their script when they open. + ("window.scriptPanel", json!({"name": "Layer Tools.jsx"})), + ("window.restoreScriptPanels", json!({})), + ] { + let msg = denied(&mut s, "execute_command", json!({"command": id, "params": params})); + assert!(msg.contains("not available while file access is confined"), "{id}: {msg}"); + let msg = denied(&mut s, "batch", json!({"steps": [{"command": id, "params": params}]})); + assert!(msg.contains("not available while file access is confined"), "{id}: {msg}"); + } // Writes to the files the agent named: none outside. assert_eq!(std::fs::read_dir(&outside).unwrap().count(), 1, "only the unconfined save"); let _ = std::fs::remove_dir_all(&base); diff --git a/crates/automation/tests/qa/compositing.rs b/crates/automation/tests/qa/compositing.rs index da78ae116..d5de8f687 100644 --- a/crates/automation/tests/qa/compositing.rs +++ b/crates/automation/tests/qa/compositing.rs @@ -35,7 +35,7 @@ fn import_footage(qa: &mut Qa) -> (Value, Value) { // Footage: the sequence, and a layered PSD written by the app itself (Save Frame As ▸ // Photoshop Layers) from a small comp. let seq = write_sequence(qa); - let imp = qa.exec("file.import", json!({"paths": [seq]})); + let imp = qa.exec("file.import", json!({"paths": [seq], "sequence": true})); let seq_item = imp["items"][0].clone(); let project = qa.tool("get_project", json!({})); let it = project["items"].as_array().unwrap().iter().find(|i| i["id"] == seq_item).unwrap().clone(); diff --git a/crates/automation/tests/qa/tracking.rs b/crates/automation/tests/qa/tracking.rs index 837872658..ee6bbb2e9 100644 --- a/crates/automation/tests/qa/tracking.rs +++ b/crates/automation/tests/qa/tracking.rs @@ -52,7 +52,7 @@ fn shake_offset(f: u32) -> (i64, i64) { /// Import a sequence at 12 fps and make a comp of it; returns (comp, layer). fn plate_comp(qa: &mut Qa, first: &str, name: &str) -> (Value, Value) { - let item = qa.exec("file.import", json!({"paths": [first]}))["items"][0].clone(); + let item = qa.exec("file.import", json!({"paths": [first], "sequence": true}))["items"][0].clone(); qa.exec("file.interpretFootage", json!({"items": [item.clone()], "frameRate": 12})); let comp = qa.exec("comp.new", json!({"name": name, "width": 160, "height": 90, "frameRate": 12, "duration": 1}))["comp"].clone(); let layer = qa.exec("layer.addItem", json!({"item": item}))["layer"].clone(); diff --git a/crates/av1enc/src/decide.rs b/crates/av1enc/src/decide.rs index eb108a204..d8bc1f825 100644 --- a/crates/av1enc/src/decide.rs +++ b/crates/av1enc/src/decide.rs @@ -174,6 +174,8 @@ pub(crate) struct Decider<'a> { ac_q: f32, dc_qi: i64, ac_qi: i64, + /// Smallest magnitude that quantises to a non-zero level, `[dc, ac][intra]`. + dead_zone: [[f32; 2]; 2], lambda: f64, lambda_sad: f64, /// Half-sample interpolations of the reference luma (visible area): (8, 0), (0, 8), (8, 8). @@ -183,6 +185,9 @@ pub(crate) struct Decider<'a> { } const MODE_BITS_INTRA: f64 = 3.0; +/// Dead-zone quantiser rounding offsets (below one half) for inter and intra blocks. +const INTER_RND: f32 = 0.30; +const INTRA_RND: f32 = 0.38; impl<'a> Decider<'a> { pub fn new(g: &'a FrameGeom, bd: u32, qidx: u8, intra_only: bool, src: &'a [Plane; 3], refr: Option<&'a RefFrame>) -> Self { @@ -209,6 +214,7 @@ impl<'a> Decider<'a> { ac_q: ac_qi as f32, dc_qi, ac_qi, + dead_zone: [dc_qi, ac_qi].map(|q| [INTER_RND, INTRA_RND].map(|rnd| zero_threshold(q as f32, rnd))), lambda, lambda_sad: lambda.sqrt(), half: match refr { @@ -577,8 +583,9 @@ impl<'a> Decider<'a> { let (dy, dx) = (m[0] >> 3, m[1] >> 3); dx >= lim_x.0 && dx <= lim_x.1 && dy >= lim_y.0 && dy <= lim_y.1 }; + let lg = log2_table(); let mv_bits = |m: [i32; 2]| -> f64 { - let comp = |d: i32| if d == 0 { 0.5 } else { 2.0 + 2.0 * ((d.abs() / 8) as f64 + 1.0).log2() }; + let comp = |d: i32| if d == 0 { 0.5 } else { 2.0 + 2.0 * log2_of(lg, (d.abs() / 8) as usize + 1) }; comp(m[0] - pred_mv[0]) + comp(m[1] - pred_mv[1]) }; let cost = @@ -863,12 +870,18 @@ impl<'a> Decider<'a> { } forward_2d(res, coef, n, tx_type); // dead-zone quantiser: rounding offset below one half - let rnd = if intra { 0.38f32 } else { 0.30f32 }; + let rnd = if intra { INTRA_RND } else { INTER_RND }; + let [dc_thr, ac_thr] = [self.dead_zone[0][intra as usize], self.dead_zone[1][intra as usize]]; levels.fill(0); let mut nonzero = false; for (k, (&cf, l)) in coef.iter().zip(levels.iter_mut()).enumerate() { - let q = if k == 0 { self.dc_q } else { self.ac_q }; - let a = (cf.abs() / q + rnd).floor() as i32; + let (q, thr) = if k == 0 { (self.dc_q, dc_thr) } else { (self.ac_q, ac_thr) }; + // dead zone: below the exact threshold the level is zero, no division needed + if cf.abs() < thr { + continue; + } + // the operand is >= 0, so truncation equals floor + let a = (cf.abs() / q + rnd) as i32; if a != 0 { *l = if cf < 0.0 { -a.min(1 << 16) } else { a.min(1 << 16) }; nonzero = true; @@ -913,6 +926,33 @@ impl<'a> Decider<'a> { } } +/// Smallest `x >= 0` with `(x / q + rnd) as i32 >= 1` in f32 arithmetic. The expression is +/// monotone in `x`, so a binary search over the bit patterns finds the exact boundary. +fn zero_threshold(q: f32, rnd: f32) -> f32 { + let (mut lo, mut hi) = (0u32, f32::MAX.to_bits()); + while lo < hi { + let mid = lo + (hi - lo) / 2; + if (f32::from_bits(mid) / q + rnd) as i32 >= 1 { + hi = mid; + } else { + lo = mid + 1; + } + } + f32::from_bits(lo) +} + +const LOG2_TABLE_LEN: usize = 1025; + +/// `log2(i)` for `i < LOG2_TABLE_LEN`, computed with the same `f64::log2` call as the direct form. +fn log2_table() -> &'static [f64; LOG2_TABLE_LEN] { + static TABLE: std::sync::OnceLock<[f64; LOG2_TABLE_LEN]> = std::sync::OnceLock::new(); + TABLE.get_or_init(|| std::array::from_fn(|i| (i as f64).log2())) +} + +fn log2_of(table: &[f64; LOG2_TABLE_LEN], v: usize) -> f64 { + if v < LOG2_TABLE_LEN { table[v] } else { (v as f64).log2() } +} + /// Unnormalised Walsh-Hadamard transform of 4 or 8 values, in place. fn hadamard(v: &mut [i32]) { let n = v.len(); @@ -941,13 +981,14 @@ fn estimate_bits(levels: &[i32], tx_sz: usize) -> f64 { if eob == 0 { return 1.0; } - let mut bits = 3.0 + (eob as f64).log2(); + let lg = log2_table(); + let mut bits = 3.0 + log2_of(lg, eob); for &pos in &scan[..eob] { let a = levels[pos as usize].unsigned_abs(); bits += match a { 0 => 0.7, 1 => 2.5, - _ => 3.5 + 1.6 * (a as f64).log2(), + _ => 3.5 + 1.6 * log2_of(lg, a as usize), }; } bits @@ -961,3 +1002,64 @@ pub(crate) fn scan_for(tx_sz: usize) -> &'static [u16] { _ => &DEFAULT_SCAN_32X32, } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::tables::{AC_QLOOKUP, DC_QLOOKUP}; + + /// The quantiser as it was before the dead-zone threshold and the truncation. + fn quant_ref(cf: f32, q: f32, rnd: f32) -> i32 { + (cf.abs() / q + rnd).floor() as i32 + } + + fn quant_new(cf: f32, q: f32, rnd: f32, thr: f32) -> i32 { + if cf.abs() < thr { + return 0; + } + (cf.abs() / q + rnd) as i32 + } + + #[test] + fn dead_zone_threshold_is_exact() { + let mut state = 0x1234_5678_9abc_def0u64; + let mut next = move || { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + state + }; + for table in AC_QLOOKUP.iter().chain(DC_QLOOKUP.iter()) { + for &qi in table.iter() { + let q = qi as f32; + for rnd in [INTER_RND, INTRA_RND] { + let thr = zero_threshold(q, rnd); + // the boundary itself and its neighbours + let b = thr.to_bits(); + for bits in b.saturating_sub(3)..=b + 3 { + let cf = f32::from_bits(bits); + assert_eq!(quant_ref(cf, q, rnd), quant_new(cf, q, rnd, thr), "q {q} rnd {rnd} cf {cf}"); + assert_eq!(quant_ref(-cf, q, rnd), quant_new(-cf, q, rnd, thr)); + } + for _ in 0..200 { + let cf = f32::from_bits((next() >> 32) as u32 & 0x7fff_ffff); + if cf.is_nan() { + continue; + } + assert_eq!(quant_ref(cf, q, rnd), quant_new(cf, q, rnd, thr)); + let cf = (next() % 100_000) as f32 / 100.0; + assert_eq!(quant_ref(cf, q, rnd), quant_new(cf, q, rnd, thr)); + } + } + } + } + } + + #[test] + fn log2_table_matches_direct_log2() { + let t = log2_table(); + for v in 1..3000usize { + assert_eq!(log2_of(t, v).to_bits(), (v as f64).log2().to_bits(), "{v}"); + } + } +} diff --git a/crates/effects/src/audio_fx.rs b/crates/effects/src/audio_fx.rs index cd9153d66..2aab70309 100644 --- a/crates/effects/src/audio_fx.rs +++ b/crates/effects/src/audio_fx.rs @@ -206,6 +206,12 @@ pub fn reverses(id: &str) -> bool { id == "ec.audio.backwards" } +/// Does each output sample depend only on the input sample at the same time (no state), so the +/// mixer may run the effect in short pieces with parameters animated between them? +pub fn per_sample(id: &str) -> bool { + matches!(id, "ec.audio.stereomixer" | "ec.audio.backwards") +} + /// Seconds of input history the effect needs before a block to reach the state it would have /// had when processing continuously. pub fn preroll(id: &str, params: &Params) -> f64 { diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 80a937b22..85be739ed 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -182,6 +182,20 @@ mod tests { assert!(have == want, "docs/effects.md is stale: UPDATE_DOCS=1 cargo test -p effectcraft-effects --lib effects_doc_is_current"); } + /// The README quotes the number of effects (#2565): every count it gives is the registry's. + #[test] + fn readme_effect_counts_are_current() { + let readme = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../../README.md")).unwrap_or_default(); + let words: Vec<&str> = readme.split_whitespace().collect(); + let counts: Vec<&str> = words + .windows(3) + .filter(|w| w[0].parse::().is_ok() && (w[1].starts_with("effects") || (w[1] == "built-in" && w[2].starts_with("effects")))) + .map(|w| w[0]) + .collect(); + let n = super::registry().len().to_string(); + assert!(!counts.is_empty() && counts.iter().all(|c| *c == n), "README.md says {counts:?} effects; the registry has {n}"); + } + #[test] fn cpu_only_controls_keep_effects_off_the_gpu() { use effectcraft_keyframe::Value; diff --git a/crates/effects/src/channel3d.rs b/crates/effects/src/channel3d.rs index 08241107c..dc65c3255 100644 --- a/crates/effects/src/channel3d.rs +++ b/crates/effects/src/channel3d.rs @@ -7,6 +7,11 @@ //! pass for a precomp of a 3D comp. Without auxiliary channels they pass the layer through //! (EXtractoR still maps the layer's own R, G, B and A). //! +//! 3D Layer Instance (Create 3D Layer Instance's own effect, Layer ▸ Create) is not an aux +//! reader: it replaces the layer's pixels with a referenced layer's render — for a 3D model +//! layer, its render on its own through the comp's camera — so 2D effects can be applied to a +//! 3D layer's render. +//! //! Aux pixels are looked up nearest-neighbour (IDs must not be interpolated) at each buffer //! pixel's layer position, so render scale and layer padding are respected. @@ -582,6 +587,19 @@ fn extractor(ctx: &EffectCtx, mut b: Buf) -> Buf { b } +/// A 2D copy of a referenced layer (Create 3D Layer Instance's solid): the layer's pixels +/// replaced with the referenced layer's render, alpha included — the copy is transparent +/// where the referenced layer isn't. A 3D model or primitive layer is rendered on its own +/// through the comp's camera, as After Effects renders 3D layers; another layer is read with +/// its masks and effects. With no layer chosen the effect passes the layer through, as a layer +/// parameter with nothing chosen does. +fn instance_3d(ctx: &EffectCtx, mut b: Buf) -> Buf { + if let Some(o) = ctx.layer_param("layer", true) { + b.img = crate::util::fit_layer(ctx, &b, &o, false); + } + b +} + pub fn specs() -> Vec { let text = |id: &'static str, name: &'static str, d: &str| p(id, name, Value::Str(d.into()), ParamUi::Text); vec![ @@ -682,6 +700,7 @@ pub fn specs() -> Vec { ], identifier, ), + spec("ec.3d.instance3d", "3D Layer Instance", vec![p("layer", "Layer", Value::Layer(None), ParamUi::Layer)], instance_3d), ] } diff --git a/crates/effects/src/channel3d/tests.rs b/crates/effects/src/channel3d/tests.rs index 4fc84440e..e69c2a6aa 100644 --- a/crates/effects/src/channel3d/tests.rs +++ b/crates/effects/src/channel3d/tests.rs @@ -373,3 +373,29 @@ fn channel_extract_antialias_averages_sub_pixels() { let soft = run("ec.3d.channelextract", &[vals.as_slice(), &[("antialias", Value::Bool(true))]].concat(), &host); assert!((soft.get(5, 5)[0] - 0.5).abs() < 1e-5, "{:?}", soft.get(5, 5)); } + +#[test] +fn instance3d_replaces_the_pixels_with_the_referenced_layer() { + // Create 3D Layer Instance's copy: the chosen layer's render takes the place of the layer's + // own pixels, alpha included — the copy is transparent where the referenced layer isn't — + // and with no layer chosen the layer passes through. + struct Host(Image); + impl EffectHost for Host { + fn layer(&self, id: u64, _: bool) -> Option { + (id == 7).then(|| LayerPixels { buf: Buf { img: self.0.clone(), offset: [0.0; 2], scale: 1.0 }, size: [self.0.width as f64, self.0.height as f64] }) + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + } + let solid = || Image::filled(4, 4, [1.0, 1.0, 1.0, 1.0]); + let mut render = Image::new(4, 4); + render.set(1, 1, [1.0, 0.0, 0.0, 1.0]); + let env = EffectEnv { host: Some(&Host(render)), ..Default::default() }; + let out = run_fx("ec.3d.instance3d", &[("layer", Value::Layer(Some(7)))], solid(), 0.0, env); + assert_eq!(out.img.get(1, 1), [1.0, 0.0, 0.0, 1.0], "the referenced layer's pixel"); + assert_eq!(out.img.get(3, 3), [0.0; 4], "transparent where the referenced layer isn't"); + // No layer chosen: the layer's own pixels. + let out = run_fx("ec.3d.instance3d", &[], solid(), 0.0, EffectEnv::default()); + assert_eq!(out.img.data, solid().data); +} diff --git a/crates/effects/src/distort2.rs b/crates/effects/src/distort2.rs index db413544b..7add5fc58 100644 --- a/crates/effects/src/distort2.rs +++ b/crates/effects/src/distort2.rs @@ -95,6 +95,9 @@ fn turbulent_displace(ctx: &EffectCtx, mut b: Buf) -> Buf { let size = (ctx.params.f("size") * b.scale).max(1.0); if ctx.params.b("resizeLayer") && !ctx.adjustment { b.pad(amount.abs().ceil() as u32 + 1); + } else { + // Room for pixels displaced past the content, up to the layer's edge (#1205). + b.pad(crate::cover_pad(ctx, [b.img.width, b.img.height], b.offset, b.scale, amount.abs().ceil() as u32 + 1)); } let off = b.to_px(ctx.params.v2("offset")); let oct = ctx.params.f("complexity").clamp(1.0, 10.0); diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index a5534bf86..c865d4f96 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -312,17 +312,47 @@ impl Buf { self.offset[0] += l as f64; self.offset[1] += t as f64; } - /// Grow by `pad` transparent pixels on every side. + /// Grow by `left` and `top` transparent pixels on those sides only. + pub fn grow_left_top(&mut self, left: u32, top: u32) { + self.pad_sides([left, top, 0, 0]); + } + /// Give the buffer the pixel-grid phase (modulo [`GRID_ALIGN`]) that [`Buf::cover`] of + /// `size` would, by growing left/top only ([`align_plan`]). A downscaled layer is resampled + /// through a pyramid whose 2x2 blocks follow the buffer's grid, so a buffer that starts at + /// its content's corner instead of the cover's resamples to different pixels (#1205). + pub fn align(&mut self, size: [f64; 2]) { + if let Some((l, t)) = align_plan(self.offset, self.scale, size, self.img.width, self.img.height) { + self.grow_left_top(l, t); + } + } + /// Grow by `pad` transparent pixels on every side, within [`pad_within_budget`] and as + /// clamped by [`Image::effective_pad`]. pub fn pad(&mut self, pad: u32) { - if pad == 0 { + let eff = self.img.effective_pad(pad_within_budget(self.img.width, self.img.height, pad)); + if eff == 0 { return; } - self.img = self.img.padded(pad); - self.offset[0] += pad as f64; - self.offset[1] += pad as f64; + self.img = self.img.padded(eff); + self.offset[0] += eff as f64; + self.offset[1] += eff as f64; } } +/// `pad` on every side of a `w` × `h` effect buffer, cut so the padded buffer stays within +/// 16 Mpx (256 MiB of f32 RGBA) or four times its own area, whichever is larger: a small layer +/// far from an effect's centre can't ask for gigabytes (#2261). Shared with the GPU path. +pub fn pad_within_budget(w: u32, h: u32, pad: u32) -> u32 { + const MIN_BUDGET: f64 = 16_777_216.0; + let (w, h) = (f64::from(w), f64::from(h)); + let budget = MIN_BUDGET.max(4.0 * w * h); + if (w + 2.0 * f64::from(pad)) * (h + 2.0 * f64::from(pad)) <= budget { + return pad; + } + // (w + 2p)(h + 2p) = budget, solved for p. + let p = (((w - h).powi(2) + 4.0 * budget).sqrt() - (w + h)) / 4.0; + (p.floor().max(0.0) as u32).min(pad) +} + /// Motion Tile's output frame for a buffer with layer origin `offset` (pixels) at `scale`: /// `(width, height, offset)`. Output Width / Height grow (or crop) the layer rectangle about its /// centre, on adjustment layers too, where the rectangle is the comp's and starts at the comp @@ -360,6 +390,33 @@ pub fn adjustment_crop(offset: [f64; 2], canvas_offset: [f64; 2], size: (u32, u3 Some((dx as i64, dy as i64)) } +/// Effects that grow their own buffer by what they need (blur and glow margins, Repeat Edge +/// Pixels through [`Buf::cover`], displacement through [`cover_pad`]): a stack of only these +/// skips the renderer's cover of the whole layer rectangle (#1205). +pub const SKIPS_COVER: &[&str] = &["ec.blur.gaussian", "ec.blur.fastbox", "ec.stylize.glow", "ec.distort.turbulentdisplace"]; + +/// The grid phase [`Buf::align`] keeps and the step [`cover_pad`] grows in: covers the levels +/// of the resampling pyramid used for downscaled layers. +pub const GRID_ALIGN: u32 = 64; + +/// Left/top growth giving a buffer [`cover_plan`]'s grid phase modulo [`GRID_ALIGN`]; `None` +/// when there is nothing to add. +pub fn align_plan(offset: [f64; 2], scale: f64, size: [f64; 2], width: u32, height: u32) -> Option<(u32, u32)> { + let (dx, dy, _, _) = cover_plan(offset, scale, size, width, height)?; + let (l, t) = (dx % GRID_ALIGN, dy % GRID_ALIGN); + (l != 0 || t != 0).then_some((l, t)) +} + +/// Padding for an effect that moves pixels up to `want` past the content (not an adjustment +/// layer): `want`, capped at what reaches the layer rectangle, in whole [`GRID_ALIGN`] steps so +/// the grid phase stays. 0 once the buffer covers the layer. +pub fn cover_pad(ctx: &EffectCtx, size: [u32; 2], offset: [f64; 2], scale: f64, want: u32) -> u32 { + if ctx.adjustment { + return 0; + } + want.min(util::layer_cover_pad(ctx, size, offset, scale)).div_ceil(GRID_ALIGN) * GRID_ALIGN +} + /// Where [`Buf::cover`] would take a buffer: shift its pixels by `(dx, dy)` and resize it to /// `width` × `height` so it spans the `size`-layer-pixel region that starts at its offset — the /// whole layer rectangle, which a sized source's buffer already is. `None` when the buffer @@ -538,6 +595,15 @@ pub struct EffectSpec { pub float: bool, } +impl EffectSpec { + /// True when this effect grows its own buffer (blur/glow margins, Repeat Edge + /// [`Buf::cover`], displacement [`cover_pad`]) so a stack of only these can skip the + /// renderer's full-layer cover and keep the pyramid grid phase (#1205). + pub fn skips_cover(&self) -> bool { + SKIPS_COVER.contains(&self.id) + } +} + pub(crate) fn p(id: &'static str, name: &'static str, default: Value, ui: ParamUi) -> ParamSpec { ParamSpec { id, name, default, ui } } @@ -1344,6 +1410,23 @@ pub(crate) fn run_fx(id: &str, vals: &[(&str, Value)], img: Image, time: f64, en mod tests { use super::*; + /// #2261: a 20 × 20 layer with CC Radial Fast Blur's Center far away grows to at most 16 Mpx; + /// big layers may still grow to four times their area, and small pads are untouched. + #[test] + fn padded_buffers_stay_within_the_budget() { + let area = |w: u32, h: u32, p: u32| (u64::from(w) + 2 * u64::from(p)) * (u64::from(h) + 2 * u64::from(p)); + let far = radial_fast_pad((6500.0, 6500.0), 20, 20, 0.8); + assert_eq!(far, 4096, "the effect asks for its full pad"); + let mut b = Buf { img: Image::new(20, 20), offset: [0.0; 2], scale: 1.0 }; + b.pad(far); + assert!(area(20, 20, 0) < area(b.img.width, b.img.height, 0) && area(b.img.width, b.img.height, 0) <= 1 << 24, "{}x{}", b.img.width, b.img.height); + assert_eq!(b.offset, [f64::from((b.img.width - 20) / 2); 2], "the content keeps its place"); + let p = pad_within_budget(3840, 2160, 4096); + assert!(p > 1000 && area(3840, 2160, p) <= 4 * 3840 * 2160 && area(3840, 2160, p + 1) > 4 * 3840 * 2160); + assert_eq!(pad_within_budget(640, 360, 200), 200); + assert_eq!(pad_within_budget(20, 20, 0), 0); + } + #[test] fn run_fx_passthrough() { let img = Image::filled(4, 3, [0.5, 0.25, 0.125, 1.0]); @@ -1351,6 +1434,20 @@ mod tests { assert_eq!(out.img.data, img.data); } + #[test] + fn pad_offset_follows_the_clamped_pad() { + // Image::padded clamps a hostile pad to keep each side within MAX_PADDED_SIDE; the offset + // must move by the pad actually applied, or the content drifts from its layer position. + let mut img = Image::new(29_990, 2); + img.set(0, 0, [1.0, 0.0, 0.0, 1.0]); + let mut buf = Buf { img, offset: [3.0, 4.0], scale: 1.0 }; + buf.pad(u32::MAX); + // Width binds: (MAX_PADDED_SIDE - 29 990) / 2 = 5. + assert_eq!(buf.offset, [8.0, 9.0]); + let (x, y) = buf.to_px([-3.0, -4.0]); + assert_eq!(buf.img.get(x as i64, y as i64), [1.0, 0.0, 0.0, 1.0]); + } + #[test] #[ignore] fn dump_registry() { @@ -1535,4 +1632,24 @@ mod tests { assert_eq!(g.get("noiseSourceLayerSource").map(|p| p.value.clone()), Some(Value::Enum(0))); assert_eq!(g.props().filter(|p| p.match_id == "noiseSourceLayerSource").count(), 1); } + + #[test] + fn align_plan_is_cover_shift_mod_grid() { + // Content inset from the layer origin: pixel 0 maps to layer (50, 30). + let offset = [-50.0, -30.0]; + let scale = 1.0; + let size = [1920.0, 1080.0]; + let (dx, dy, _, _) = cover_plan(offset, scale, size, 80, 40).unwrap(); + assert_eq!((dx, dy), (50, 30)); + assert_eq!(align_plan(offset, scale, size, 80, 40), Some((50, 30))); + assert_eq!(align_plan([-64.0, -128.0], 1.0, size, 80, 40), None); + } + + #[test] + fn known_margin_effects_skip_cover_and_twirl_does_not() { + for id in SKIPS_COVER { + assert!(find(id).expect(id).skips_cover(), "{id}"); + } + assert!(!find("ec.distort.twirl").unwrap().skips_cover()); + } } diff --git a/crates/effects/src/misc.rs b/crates/effects/src/misc.rs index e1f41954c..15d5d0aac 100644 --- a/crates/effects/src/misc.rs +++ b/crates/effects/src/misc.rs @@ -34,6 +34,9 @@ fn gaussian(ctx: &EffectCtx, mut b: Buf) -> Buf { let repeat = ctx.params.b("repeatEdge"); if !repeat && !ctx.adjustment { b.pad((s * 3.0).ceil() as u32); + } else if repeat && !ctx.adjustment { + // The edge it repeats is the layer's, not the painted content's (#1205). + b.cover(ctx.layer_size); } let (sx, sy) = (s * kx, s * ky); // Small sigmas: integer-radius box passes would step (and do nothing below ~0.6 px), so sample the kernel (#1939). @@ -60,6 +63,9 @@ fn box_blur(ctx: &EffectCtx, mut b: Buf) -> Buf { if !repeat && !ctx.adjustment { let reach = if frac > 0.0 { r + 1 } else { r }; b.pad((reach * it) as u32 + 1); + } else if repeat && !ctx.adjustment { + // The edge it repeats is the layer's, not the painted content's (#1205). + b.cover(ctx.layer_size); } let edge = repeat || ctx.adjustment; let lo = effectcraft_raster::box_blur(&b.img, r * kx as usize, r * ky as usize, it, edge); diff --git a/crates/effects/src/utility.rs b/crates/effects/src/utility.rs index 1d3b3ee4a..e41b42e99 100644 --- a/crates/effects/src/utility.rs +++ b/crates/effects/src/utility.rs @@ -100,17 +100,11 @@ impl Cineon { fn knee(&self) -> f32 { 1.0 - (self.rolloff / 100.0).clamp(0.0, 0.99) } - /// Normalised code value (code / 1023) → linear. + /// Normalized code value (code / 1023) → linear, raised to `1.7 / gamma` + /// ([`effectcraft_project::cineon_log_to_lin`], which Interpret Footage ▸ Cineon Settings + /// also uses with `1 / gamma`). pub fn log_to_lin(&self, v: f32) -> f32 { - let off = self.offset(); - let code = v * 1023.0; - let mut lin = (10f32.powf((code - self.white) * 0.002 / 0.6) - off) / (1.0 - off).max(1e-6); - lin = lin.max(0.0).powf(1.7 / self.gamma.max(0.01)); - let k = self.knee(); - if k < 1.0 && lin > k { - let w = 1.0 - k; - lin = k + w * (1.0 - (-(lin - k) / w).exp()); - } + let lin = effectcraft_project::cineon_log_to_lin(v, self.black, self.white, 1.7, self.gamma, self.rolloff); self.ib + (self.iw - self.ib) * lin } /// Linear → normalised code value. @@ -495,6 +489,87 @@ mod tests { assert!(c.log_to_lin(95.0 / 1023.0).abs() < 1e-4); } + /// Interpret Footage ▸ Cineon Settings and the Cineon Converter's Log to Linear give the same + /// values for the same points and Highlight Rolloff when the converter's Gamma is 1.7 times + /// Cineon Settings' Gamma: the converter's default 1.7 and Cineon Settings' Gamma 1 both give + /// linear light (code 400 is 0.10251). + #[test] + fn cineon_converter_matches_interpret_footage() { + let codes = [0.0f32, 95.0, 250.0, 400.0, 560.0, 685.0, 800.0, 1023.0]; + for (gamma, rolloff) in [(1.0, 20.0), (1.0, 0.0), (1.7, 50.0), (2.4, 50.0)] { + let mut img = Image::new(codes.len() as u32, 1); + for (x, code) in codes.iter().enumerate() { + let v = code / 1023.0; + img.set(x as u32, 0, [v, v, v, 1.0]); + } + let vals = [("conversionType", Value::Enum(1)), ("gamma", num(gamma * 1.7)), ("highlightRolloff", num(rolloff))]; + let out = run("ec.utility.cineon", &vals, img); + let settings = effectcraft_project::CineonSettings { log_conversion: true, gamma: gamma as f32, rolloff: rolloff as f32, ..Default::default() }; + for (x, code) in codes.iter().enumerate() { + let footage = settings.convert(code / 1023.0); + let effect = out.img.data[x][0]; + assert!((footage - effect).abs() < 1e-6, "gamma {gamma}, rolloff {rolloff}, code {code}: {footage} vs {effect}"); + } + } + let linear = effectcraft_project::CineonSettings { gamma: 1.0, rolloff: 0.0, ..Default::default() }; + assert!((linear.convert(400.0 / 1023.0) - 0.10251).abs() < 1e-4); + } + + /// The Cineon Converter gives the same values as before Cineon Settings shared its + /// log-to-linear function, at the default Gamma (1.7) and at Gamma 2.4, for all three + /// conversion types. + #[test] + fn cineon_converter_output_is_unchanged() { + fn log_to_lin(v: f32, gamma: f32, rolloff: f32, black: f32, white: f32) -> f32 { + let off = 10f32.powf((black - white) * 0.002 / 0.6); + let mut lin = (10f32.powf((v * 1023.0 - white) * 0.002 / 0.6) - off) / (1.0 - off).max(1e-6); + lin = lin.max(0.0).powf(1.7 / gamma.max(0.01)); + let k = 1.0 - (rolloff / 100.0).clamp(0.0, 0.99); + if k < 1.0 && lin > k { + let w = 1.0 - k; + lin = k + w * (1.0 - (-(lin - k) / w).exp()); + } + lin + } + fn lin_to_log(v: f32, gamma: f32, rolloff: f32, black: f32, white: f32) -> f32 { + let off = 10f32.powf((black - white) * 0.002 / 0.6); + let mut lin = v; + let k = 1.0 - (rolloff / 100.0).clamp(0.0, 0.99); + if k < 1.0 && lin > k { + let w = 1.0 - k; + lin = k - w * (1.0 - (lin - k) / w).max(1e-6).ln(); + } + lin = lin.max(0.0).powf(gamma.max(0.01) / 1.7); + (white + (lin * (1.0 - off) + off).max(1e-9).log10() * 0.6 / 0.002) / 1023.0 + } + let codes = [0.0f32, 95.0, 250.0, 400.0, 560.0, 685.0, 800.0, 1023.0]; + for gamma in [None, Some(2.4)] { + for kind in 0..3 { + let mut img = Image::new(codes.len() as u32, 1); + for (x, code) in codes.iter().enumerate() { + let v = code / 1023.0; + img.set(x as u32, 0, [v, v, v, 1.0]); + } + let mut vals = vec![("conversionType", Value::Enum(kind))]; + if let Some(g) = gamma { + vals.push(("gamma", num(g))); + } + let out = run("ec.utility.cineon", &vals, img); + let g = gamma.unwrap_or(1.7) as f32; + for (x, code) in codes.iter().enumerate() { + let v = code / 1023.0; + let want = match kind { + 0 => lin_to_log(v, g, 20.0, 95.0, 685.0), + 1 => log_to_lin(v, g, 20.0, 95.0, 685.0), + _ => lin_to_log(log_to_lin(v, g, 20.0, 95.0, 685.0), 1.7, 20.0, 95.0, 685.0), + }; + let got = out.img.data[x][0]; + assert!((want - got).abs() < 1e-6, "gamma {g}, type {kind}, code {code}: {want} vs {got}"); + } + } + } + } + #[test] fn hdr_compression_behaviour() { let mut img = Image::new(2, 1); diff --git a/crates/engine/presets/text_animators.json b/crates/engine/presets/text_animators.json index 20e142268..e82707a52 100644 --- a/crates/engine/presets/text_animators.json +++ b/crates/engine/presets/text_animators.json @@ -77,7 +77,7 @@ "name": "Tracking In", "properties": { "tracking": 0, "opacity": 100 }, "keys": { - "tracking": [[0, 400], [1.6, 0]], + "tracking": [[0, 40], [1.6, 0]], "opacity": [[0, 0], [0.8, 100]] }, "ease": true, diff --git a/crates/engine/src/analysis/tracking.rs b/crates/engine/src/analysis/tracking.rs index 434cdd8af..221b28602 100644 --- a/crates/engine/src/analysis/tracking.rs +++ b/crates/engine/src/analysis/tracking.rs @@ -197,7 +197,8 @@ pub(crate) struct Work { } /// The layer's source frame at comp time `t` at 100 % (layer pixels + offset = image pixels). -/// Footage at its native size is used straight from the footage source (no copy). +/// Footage at its native size is used straight from the footage source (no copy unless its +/// Cineon Settings convert it). pub(crate) fn source_frame( r: &Renderer, project: &Project, @@ -207,7 +208,7 @@ pub(crate) fn source_frame( t: Tick, expr: Option<&dyn ExprHost>, ) -> Option<(Arc, [f64; 2])> { - let ctx = EvalCtx { project, comp_id: cid, comp, time: t, expr, footage: None }; + let ctx = EvalCtx { project, comp_id: cid, comp, time: t, expr, footage: None, property_overrides: &[], media_overrides: &[] }; if let effectcraft_project::LayerSource::Footage { item } = &layer.source && let Some(effectcraft_project::ItemKind::Footage(f)) = project.item(*item).map(|i| &i.kind) && f.has_video @@ -215,7 +216,7 @@ pub(crate) fn source_frame( && img.width == f.width && img.height == f.height { - return Some((img, [0.0; 2])); + return Some((effectcraft_render::cineon_frame(img, f), [0.0; 2])); } let buf = r.layer_source(&ctx, layer)?; if (buf.scale - 1.0).abs() > 1e-9 && buf.scale > 0.0 { diff --git a/crates/engine/src/cmd/app_more.rs b/crates/engine/src/cmd/app_more.rs index f607c8288..29597a3bc 100644 --- a/crates/engine/src/cmd/app_more.rs +++ b/crates/engine/src/cmd/app_more.rs @@ -152,7 +152,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_with_view(&s.saved_view())?).map_err(EngineError::Other)?; + let xml = crate::xml_project::to_xml(&s.project.to_file_json_with_view(&s.saved_view(), false)?).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()})) @@ -240,7 +240,7 @@ fn replace_with_precomp(s: &mut Session, p: &Value) -> Result { let ids: Vec = proj.items.keys().copied().filter(|c| *c != comp).collect(); for cid in ids { if let Some(c) = proj.comp_mut(cid) { - for l in c.layers.iter_mut().filter(|l| is_user(l)) { + for l in c.layers.iter_mut_where(|l| is_user(l)) { l.source = LayerSource::Comp { item: comp }; n += 1; } diff --git a/crates/engine/src/cmd/batch.rs b/crates/engine/src/cmd/batch.rs index f6e6901cc..5931fe09d 100644 --- a/crates/engine/src/cmd/batch.rs +++ b/crates/engine/src/cmd/batch.rs @@ -9,8 +9,9 @@ use serde_json::{Value, json}; use super::{CommandSpec, always, bad, str_p}; use crate::{EngineError, Result, Session}; -/// Commands that can't run inside a batch (they replace the project or nest batches). -const NOT_IN_BATCH: &[&str] = &["engine.batch", "file.runScript", "script.run"]; +/// Commands that can't run inside a batch (they nest batches), besides the commands that run +/// scripts ([`super::scripts::RUNS_SCRIPTS`]). +const NOT_IN_BATCH: &[&str] = &["engine.batch"]; /// Resolve a `$N` / `$N.key.0.key` reference against the results so far. fn reference(s: &str, results: &[Value]) -> Option> { @@ -78,7 +79,7 @@ fn batch(s: &mut Session, p: &Value) -> Result { .or_else(|| step.get("id")) .and_then(Value::as_str) .ok_or_else(|| bad("engine.batch", "each step needs `command` (a command id)"))?; - if NOT_IN_BATCH.contains(&id) { + if NOT_IN_BATCH.contains(&id) || super::scripts::RUNS_SCRIPTS.contains(&id) { return Err(bad("engine.batch", format!("`{id}` can't run inside a batch"))); } let params = substitute(step.get("params").unwrap_or(&json!({})), results)?; diff --git a/crates/engine/src/cmd/comp.rs b/crates/engine/src/cmd/comp.rs index 6648a7426..8bc0931f8 100644 --- a/crates/engine/src/cmd/comp.rs +++ b/crates/engine/src/cmd/comp.rs @@ -35,7 +35,7 @@ fn apply_settings(c: &mut Comp, p: &Value) { let dx = (c.width as f64 - ow as f64) * (a % 3) as f64 / 2.0; let dy = (c.height as f64 - oh as f64) * (a / 3) as f64 / 2.0; if dx != 0.0 || dy != 0.0 { - for l in c.layers.iter_mut().filter(|l| l.parent.is_none()) { + for l in c.layers.iter_mut_where(|l| l.parent.is_none()) { if let Some(tr) = l.transform_mut() { super::layer::offset_position(tr, dx, dy); } diff --git a/crates/engine/src/cmd/comp_more.rs b/crates/engine/src/cmd/comp_more.rs index d6de320ec..83c49bad2 100644 --- a/crates/engine/src/cmd/comp_more.rs +++ b/crates/engine/src/cmd/comp_more.rs @@ -5,7 +5,7 @@ use effectcraft_project::Marker; use effectcraft_time::Tick; use serde_json::{Value, json}; -use super::{CommandSpec, always, bad, comp_id, f_p, has_comp, has_layers, layers_p, str_p}; +use super::{CommandSpec, always, b_p, bad, comp_id, f_p, has_comp, has_layers, layers_p, str_p}; use crate::prefs::CompPreset; use crate::{EngineError, Result, Session, cmd, query}; @@ -25,7 +25,7 @@ fn crop(s: &mut Session, cid: effectcraft_project::ItemId, r: [f64; 4], label: & let (old_w, old_h) = (c.width, c.height); c.width = w; c.height = h; - for l in c.layers.iter_mut().filter(|l| l.parent.is_none()) { + for l in c.layers.iter_mut_where(|l| l.parent.is_none()) { if let Some(tr) = l.transform_mut() { super::layer::offset_position(tr, -x, -y); } @@ -82,7 +82,7 @@ pub(crate) fn encode_png(rgba: &[u8], w: u32, h: u32) -> std::result::Result Result { - use effectcraft_project::render_queue::{RenderQueueItem, TimeSpan}; + use effectcraft_project::render_queue::{Channels, RenderQueueItem, TimeSpan}; use effectcraft_project::render_templates::TemplateSlot; let cid = comp_id(s, p)?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; @@ -91,6 +91,9 @@ fn queue_frame(s: &mut Session, p: &Value) -> Result { it.settings = s.project.render_templates.default_render_settings(TemplateSlot::Still); it.settings.time_span = TimeSpan::Custom { start: t, end: t + comp.frame_duration() }; it.output = s.project.render_templates.default_output_module(TemplateSlot::Still); + if b_p(p, "transparent") == Some(false) { + it.output.channels = Channels::Rgb; + } if let Some(path) = str_p(p, "path") { it.output.output = path.to_string(); } else if !it.output.format.is_sequence() { @@ -107,7 +110,7 @@ fn queue_frame(s: &mut Session, p: &Value) -> Result { } fn save_frame(s: &mut Session, p: &Value) -> Result { - if p.get("queue").and_then(Value::as_bool) == Some(true) { + if b_p(p, "queue") == Some(true) { return queue_frame(s, p); } let path = str_p(p, "path").ok_or_else(|| bad("comp.saveFrameAs", "missing `path` (.png)"))?.to_string(); @@ -117,7 +120,7 @@ fn save_frame(s: &mut Session, p: &Value) -> Result { let scale = f_p(p, "scale").unwrap_or(1.0).clamp(0.01, 4.0); let bg = comp.background; let img = s.render(cid, t, effectcraft_render::RenderOpts { scale, backend: effectcraft_render::Backend::Auto, ..Default::default() }); - let rgba = img.to_rgba8_over(bg); + let rgba = if b_p(p, "transparent") == Some(false) { img.to_rgba8_over(bg) } else { img.to_rgba8() }; let png = encode_png(&rgba, img.width, img.height).map_err(|e| EngineError::Other(format!("PNG encoding failed: {e}")))?; s.services.write_file(&path, &png).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?; s.toast(format!("Saved frame to {path}")); @@ -235,7 +238,7 @@ pub fn specs() -> Vec { "File...", ["Composition", "Save Frame As"], Some("Cmd+Alt+S"), - "{path (.png; with queue: an output path or template), time?, scale?, queue?: bool (add a Render Queue item from the Frame Default templates instead of writing now)}", + "{path (.png; with queue: an output path or template), time?, scale?, queue?: bool (add a Render Queue item from the Frame Default templates instead of writing now), transparent?: bool (default true)}", has_comp, save_frame ), diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 850d38ada..6c4b02c87 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -1,14 +1,17 @@ //! Layer ▸ Create: Convert to Editable Text (Photoshop type layers), Create Shapes from Text, -//! Create Masks from Text and Create Shapes from Vector Layer (SVG footage). +//! Create Masks from Text, Create Shapes from Vector Layer (SVG footage) and Create 3D Layer +//! Instance (a 2D copy of a 3D model layer that 2D effects can be applied to). use effectcraft_color::Label; +use effectcraft_keyframe::Value as KV; +use effectcraft_project::build::{self, Ids}; use effectcraft_project::{ItemKind, LayerSource, Solid}; use serde_json::{Value, json}; use super::{CommandSpec, bad, has_layers, layers_p, str_p}; use crate::{EngineError, Result, Session, cmd}; -const OPS: &str = "editableText|shapesFromText|masksFromText|shapesFromVector"; +const OPS: &str = "editableText|shapesFromText|masksFromText|shapesFromVector|instance3d"; fn create(s: &mut Session, p: &Value) -> Result { let op = str_p(p, "op").ok_or_else(|| bad("layer.create", format!("missing `op` ({OPS})")))?; @@ -19,6 +22,69 @@ fn create(s: &mut Session, p: &Value) -> Result { let time = s.time(); let mut created = vec![]; let mut skipped = vec![]; + // All the selected models in one edit: one undo (or redo) removes (or restores) every + // instance at once, as one command in the History panel. + if op == "instance3d" { + // After Effects' Create 3D Layer Instance (Layer ▸ Create): 3D model layers take + // no masks or effects, so a comp-sized solid goes at the top of the layer stack + // with a 3D Layer Instance effect referencing the model — a 2D copy of its render, + // through the comp's camera, that 2D effects can be applied to. The model is switched + // off, as After Effects does (back on to keep its shadow). + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); + let mut eligible = vec![]; + for lid in &lids { + match comp.layer(*lid) { + Some(src) if src.source.is_model() => eligible.push((*lid, src.clone())), + Some(src) => skipped.push(format!("{}: not a 3D model layer", src.name)), + None => {} + } + } + if !eligible.is_empty() { + let ids = s.edit("Create 3D Layer Instance", None, |proj, st| { + let mut ids = vec![]; + for (lid, src) in &eligible { + let name = proj.comp(cid).ok_or(EngineError::NoComp)?.unique_layer_name(&format!("{} Instance", src.name)); + let folder = proj.folder_named("Solids").unwrap_or_else(|| proj.add_item("Solids", Label::Yellow, None, ItemKind::Folder)); + let sid = proj.add_item( + &name, + src.label, + Some(folder), + ItemKind::Solid(Solid { color: [1.0, 1.0, 1.0], width: comp.width, height: comp.height, pixel_aspect: 1.0 }), + ); + let mut l = build::layer(proj, &comp, &name, LayerSource::Solid { item: sid }, (comp.width, comp.height), None); + // A soloed source keeps its Solo switch with video off, and any_solo hides + // the non-soloed copy: the copy takes the Solo over to stay visible. + l.switches.solo = src.switches.solo; + // A 3D Layer Instance effect referencing the 3D layer: the solid shows its + // render (the 2D copy). + let fx = effectcraft_effects::find("ec.3d.instance3d").ok_or_else(|| bad("layer.create", "the 3D Layer Instance effect is missing"))?; + let mut g = effectcraft_effects::instantiate(fx, &mut Ids(&mut proj.next_id), fx.name, [comp.width as f64, comp.height as f64]); + if let Some(pr) = g.get_mut("layer") { + pr.value = KV::Layer(Some(lid.0)); + } + if let Some(fxg) = l.props.sub_mut("effects") { + fxg.children.push(g.into()); + } + // The model is switched off (After Effects turns the referenced layer off); + // the copies go at the top of the stack, selected. + let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + if let Some(m) = c.layers.iter_mut().find(|x| x.id == *lid) { + m.switches.video = false; + } + let id = l.id; + c.layers.insert(0, l); + ids.push(id); + } + st.selected_layers = ids.clone(); + Ok(ids) + })?; + created.extend(ids.iter().map(|i| i.0)); + } + if created.is_empty() && !skipped.is_empty() { + return Err(bad("layer.create", skipped.join("; "))); + } + return Ok(json!({"layers": created, "skipped": skipped})); + } for lid in lids { let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); let Some(src) = comp.layer(lid).cloned() else { continue }; @@ -198,5 +264,5 @@ fn insert_above( } pub fn specs() -> Vec { - vec![cmd!("layer.create", "Create", [], None, "{op: editableText|shapesFromText|masksFromText|shapesFromVector, layers?}", has_layers, create)] + vec![cmd!("layer.create", "Create", [], None, "{op: editableText|shapesFromText|masksFromText|shapesFromVector|instance3d, layers?}", has_layers, create)] } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index bfc05933e..dd1685722 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -74,9 +74,9 @@ fn select_all(s: &mut Session, _: &Value) -> Result { Ok(json!(s.state.selected_layers.len())) } fn deselect_all(s: &mut Session, _: &Value) -> Result { - // A deselected layer never keeps an open text edit: commit the edit first, exactly as - // `text.endEdit` (an empty Type-tool layer included), so every route — menu, shortcut, - // script, MCP — leaves one final state. + // A deselected layer never keeps an open text edit: commit it first, exactly as `text.endEdit`, + // so every route — menu, shortcut, script, MCP — leaves one final state. An empty Type-tool + // layer survives, as it does in After Effects. super::text_edit::end_edit(s, &Value::Null)?; s.state.selected_layers.clear(); s.state.selected_props.clear(); @@ -90,7 +90,7 @@ fn deselect_all(s: &mut Session, _: &Value) -> Result { fn relink_copies(comp: &mut Comp, map: &[(LayerId, LayerId)]) { let present: Vec = comp.layers.iter().map(|l| l.id).collect(); let link = |id: LayerId| map.iter().find(|(o, _)| *o == id).map(|(_, c)| *c).or(present.contains(&id).then_some(id)); - for l in comp.layers.iter_mut().filter(|l| map.iter().any(|(_, c)| *c == l.id)) { + for l in comp.layers.iter_mut_where(|l| map.iter().any(|(_, c)| *c == l.id)) { l.parent = l.parent.and_then(link); l.track_matte = l.track_matte.and_then(|m| link(m.layer).map(|layer| TrackMatte { layer, ..m })); } @@ -146,6 +146,11 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { st.selected_layers = created.clone(); Ok(created) })?; + // Duplicating selects the copies, so an open text edit on a now-deselected layer ends + // (#2226): typing must not keep editing an unselected layer. + if s.state.text_edit.as_ref().is_some_and(|e| !s.state.selected_layers.contains(&e.layer)) { + super::text_edit::end_edit(s, &Value::Null)?; + } Ok(json!(new.iter().map(|l| l.0).collect::>())) } @@ -680,7 +685,7 @@ fn label(s: &mut Session, p: &Value) -> Result { } } if let Some(comp) = proj.comp_mut(cid) { - for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) { + for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id)) { l.label = lab; } } diff --git a/crates/engine/src/cmd/effect.rs b/crates/engine/src/cmd/effect.rs index 003993fde..32b0aaa2b 100644 --- a/crates/engine/src/cmd/effect.rs +++ b/crates/engine/src/cmd/effect.rs @@ -136,7 +136,16 @@ fn warning(s: &mut Session, p: &Value) -> Result { let layer = comp.layer(lid).ok_or(EngineError::NoComp)?; let g = layer.effects().and_then(|fx| fx.groups().find(|g| g.uid == uid)).ok_or_else(|| bad(cmd, "no such effect"))?; let t = super::time_p(s, p, Some(comp)); - let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None }; + let ctx = effectcraft_render::EvalCtx { + project: &s.project, + comp_id: cid, + comp, + time: t, + expr: s.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; Ok(json!({"effect": uid, "warning": effectcraft_effects::warning(g, &mut |pr| ctx.value(layer, pr))})) } @@ -160,7 +169,16 @@ fn pick_color(s: &mut Session, p: &Value) -> Result { .filter(|pr| pr.ui == effectcraft_project::ParamUi::Color) .ok_or_else(|| bad(cmd, "not a colour parameter of this effect"))?; let t = super::time_p(s, p, Some(comp)); - let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None }; + let ctx = effectcraft_render::EvalCtx { + project: &s.project, + comp_id: cid, + comp, + time: t, + expr: s.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let mut r = effectcraft_render::Renderer::new(&s.project, &*s.footage, effectcraft_render::RenderOpts::default()); r.expr = s.expr.as_deref(); r.cache = Some(&s.layer_cache); @@ -288,7 +306,16 @@ impl CryptoProbe { let comp = r.project.comp(cid).ok_or("no composition")?; let layer = comp.layer(lid).ok_or("no such layer")?; let g = layer.effects().and_then(|fx| fx.groups().nth(pos)).ok_or("no such effect")?; - let ctx = effectcraft_render::EvalCtx { project: r.project, comp_id: cid, comp, time, expr: r.expr, footage: None }; + let ctx = effectcraft_render::EvalCtx { + project: r.project, + comp_id: cid, + comp, + time, + expr: r.expr, + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let params = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr)); let want = effectcraft_effects::CRYPTO_LAYERS.get(params.e("layer") as usize).copied().unwrap_or("CryptoAsset"); let aux = r.layer_aux(&ctx, layer).ok_or(NO_CRYPTO)?; @@ -351,7 +378,16 @@ fn probe_key<'a>(s: &'a Session, p: &Value, cmd: &str) -> Result<(ProbeKey, Tick let g = fx.groups().nth(pos).ok_or_else(|| bad(cmd, "no such effect"))?; let prop = g.prop("selection").filter(|_| g.match_id == "ec.3d.cryptomatte").ok_or_else(|| bad(cmd, "not a Cryptomatte effect"))?; let time = super::time_p(s, p, Some(comp)); - let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time, expr: s.expr.as_deref(), footage: None }; + let ctx = effectcraft_render::EvalCtx { + project: &s.project, + comp_id: cid, + comp, + time, + expr: s.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let frame = probe_frame(&ctx, layer, pos); let o = s.render_opts(Default::default()); let prefs = (o.nested_switches, o.draft_shadows); @@ -503,7 +539,16 @@ fn crypto_hit(s: &Session, p: &Value, cmd: &str) -> Result { let Some(hash) = b.aux.index_at(ax, ay).and_then(|i| b.aux.crypto_id_at(&b.layer, i)) else { return Ok(Hit::Nothing) }; let (cid, lid, ..) = key.at; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; - let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time, expr: s.expr.as_deref(), footage: None }; + let ctx = effectcraft_render::EvalCtx { + project: &s.project, + comp_id: cid, + comp, + time, + expr: s.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let selection = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr)).s("selection").to_string(); let driven = prop.expr.as_ref().is_some_and(|e| e.enabled); let (aux, layer_name) = (b.aux.clone(), b.layer.clone()); diff --git a/crates/engine/src/cmd/expr_tools.rs b/crates/engine/src/cmd/expr_tools.rs index fae74a5b5..cb39077af 100644 --- a/crates/engine/src/cmd/expr_tools.rs +++ b/crates/engine/src/cmd/expr_tools.rs @@ -163,6 +163,8 @@ pub fn language_menu() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> ("easeIn(t, tMin, tMax, value1, value2)", "easeIn(t, tMin, tMax, value1, value2)"), ("easeOut(t, value1, value2)", "easeOut(t, value1, value2)"), ("easeOut(t, tMin, tMax, value1, value2)", "easeOut(t, tMin, tMax, value1, value2)"), + ("bezier(t, value1, value2, [x1, y1, x2, y2])", "bezier(t, value1, value2, [x1, y1, x2, y2])"), + ("bezier(t, tMin, tMax, value1, value2, [x1, y1, x2, y2])", "bezier(t, tMin, tMax, value1, value2, [x1, y1, x2, y2])"), ], ), ( diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index b3922c7d6..e1f58a1cc 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -87,7 +87,8 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result { /// 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_with_view(&s.saved_view())?; + let compact = s.prefs.project.compact_project_files && !crate::xml_project::is_xml_path(path); + let json = s.project.to_file_json_with_view(&s.saved_view(), compact)?; if crate::xml_project::is_xml_path(path) { crate::xml_project::to_xml(&json).map_err(EngineError::Other) } else { Ok(json) } } @@ -124,7 +125,8 @@ fn revert(s: &mut Session, _: &Value) -> Result { } /// File ▸ Import; with `addToComp` (files dropped on the Timeline or the Composition viewer) they -/// also become layers of the active comp ([`add_to_comp`]). +/// also become layers of the active comp ([`add_to_comp`]); with `newComp` (the empty viewer's +/// New Composition From Footage) they become one new composition instead ([`new_comp`]). /// /// With `background` (files dropped on the window or picked in the Import dialog) the files are /// probed on a background thread, so decoding a slow video doesn't freeze the app: the job shows @@ -174,10 +176,10 @@ fn import_cmd(s: &mut Session, p: &Value) -> Result { Ok(r) } -/// After an import (its result `r`): with `addToComp`, the new items become layers of `target`; -/// what couldn't be added joins the result's errors. +/// After an import (its result `r`): with `newComp`, the new items become one new composition; +/// otherwise with `addToComp`, layers of `target`. What couldn't be added joins the result's errors. fn finish_import(s: &mut Session, r: &mut Value, target: Option, p: &Value) { - let errors = add_to_comp(s, r, target, p); + let errors = if b_p(p, "newComp").unwrap_or(false) { new_comp(s, r) } else { add_to_comp(s, r, target, p) }; if !errors.is_empty() && let Some(e) = r.get_mut("errors").and_then(Value::as_array_mut) { @@ -191,18 +193,7 @@ fn finish_import(s: &mut Session, r: &mut Value, target: Option, p: &Val /// Project panel) and the other footage. `target` stays open. Returns what couldn't be added. pub(crate) fn add_to_comp(s: &mut Session, r: &Value, target: Option, p: &Value) -> Vec { let Some(cid) = target.filter(|_| p.get("addToComp").and_then(Value::as_bool).unwrap_or(false)) else { return vec![] }; - let ids = |k: &str| r.get(k).and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_u64).collect::>(); - let comps = ids("comps"); - let in_comps: std::collections::HashSet = comps - .iter() - .filter_map(|c| s.project.comp(ItemId(*c))) - .flat_map(|c| c.layers.iter()) - .filter_map(|l| match l.source { - LayerSource::Footage { item } | LayerSource::Comp { item } | LayerSource::Solid { item } => Some(item.0), - _ => None, - }) - .collect(); - let add: Vec = comps.iter().copied().chain(ids("items").into_iter().filter(|i| !in_comps.contains(i))).collect(); + let add = imported(s, r); let mut errors = vec![]; for (k, item) in add.into_iter().enumerate() { let mut params = json!({"comp": cid.0, "item": item, "time": p.get("time"), "position": p.get("position")}); @@ -219,6 +210,55 @@ pub(crate) fn add_to_comp(s: &mut Session, r: &Value, target: Option, p: errors } +/// What an import (its result `r`) made that belongs in a comp: the comps of layered files +/// (their layers' footage, in their precomps too, stays in the Project panel), then the other items, +/// without those comps again or the files' folders. +fn imported(s: &Session, r: &Value) -> Vec { + let ids = |k: &str| r.get(k).and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_u64).collect::>(); + let comps = ids("comps"); + let mut in_comps = std::collections::HashSet::new(); + let mut todo = comps.clone(); + while let Some(c) = todo.pop() { + for l in s.project.comp(ItemId(c)).into_iter().flat_map(|c| c.layers.iter()) { + match l.source { + LayerSource::Comp { item } if in_comps.insert(item.0) => todo.push(item.0), + LayerSource::Footage { item } | LayerSource::Solid { item } => { + in_comps.insert(item.0); + } + _ => {} + } + } + } + let rest = ids("items").into_iter().filter(|i| !in_comps.contains(i) && !comps.contains(i) && !s.project.item(ItemId(*i)).is_some_and(|it| it.is_folder())); + comps.iter().copied().chain(rest).collect() +} + +/// With `newComp`, make one composition of the footage and solids an import (its result `r`) +/// made, as New Comp from Selection does with `single` (first item on top, its settings). A comp +/// the import made (a layered file) already is the composition, so it stays out, as does data +/// footage; none left, no new comp. Returns what went wrong. +fn new_comp(s: &mut Session, r: &Value) -> Vec { + let items: Vec = imported(s, r) + .into_iter() + .map(ItemId) + .filter(|i| { + s.project.item(*i).is_some_and(|it| match &it.kind { + ItemKind::Footage(f) => f.kind != FootageKind::Data, + ItemKind::Solid(_) => true, + ItemKind::Comp(_) | ItemKind::Folder => false, + }) + }) + .collect(); + if items.is_empty() { + return vec![]; + } + s.state.project_selection = items; + match s.execute("file.newCompFromSelection", json!({"single": true})) { + Ok(_) => vec![], + Err(e) => vec![e.to_string()], + } +} + /// File ▸ Import, all in one go (scripts and commands built on it); see [`import_cmd`]. pub(crate) fn import(s: &mut Session, p: &Value) -> Result { let pending = prepare_import(s, p)?; @@ -745,7 +785,7 @@ pub fn specs() -> Vec { "File...", ["File", "Import"], Some("Cmd+I"), - "{paths: [string] (files or folders), sequence?: bool (the Sequence checkbox: true imports numbered stills of one run as one image sequence, one picked file bringing its whole run and several picked files that range; default: a numbered still brings its run, except dropped files (drag), which import as stills as in After Effects; folders' runs import as sequences; false imports stills and a folder's files individually), alphabetical?: bool (Force Alphabetical Order: every image of that type in the folder, by name), frameRate?: fps (image sequences; default Settings ▸ Import ▸ Sequence Footage), importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), page?: number from 1 (PDF / Illustrator page), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As; files import as stills unless sequence), addToComp?: bool (also add them to the active comp, at time?, index?, position? as in layer.addItem), background?: bool (probe the files in a background job, jobs.list / jobs.wait; returns {job})}", + "{paths: [string] (files or folders), sequence?: bool (the Sequence checkbox: true imports numbered stills of one run as one image sequence, one picked file bringing its whole run and several picked files that range; default false: picked or dropped files import as stills, as in After Effects; folders' runs import as sequences; false imports stills and a folder's files individually), alphabetical?: bool (Force Alphabetical Order: every image of that type in the folder, by name), frameRate?: fps (image sequences; default Settings ▸ Import ▸ Sequence Footage), importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), page?: number from 1 (PDF / Illustrator page), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As; files import as stills unless sequence), addToComp?: bool (also add them to the active comp, at time?, index?, position? as in layer.addItem), newComp?: bool (make one new composition of the footage and solids imported, as file.newCompFromSelection with single; a layered file imported as a composition is that composition and gets no other; takes precedence over addToComp), background?: bool (probe the files in a background job, jobs.list / jobs.wait; returns {job})}", always, import_cmd ), diff --git a/crates/engine/src/cmd/file_more.rs b/crates/engine/src/cmd/file_more.rs index 1e57d4b5c..c5018ae7c 100644 --- a/crates/engine/src/cmd/file_more.rs +++ b/crates/engine/src/cmd/file_more.rs @@ -5,7 +5,7 @@ use std::collections::{BTreeMap, BTreeSet}; use effectcraft_color::Label; -use effectcraft_project::{AlphaMode, Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid, limits}; +use effectcraft_project::{AlphaMode, CineonSettings, CineonUnits, Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid, limits}; use effectcraft_time::{FrameRate, Tick}; use serde_json::{Value, json}; @@ -501,7 +501,7 @@ fn run_script(s: &mut Session, p: &Value) -> Result { } _ => return Err(bad("file.runScript", format!("step {}: needs `command`", i + 1))), }; - if id == "file.runScript" || id == "script.run" { + if super::scripts::RUNS_SCRIPTS.contains(&id.as_str()) { return Err(bad("file.runScript", "scripts can't run scripts")); } let r = if from_file { s.execute_for_script(&id, params) } else { s.execute(&id, params) }; @@ -536,7 +536,7 @@ fn is_jsxbin(name: &str) -> bool { } /// A script error becomes a toast and a command error. -fn report_js(s: &mut Session, name: &str, out: Value) -> Result { +pub(crate) fn report_js(s: &mut Session, name: &str, out: Value) -> Result { if let Some(e) = out.get("error").filter(|e| !e.is_null()) { let line = e.get("line").and_then(Value::as_u64).map(|l| format!(" (line {l})")).unwrap_or_default(); let msg = e.get("message").and_then(Value::as_str).unwrap_or("script error"); @@ -552,6 +552,13 @@ pub(crate) fn run_js_named(s: &mut Session, code: &str, name: &str) -> Result Result { + let out = run_js(s, code, path, false)?; + report_js(s, name, out) +} + /// Run JavaScript through the session's scripting engine. fn run_js(s: &mut Session, code: &str, name: &str, console: bool) -> Result { let runner = s.script.ok_or_else(|| EngineError::Other("JavaScript scripting is not available in this build".into()))?; @@ -594,6 +601,131 @@ pub(crate) struct Interpretation { preserve_rgb: Option, /// Start Timecode. start: Option, + /// Cineon Settings (DPX and Cineon footage). + cineon: CineonEdit, +} + +/// Interpret Footage ▸ Cineon Settings parsed from command parameters (every field optional). +#[derive(Clone, Debug, Default)] +struct CineonEdit { + /// Logarithmic Conversion. + log: Option, + units: Option, + /// The units the Converted Black and White Points are given in (`pointUnits`, else `cineonUnits`, + /// else the footage's own Units). + point_units: Option, + converted_black: Option, + converted_white: Option, + /// 10 Bit Black and White Points. + black: Option, + white: Option, + gamma: Option, + rolloff: Option, +} + +impl CineonEdit { + fn parse(p: &Value, cmd: &str) -> Result { + let units = |key: &str| -> Result> { + match str_p(p, key) { + None => Ok(None), + Some(u) => CineonUnits::parse(u).map(Some).ok_or_else(|| bad(cmd, format!("{key}: 10bit|8bit|float, not `{u}`"))), + } + }; + let num = |key: &str, lo: f64, hi: f64| -> Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => v + .as_f64() + .filter(|x| x.is_finite() && (lo..=hi).contains(x)) + .map(Some) + .ok_or_else(|| bad(cmd, format!("{key}: a number from {lo} to {hi}"))), + } + }; + let units_now = units("cineonUnits")?; + Ok(CineonEdit { + log: b_p(p, "logConversion"), + units: units_now, + point_units: units("pointUnits")?.or(units_now), + // Range-checked once their units are known (`settings_for`). + converted_black: num("convertedBlack", f64::MIN, f64::MAX)?, + converted_white: num("convertedWhite", f64::MIN, f64::MAX)?, + black: num("tenBitBlack", 0.0, 1023.0)?, + white: num("tenBitWhite", 0.0, 1023.0)?, + gamma: num("currentGamma", 0.1, 10.0)?, + rolloff: num("highlightRolloff", 0.0, 100.0)?, + }) + } + + fn is_empty(&self) -> bool { + self.log.is_none() + && self.units.is_none() + && self.converted_black.is_none() + && self.converted_white.is_none() + && self.black.is_none() + && self.white.is_none() + && self.gamma.is_none() + && self.rolloff.is_none() + } + + /// Footage `f`'s Cineon Settings after this edit; `None` when nothing changes them (no Cineon + /// parameters, or `f` is not a DPX or Cineon file). + fn settings_for(&self, f: &Footage, cmd: &str) -> Result> { + if self.is_empty() || !f.is_cineon_file() { + return Ok(None); + } + let mut c = f.cineon; + let units = self.point_units.unwrap_or(c.units); + let point = |v: f64| -> Result { + let ten = units.to_ten_bit(v); + let max = units.from_ten_bit(f64::from(CineonSettings::MAX)); + if (0.0..=f64::from(CineonSettings::MAX)).contains(&ten) { + Ok(ten as f32) + } else { + Err(bad(cmd, format!("converted points: 0 to {max} in {} units", units.label()))) + } + }; + if let Some(v) = self.converted_black { + c.converted_black = point(v)?; + } + if let Some(v) = self.converted_white { + c.converted_white = point(v)?; + } + if let Some(v) = self.log { + c.log_conversion = v; + } + if let Some(u) = self.units { + c.units = u; + } + if let Some(v) = self.black { + c.black = v as f32; + } + if let Some(v) = self.white { + c.white = v as f32; + } + if let Some(v) = self.gamma { + c.gamma = v as f32; + } + if let Some(v) = self.rolloff { + c.rolloff = v as f32; + } + if c.black >= c.white { + return Err(bad(cmd, "the 10 Bit Black Point must be below the 10 Bit White Point")); + } + Ok(Some(c)) + } +} + +/// Footage `f`'s Cineon Settings as commands report them (`null` for files they don't apply to). +fn cineon_json(f: &Footage) -> Value { + if !f.is_cineon_file() { + return Value::Null; + } + let c = &f.cineon; + json!({ + "logConversion": c.log_conversion, "units": c.units.id(), + "convertedBlack": c.units.from_ten_bit(f64::from(c.converted_black)), "convertedWhite": c.units.from_ten_bit(f64::from(c.converted_white)), + "tenBitBlack": c.black, "tenBitWhite": c.white, "currentGamma": c.gamma, "highlightRolloff": c.rolloff, + }) } /// Interpret Footage ▸ Start Timecode. @@ -687,9 +819,23 @@ impl Interpretation { v.as_f64().filter(|x| x.is_finite() && (0.0..=StartTimecode::MAX as f64).contains(x)).map(|x| x.round() as i64).ok_or_else(start_err)?, )), }; + it.cineon = CineonEdit::parse(p, cmd)?; Ok(it) } + /// Check the Cineon Settings this interpretation gives each of `footage`: an error names a + /// value out of range, or Cineon parameters when none of the footage is DPX or Cineon. + pub(crate) fn check<'a>(&self, footage: impl IntoIterator, cmd: &str) -> Result<()> { + if self.cineon.is_empty() { + return Ok(()); + } + let mut any = false; + for f in footage { + any |= self.cineon.settings_for(f, cmd)?.is_some(); + } + if any { Ok(()) } else { Err(bad(cmd, "Cineon Settings apply to DPX and Cineon footage only")) } + } + pub(crate) fn apply(&self, f: &mut Footage, guessed: Option<(AlphaMode, [f32; 3])>) { let before = f.frame_rate; self.apply_settings(f, guessed); @@ -749,6 +895,10 @@ impl Interpretation { if let Some(v) = self.preserve_rgb { f.preserve_rgb = v; } + // Checked before the edit (`Interpretation::check`). + if let Ok(Some(c)) = self.cineon.settings_for(f, "file.interpretFootage") { + f.cineon = c; + } // After the frame rate: timecode is read at the footage's rate. if let Some(start) = self.start.as_ref().and_then(|t| t.frame(f.frame_rate)) { f.start_timecode = start; @@ -795,6 +945,11 @@ fn interpret(s: &mut Session, p: &Value) -> Result { return Err(bad("file.interpretFootage", "select footage")); } let it = Interpretation::parse(p, "file.interpretFootage")?; + let footage = items.iter().filter_map(|i| match s.project.item(*i).map(|x| &x.kind) { + Some(ItemKind::Footage(f)) => Some(f), + _ => None, + }); + it.check(footage, "file.interpretFootage")?; let guesses: Vec> = items .iter() .map(|i| match (it.guess, s.project.item(*i).map(|x| &x.kind)) { @@ -830,7 +985,7 @@ fn interpret(s: &mut Session, p: &Value) -> Result { "item": i.0, "alpha": format!("{:?}", f.alpha), "matteColor": f.premul_color, "invertAlpha": f.invert_alpha, "fields": f.fields.label(), "pixelAspect": f.pixel_aspect, "loop": f.loop_count, "frameRate": f.frame_rate.as_f64(), "linearLight": f.linear_light, "preserveRgb": f.preserve_rgb, "startTimecode": f.timecode(0), "overrideStart": f.start_timecode.is_some(), - "frames": f.sequence_frames(), + "frames": f.sequence_frames(), "cineon": cineon_json(f), })), _ => None, }) @@ -861,6 +1016,9 @@ fn apply_interpretation(s: &mut Session, p: &Value) -> Result { f.linear_light = src.linear_light; f.preserve_rgb = src.preserve_rgb; f.color_profile = src.color_profile; + if f.is_cineon_file() && src.is_cineon_file() { + f.cineon = src.cineon; + } if src.native_rate.is_some() { if f.native_rate.is_none() { f.native_rate = Some(f.frame_rate); @@ -878,10 +1036,34 @@ fn replace_footage(s: &mut Session, p: &Value) -> Result { let path = str_p(p, "path").ok_or_else(|| bad("file.replaceFootage", "missing `path`"))?.to_string(); let item = *items_p(s, p).first().ok_or_else(|| bad("file.replaceFootage", "select footage"))?; let importer = s.importer.clone().ok_or_else(|| EngineError::Other("media import is not available in this build".into()))?; - // A numbered still brings its image sequence, as on import (`sequence: false` for one frame). - let opts = SequenceOptions::from_params(p); + // Replacing a still keeps it a still; replacing a sequence keeps bringing the new file's run. + // `sequence`/`alphabetical` override that (#2073). + let previous = match s.project.item(item).map(|it| &it.kind) { + Some(ItemKind::Footage(f)) if f.kind == FootageKind::Sequence => (true, true, f.alphabetical), + _ => (false, false, false), + }; + // Only `sequence` decides whether the item keeps its kind; `alphabetical` is a sequence + // modifier, not a request to make one. + let inherited = b_p(p, "sequence").is_none(); + let mut opts = SequenceOptions::from_params(p); + if inherited { + (opts.sequence, opts.folders) = (previous.0, previous.1); + if previous.0 && b_p(p, "alphabetical").is_none() { + opts.alphabetical = previous.2; + } + } let src = crate::sequence::source_of(s.services.as_ref(), &path, opts); - let f = crate::sequence::probe(importer.as_ref(), &src, s.sequence_rate()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + let mut f = crate::sequence::probe(importer.as_ref(), &src, s.sequence_rate()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + // A single-file source comes back as a still; an inherited sequence kind keeps it a + // one-frame sequence, so a later default replacement still brings numbered runs (#2073). + if inherited && previous.0 && matches!(src, crate::sequence::Source::File(_)) && f.kind == FootageKind::Still { + f.kind = FootageKind::Sequence; + f.sequence = vec![f.path.clone()]; + f.alphabetical = opts.alphabetical; + f.frame_rate = s.sequence_rate(); + f.native_rate = None; + f.sync_sequence_duration(); + } let name = src.name(); // Replacing a missing file relinks the other missing files that moved with it (as After Effects // does when you locate one). @@ -1066,7 +1248,7 @@ pub fn specs() -> Vec { "Main...", ["File", "Interpret Footage"], Some("Cmd+Alt+G"), - "{items?|item?, frameRate?: fps|\"file\", alpha?: straight|premultiplied|ignore|guess, guessAlpha?, matteColor?|premulColor?, invertAlpha?, loop?, pixelAspect?, fields?: off|upper|lower, colorProfile?: srgb|rec709|rec2020|p3|auto, linearLight?, preserveRgb? (the file's values unconverted), overrideStart?: bool (false: Use Source File Timecode), startTimecode?: timecode|frame (Override Start)}", + "{items?|item?, frameRate?: fps|\"file\", alpha?: straight|premultiplied|ignore|guess, guessAlpha?, matteColor?|premulColor?, invertAlpha?, loop?, pixelAspect?, fields?: off|upper|lower, colorProfile?: srgb|rec709|rec2020|p3|auto, linearLight?, preserveRgb? (the file's values unconverted), overrideStart?: bool (false: Use Source File Timecode), startTimecode?: timecode|frame (Override Start), logConversion? (Cineon Settings of DPX and Cineon files), cineonUnits?: 10bit|8bit|float, convertedBlack?, convertedWhite?, pointUnits?: 10bit|8bit|float (the units of convertedBlack and convertedWhite; default cineonUnits, else the footage's Units), tenBitBlack?, tenBitWhite? (0..1023), currentGamma? (0.1..10), highlightRolloff? (0..100)}", has_footage_selection, interpret ), @@ -1093,7 +1275,7 @@ pub fn specs() -> Vec { "File...", ["File", "Replace Footage"], Some("Cmd+H"), - "{path, item?, sequence?: bool (default true: a numbered still brings its image sequence), alphabetical?: bool}", + "{path, item?, sequence?: bool (a numbered still brings its image sequence; default: the item keeps its kind, so a still stays a still and a sequence stays a sequence), alphabetical?: bool}", has_footage_selection, replace_footage ), diff --git a/crates/engine/src/cmd/focus.rs b/crates/engine/src/cmd/focus.rs index 013672a38..356ce1a07 100644 --- a/crates/engine/src/cmd/focus.rs +++ b/crates/engine/src/cmd/focus.rs @@ -78,6 +78,12 @@ fn set_to_layer(s: &mut Session, p: &Value) -> Result { let c = "camera.setFocusToLayer"; let (cid, cam, target) = refs(s, p, c, true)?; let d = focus_depth(s, cid, cam, target.ok_or_else(|| bad(c, "no layer"))?).ok_or_else(|| bad(c, "can't evaluate the layers"))?; + // Only a finite depth can be stored: JSON has no infinity or NaN, so the project could no + // longer be saved. Extreme but finite positions can still produce one, and `d <= 0.0` is + // false for NaN. + if !d.is_finite() { + return Err(bad(c, "can't measure the distance to the layer (the camera or layer position is out of range)")); + } if d <= 0.0 { return Err(bad(c, "the layer is behind the camera")); } diff --git a/crates/engine/src/cmd/footage_panel.rs b/crates/engine/src/cmd/footage_panel.rs index 1bd0d2236..26cc8e5c9 100644 --- a/crates/engine/src/cmd/footage_panel.rs +++ b/crates/engine/src/cmd/footage_panel.rs @@ -56,6 +56,12 @@ pub fn timecode(item: Option<&effectcraft_project::Item>, fr: effectcraft_time:: } } +/// The frame the Footage panel shows: footage `f` at source time `t`, with its Cineon Settings +/// applied. +pub fn frame(s: &Session, item: ItemId, f: &effectcraft_project::Footage, t: Tick) -> Option> { + s.footage.frame(item, f, t).map(|img| effectcraft_render::cineon_frame(img, f)) +} + fn source_time(s: &Session, p: &Value, item: ItemId) -> Option { let (_, fr) = source_timing(s, item); if let Some(t) = f_p(p, "time") { diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index f6d2feb04..eb9641547 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -48,7 +48,7 @@ pub fn specs() -> Vec { "Text", ["Layer", "New"], Some("Cmd+Alt+Shift+T"), - "{text?, name? (layer name; default the text), position? [x,y], box? [x,y,w,h] (paragraph text, comp space), vertical?, edit? (start editing), font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?, baselineShift?, hScale?, vScale?, tsume?, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?, superscript?, subscript?, kerning?, ligatures?, justify?, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?, composer?, hangingPunctuation?, strokeOverFill?} (attributes as layer.setText)", + "{text?, name? (layer name; default the text), position? [x,y], box? [x,y,w,h] (paragraph text, comp space), vertical?, edit? (start editing), font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?, baselineShift?, hScale?, vScale?, tsume?, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?, superscript?, subscript?, kerning?, ligatures?, justify?, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?, composer?, hangingPunctuation?, strokeOverFill?, lineJoin?} (attributes as layer.setText)", has_comp, new_text ), @@ -121,7 +121,7 @@ pub fn specs() -> Vec { "Track Matte", [], None, - "{layer?, matte: layer|null, kind?: alpha|alphaInverted|luma|lumaInverted}", + "{layers? | layer?, matte: layer|null, kind?: alpha|alphaInverted|luma|lumaInverted|preserve}", has_layers, track_matte ), @@ -183,7 +183,7 @@ pub fn specs() -> Vec { "Add (Shape)", [], None, - "{layer?, kind: group|rect|ellipse|star|polygon|path|fill|stroke|gfill|gstroke|trim|repeater|round|offset|pucker|twist|zigzag|wiggle|merge|frontColor|bevelColor|sideColor|backColor, group?: uid|path} → {uid, path} (frontColor…backColor: the group's Front / Bevel / Side / Back item, which colours that surface of every extruded paint in the group in Advanced 3D)", + "{layer?, kind: group|rect|ellipse|star|polygon|path|fill|stroke|gfill|gstroke|trim|repeater|round|offset|pucker|twist|zigzag|wiggle|wiggler|merge|frontColor|bevelColor|sideColor|backColor, group?: uid|path} → {uid, path} (frontColor…backColor: the group's Front / Bevel / Side / Back item, which colours that surface of every extruded paint in the group in Advanced 3D)", has_layers, add_shape_item ), @@ -192,7 +192,7 @@ pub fn specs() -> Vec { "Edit Text", [], None, - "{layer?, range?: [start, end] (characters; default all), text?, font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?: px|\"auto\", baselineShift? px, hScale? %, vScale? %, tsume? %, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?: normal|superscript|subscript, superscript?, subscript?, kerning?: metrics|optical|number, ligatures?, discretionaryLigatures? (OpenType), contextualAlternates?, stylisticAlternates?, stylisticSets?: [1–20], ss01?, ss02?, ss03?, ss04?, ss05?, ss06?, ss07?, ss08?, ss09?, ss10?, ss11?, ss12?, ss13?, ss14?, ss15?, ss16?, ss17?, ss18?, ss19?, ss20?, swash?, titling?, ordinals?, fractions?, allSmallCaps?, figureStyle?: default|lining|oldStyle, figureWidth?: default|proportional|tabular, figures?, variations?: {tag: value} (variable font axes; null resets), justify?: left|center|right|justifyLeft|justifyCenter|justifyRight|justifyAll, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?: ltr|rtl, composer?: everyLine|singleLine, hangingPunctuation?, strokeOverFill?, box?: [x,y,w,h]|null (layer space), vertical?}", + "{layer?, range?: [start, end] (characters; default all), text?, font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?: px|\"auto\", baselineShift? px, hScale? %, vScale? %, tsume? %, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?: normal|superscript|subscript, superscript?, subscript?, kerning?: metrics|optical|number, ligatures?, discretionaryLigatures? (OpenType), contextualAlternates?, stylisticAlternates?, stylisticSets?: [1–20], ss01?, ss02?, ss03?, ss04?, ss05?, ss06?, ss07?, ss08?, ss09?, ss10?, ss11?, ss12?, ss13?, ss14?, ss15?, ss16?, ss17?, ss18?, ss19?, ss20?, swash?, titling?, ordinals?, fractions?, allSmallCaps?, figureStyle?: default|lining|oldStyle, figureWidth?: default|proportional|tabular, figures?, variations?: {tag: value} (variable font axes; null resets), justify?: left|center|right|justifyLeft|justifyCenter|justifyRight|justifyAll, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?: ltr|rtl, composer?: everyLine|singleLine, hangingPunctuation?, strokeOverFill?, lineJoin?: miter|round|bevel, box?: [x,y,w,h]|null (layer space), vertical?}", has_layers, set_text ), diff --git a/crates/engine/src/cmd/layer/create.rs b/crates/engine/src/cmd/layer/create.rs index 7962c2485..ccc4810a9 100644 --- a/crates/engine/src/cmd/layer/create.rs +++ b/crates/engine/src/cmd/layer/create.rs @@ -161,7 +161,7 @@ pub(super) fn text_range_p(p: &Value) -> Option> { /// It is left justified unless `justify` is given. The layer is named by `name`, else the first 40 /// characters of the first text line, else "Text". `edit: true` also opens a new editing /// session with anchor `0` and the caret after the text, selecting all initial text so the next -/// insertion replaces it. The session has `created: true`, so leaving it empty removes the layer. +/// insertion replaces it. The session has `created: true`; leaving it empty keeps the layer, as After Effects does. pub(super) fn new_text(s: &mut Session, p: &Value) -> Result { let cid = comp_id(s, p)?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); diff --git a/crates/engine/src/cmd/layer/parent.rs b/crates/engine/src/cmd/layer/parent.rs index fbaec14e4..870d22f5f 100644 --- a/crates/engine/src/cmd/layer/parent.rs +++ b/crates/engine/src/cmd/layer/parent.rs @@ -1,4 +1,4 @@ -use super::{Comp, EngineError, ItemId, KV, Layer, LayerId, PropGroup, Result, Session, Tick, Value, b_p, bad, layers_p, resolve_layer}; +use super::{Comp, EngineError, ItemId, KV, Layer, LayerId, PropGroup, Result, Session, Tick, Value, b_p, bad, layers_p, resolve_layer, unlocked}; /// Re-express a layer in its new parent space without moving its anchor. pub(crate) struct ParentFix { @@ -9,10 +9,20 @@ pub(crate) struct ParentFix { rotation: f64, /// Multiply Scale. scale: [f64; 2], - /// 3D AV layers retain their world-space anchor, including Z. + /// 3D AV layers retain their world-space anchor, including Z. Cameras and lights also + /// re-express their Point of Interest. position_3d: Option, - /// A uniform relative parent transform can preserve static Orientation and Scale. - pose_3d: Option<([f64; 3], f64)>, + /// A uniform relative parent transform re-expresses static Orientation and Scale. + pose_3d: Option, +} + +/// A uniform relative parent transform, as it applies to a 3D layer's Orientation and Scale. +#[derive(Clone, Copy)] +struct Pose3d { + /// Rotation that Orientation is re-expressed under: `Orientation' = correction * Orientation`. + correction: effectcraft_geom::Mat4, + /// Uniform scale factor, multiplied into Scale (negative for a mirrored parent). + factor: f64, } impl ParentFix { @@ -22,7 +32,7 @@ impl ParentFix { if let Some(m) = self.position_3d { // Spatial Position speed measures distance along its path. Its parent-space // unit changes even when the child's Orientation cannot be re-expressed. - let position_speed = uniform_parent_pose(m, [0.0; 3]).map(|(_, factor)| factor.abs()); + let position_speed = uniform_rotation(m).map(|(_, factor)| factor.abs()); let map_position = |v: &KV| { let q = m.apply(effectcraft_geom::Vec3::from(v.as_vec3())); KV::Vec3([q.x, q.y, q.z]) @@ -44,7 +54,8 @@ impl ParentFix { } } } - if let Some(pr) = tr.get_mut("position") { + // Position and a camera's or light's Point of Interest are both points in the parent's space. + let map_point = |pr: &mut effectcraft_project::Property| { pr.value = map_position(&pr.value); for key in &mut pr.keys { key.value = map_position(&key.value); @@ -64,34 +75,19 @@ impl ParentFix { } } } + }; + if let Some(pr) = tr.get_mut("position") { + map_point(pr); } - // Cameras and lights aim with Point of Interest: move it by the same parent-space - // change, so a two-node rig keeps its world frame (#1010, #1020). if let Some(pr) = tr.get_mut("poi") { - pr.value = map_position(&pr.value); - for key in &mut pr.keys { - key.value = map_position(&key.value); - for tangent in [&mut key.spatial_in, &mut key.spatial_out] { - let v = m.apply_vec(effectcraft_geom::Vec3::from(*tangent)); - *tangent = [v.x, v.y, v.z]; - } - } - if let Some(factor) = position_speed - .filter(|_| pr.spatial) - .filter(|factor| pr.keys.iter().flat_map(|key| key.in_ease.iter().chain(&key.out_ease)).all(|ease| (ease.speed * *factor).is_finite())) - { - for key in &mut pr.keys { - for ease in key.in_ease.iter_mut().chain(&mut key.out_ease) { - ease.speed *= factor; - } - } - } + map_point(pr); } - if let Some((orientation, factor)) = self.pose_3d { + if let Some(pose) = self.pose_3d { if let Some(pr) = tr.get_mut("orientation") { - pr.value = KV::Vec3(orientation); + reexpress_orientation(pose.correction, pr); } if let Some(pr) = tr.get_mut("scale") { + let factor = pose.factor; let scaled = |value: &KV| KV::Vec3(value.as_vec3().map(|v| v * factor)); pr.value = scaled(&pr.value); for key in &mut pr.keys { @@ -154,13 +150,10 @@ impl ParentFix { } } -/// A uniform factor commutes with authored axis Rotation and Scale; prepend its orthogonal -/// rotation to static Orientation using the existing Rz * Ry * Rx order. -fn uniform_parent_pose(m: effectcraft_geom::Mat4, orientation: [f64; 3]) -> Option<([f64; 3], f64)> { - use effectcraft_geom::{Mat4, Vec3, vec3}; - if !orientation.iter().all(|v| v.is_finite()) { - return None; - } +/// The rotation and signed scale factor of a uniform parent transform's linear part (negative for a +/// mirrored parent). `None` for a non-uniform or degenerate transform. +fn uniform_rotation(m: effectcraft_geom::Mat4) -> Option<(effectcraft_geom::Mat4, f64)> { + use effectcraft_geom::{Mat4, vec3}; let a = m.0; let columns = [vec3(a[0][0], a[1][0], a[2][0]), vec3(a[0][1], a[1][1], a[2][1]), vec3(a[0][2], a[1][2], a[2][2])]; let magnitude = columns[0].x.hypot(columns[0].y).hypot(columns[0].z); @@ -179,15 +172,188 @@ fn uniform_parent_pose(m: effectcraft_geom::Mat4, orientation: [f64; 3]) -> Opti rotation.0[row][col] = a[row][col] / factor; } } - if (0..3).all(|row| (0..3).all(|col| (rotation.0[row][col] - if row == col { 1.0 } else { 0.0 }).abs() < 1e-12)) { - return Some((orientation, factor)); - } - let r = (rotation * Mat4::orientation(Vec3::from(orientation))).0; + Some((rotation, factor)) +} + +/// The turn (degrees) of a rotation about Z alone, or `None` for any other rotation. +fn z_only_turn(correction: effectcraft_geom::Mat4) -> Option { + let m = correction.0; + let about_z = [m[0][2], m[1][2], m[2][0], m[2][1]].iter().all(|v| v.abs() < 1e-9) && (m[2][2] - 1.0).abs() < 1e-9; + about_z.then(|| m[1][0].atan2(m[0][0]).to_degrees()) +} + +/// ZYX Euler angles in degrees of a row-major rotation (`Rz * Ry * Rx`), safe at gimbal lock. +fn euler_zyx(r: [[f64; 4]; 4]) -> [f64; 3] { // atan2 rather than asin: near ±90 degrees asin loses precision, while cos(y) stays exact. let cy = r[0][0].hypot(r[1][0]); let y = (-r[2][0]).atan2(cy); let (x, z) = if cy > 1e-9 { (r[2][1].atan2(r[2][2]), r[1][0].atan2(r[0][0])) } else { ((-r[1][2]).atan2(r[1][1]), 0.0) }; - Some(([x.to_degrees(), y.to_degrees(), z.to_degrees()], factor)) + [x.to_degrees(), y.to_degrees(), z.to_degrees()] +} + +/// The equivalent ZYX Euler triple nearest `reference`: each axis moved by whole turns, or the +/// `(x + 180, 180 - y, z + 180)` form when that is nearer. +fn nearest_euler(euler: [f64; 3], reference: [f64; 3]) -> [f64; 3] { + let near = |v: [f64; 3]| -> [f64; 3] { std::array::from_fn(|i| v[i] + ((reference[i] - v[i]) / 360.0).round() * 360.0) }; + let natural = near(euler); + let flipped = near([euler[0] + 180.0, 180.0 - euler[1], euler[2] + 180.0]); + let distance = |v: [f64; 3]| v.iter().zip(reference.iter()).map(|(a, b)| (a - b).powi(2)).sum::(); + if distance(flipped) < distance(natural) { flipped } else { natural } +} + +/// The Euler Orientation of `correction * Orientation(o)`, in the form nearest `reference`. +fn reexpress_euler(correction: effectcraft_geom::Mat4, o: [f64; 3], reference: [f64; 3]) -> [f64; 3] { + let world = correction * effectcraft_geom::Mat4::orientation(effectcraft_geom::Vec3::from(o)); + nearest_euler(euler_zyx(world.0), reference) +} + +/// Largest angle (degrees) a baked Orientation may drift from the exact rotation between its keys. +const BAKE_TOLERANCE_DEGREES: f64 = 0.05; +/// How many times a segment may be halved while baking, and the most keys a property may end up with. +const BAKE_MAX_DEPTH: u32 = 10; +const BAKE_MAX_KEYS: usize = 4096; + +/// The angle (degrees) between two rotations. +fn rotation_angle(a: effectcraft_geom::Mat4, b: effectcraft_geom::Mat4) -> f64 { + let trace: f64 = (0..3).flat_map(|i| (0..3).map(move |j| (i, j))).map(|(i, j)| a.0[i][j] * b.0[i][j]).sum(); + ((trace - 1.0) / 2.0).clamp(-1.0, 1.0).acos().to_degrees() +} + +/// Adds keys between `(ta, ea)` and `(tb, eb)` (already converted) wherever the straight Euler blend +/// between them drifts from `correction * Orientation(authored)` by more than the tolerance. `out` +/// never grows past `budget` keys: what the whole property may still add, not what one segment may. +fn bake_between( + correction: effectcraft_geom::Mat4, + authored: &effectcraft_project::Property, + (ta, ea): (Tick, [f64; 3]), + (tb, eb): (Tick, [f64; 3]), + depth: u32, + budget: usize, + out: &mut Vec, +) { + use effectcraft_geom::{Mat4, Vec3}; + if depth == 0 || tb.0 - ta.0 < 2 || out.len() >= budget { + return; + } + let tm = Tick(ta.0 + (tb.0 - ta.0) / 2); + let blend = [(ea[0] + eb[0]) / 2.0, (ea[1] + eb[1]) / 2.0, (ea[2] + eb[2]) / 2.0]; + let exact = correction * Mat4::orientation(Vec3::from(authored.value_at(tm).as_vec3())); + // Symmetric easing crosses the straight blend at its midpoint. Check both sides too before + // accepting a segment whose original easing will be replaced with a linear blend. + let quarter = (tb.0 - ta.0) / 4; + if [Tick(ta.0 + quarter), tm, Tick(tb.0 - quarter)].into_iter().all(|t| { + let u = (t.0 - ta.0) as f64 / (tb.0 - ta.0) as f64; + let linear = std::array::from_fn(|i| ea[i] + (eb[i] - ea[i]) * u); + let target = correction * Mat4::orientation(Vec3::from(authored.value_at(t).as_vec3())); + rotation_angle(Mat4::orientation(Vec3::from(linear)), target) <= BAKE_TOLERANCE_DEGREES + }) { + return; + } + let em = nearest_euler(euler_zyx(exact.0), blend); + bake_between(correction, authored, (ta, ea), (tm, em), depth - 1, budget, out); + if out.len() >= budget { + return; + } + out.push(effectcraft_keyframe::Keyframe::new(tm, KV::Vec3(em))); + bake_between(correction, authored, (tm, em), (tb, eb), depth - 1, budget, out); +} + +/// Re-expresses Orientation as `correction * Orientation`. A Z-only correction shifts Z exactly in the +/// value and every key. For any other correction a static value is converted directly; keys are +/// converted, and keys are added between them wherever the Euler blend between two keys would drift +/// from the exact rotation (the segments become linear, with the original easing baked in). +fn reexpress_orientation(correction: effectcraft_geom::Mat4, pr: &mut effectcraft_project::Property) { + if let Some(turn) = z_only_turn(correction) { + // No turn (an identity correction) leaves every authored value as it is. + if turn.abs() <= 1e-12 { + return; + } + let shift = |value: &KV| { + let [x, y, z] = value.as_vec3(); + KV::Vec3([x, y, z + turn]) + }; + pr.value = shift(&pr.value); + for key in &mut pr.keys { + key.value = shift(&key.value); + } + return; + } + let value = pr.value.as_vec3(); + pr.value = KV::Vec3(reexpress_euler(correction, value, value)); + if pr.keys.is_empty() { + return; + } + let authored = pr.clone(); + // Each key keeps the authored change from the previous key on top of its converted value. + let mut converted: Vec<[f64; 3]> = vec![]; + let mut prev: Option<([f64; 3], [f64; 3])> = None; + for key in &authored.keys { + let o = key.value.as_vec3(); + let reference = match prev { + Some((before, after)) => [after[0] + o[0] - before[0], after[1] + o[1] - before[1], after[2] + o[2] - before[2]], + None => o, + }; + let n = reexpress_euler(correction, o, reference); + converted.push(n); + prev = Some((o, n)); + } + use effectcraft_keyframe::Interp; + let mut keys: Vec = vec![]; + let mut after_baked_segment = false; + for (i, key) in authored.keys.iter().enumerate() { + let mut k = key.clone(); + k.value = KV::Vec3(converted[i]); + if after_baked_segment { + k.in_interp = Interp::Linear; + k.in_ease.clear(); + k.auto_bezier = false; + k.continuous = false; + } + after_baked_segment = false; + let segment = authored.keys.get(i + 1).filter(|_| key.out_interp != Interp::Hold); + let mut between = vec![]; + if let Some(next) = segment { + // What the whole property may still add: the keys it will have already (the ones written + // and the authored ones still to come) count against the cap. + let budget = BAKE_MAX_KEYS.saturating_sub(keys.len() + authored.keys.len() - i); + bake_between(correction, &authored, (key.time, converted[i]), (next.time, converted[i + 1]), BAKE_MAX_DEPTH, budget, &mut between); + // With the cap reached the segment can't be checked or subdivided: it keeps its authored easing between + // the converted keys, rather than becoming a straight blend that was never checked against the original. + if between.len() >= budget { + between.clear(); + // An eased segment, or one that gained keys, becomes a run of straight blends. + } else if !between.is_empty() || key.out_interp != Interp::Linear || next.in_interp != Interp::Linear { + k.out_interp = Interp::Linear; + k.out_ease.clear(); + k.auto_bezier = false; + k.continuous = false; + after_baked_segment = true; + } + } + keys.push(k); + keys.extend(between); + } + pr.keys = keys; +} + +/// Correction for a two-node rig's Orientation: after a uniform parent change its aim is +/// `rotation * aim`, so the roll is re-expressed against that aim. `None` for a degenerate aim. +fn aim_correction(rotation: effectcraft_geom::Mat4, aim: [f64; 3]) -> Option { + use effectcraft_geom::vec3; + use effectcraft_render::three_d::camera::basis_rotation; + let [x, y, z] = aim; + let length = (x * x + y * y + z * z).sqrt(); + if !length.is_finite() || length <= 1e-9 { + return None; + } + let dir = vec3(x / length, y / length, z / length); + let turned = rotation.apply_vec(dir); + // A view along the up axis has no roll to keep, and its basis is undefined. + if 1.0 - dir.y * dir.y < 1e-12 || 1.0 - turned.y * turned.y < 1e-12 { + return None; + } + let up = vec3(0.0, 1.0, 0.0); + Some(basis_rotation(turned, up).transpose() * rotation * basis_rotation(dir, up)) } /// A parent change retains 2D transforms and the world-space anchor of 3D layers (Position, @@ -196,8 +362,16 @@ fn uniform_parent_pose(m: effectcraft_geom::Mat4, orientation: [f64; 3]) -> Opti /// time. pub(crate) fn parent_fixes(s: &Session, cid: ItemId, ids: &[LayerId], parent: Option) -> Vec { let Some(comp) = s.project.comp(cid) else { return vec![] }; - let ectx = - effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: s.time(), expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) }; + let ectx = effectcraft_render::EvalCtx { + project: &s.project, + comp_id: cid, + comp, + time: s.time(), + expr: s.expr.as_deref(), + footage: Some(s.footage.as_ref()), + property_overrides: &[], + media_overrides: &[], + }; let space = |id: Option| -> effectcraft_geom::Mat3 { id.and_then(|i| comp.layer(i)).filter(|l| !l.is_3d()).map(|l| ectx.layer_to_comp(l).0).unwrap_or(effectcraft_geom::Mat3::IDENTITY) }; @@ -235,31 +409,58 @@ pub(crate) fn parent_fixes(s: &Session, cid: ItemId, ids: &[LayerId], parent: Op }) }; if let Some(inv) = space_3d(parent).inverse().filter(|m| m.0.iter().flatten().all(|v| v.is_finite())) { - // Cameras and lights compensate like 3D layers (Position, and Point of Interest for - // two-node rigs), so parenting keeps the world frame (#1010, #1020). They have no - // Scale, so `pose_3d` below stays `None` and Orientation is kept as authored. for layer in comp.layers.iter().filter(|l| ids.contains(&l.id) && l.is_3d() && l.parent != parent) { let m = inv * space_3d(layer.parent); + // A one-node camera or light has no Scale to carry a mirror, and its frame (forward and + // down) would come out reversed: compensate none of it, so Position and Orientation stay + // as authored instead of half-following the parent. + let rig = layer.is_camera() || layer.is_light(); + if rig && !effectcraft_render::three_d::camera::is_two_node(layer) && uniform_rotation(m).is_some_and(|(_, factor)| factor <= 0.0) { + continue; + } if m.0.iter().flatten().all(|v| v.is_finite()) { let pose_3d = layer.transform().and_then(|tr| { let orientation = tr.get("orientation")?; - let scale = tr.get("scale")?; - // Animated Euler Orientation and auto-orient cannot be re-expressed by - // prepending a constant Euler value. Expression-driven values stay authored. - if !orientation.keys.is_empty() - || orientation.expr.is_some() - || scale.expr.is_some() - || layer.auto_orient != effectcraft_project::AutoOrient::Off + // Cameras and lights have no Scale; their Orientation is re-expressed alone. + let scale = tr.get("scale"); + // Expression-driven values stay authored. Auto-orient replaces Orientation, except + // a two-node rig, whose roll is re-expressed against its new aim. + let two_node = (layer.is_camera() || layer.is_light()) && effectcraft_render::three_d::camera::is_two_node(layer); + if orientation.expr.is_some() + || scale.is_some_and(|scale| scale.expr.is_some()) + || (layer.auto_orient != effectcraft_project::AutoOrient::Off && !two_node) { return None; } - let pose = uniform_parent_pose(m, orientation.value.as_vec3())?; - let factor = pose.1; - let values_finite = std::iter::once(&scale.value) - .chain(scale.keys.iter().map(|key| &key.value)) - .all(|value| value.as_vec3().iter().all(|v| (v * factor).is_finite())); - let speeds_finite = scale.keys.iter().flat_map(|key| key.in_ease.iter().chain(&key.out_ease)).all(|ease| (ease.speed * factor).is_finite()); - (values_finite && speeds_finite).then_some(pose) + let (rotation, factor) = uniform_rotation(m)?; + let correction = if two_node { + // A mirrored parent flips the aim, which this correction does not model. + if factor <= 0.0 { + return None; + } + let (w, h) = (comp.width as f64, comp.height as f64); + let pos = ectx.v3(layer, tr, "position", [w / 2.0, h / 2.0, -1000.0]); + let poi = ectx.v3(layer, tr, "poi", [w / 2.0, h / 2.0, 0.0]); + aim_correction(rotation, [poi[0] - pos[0], poi[1] - pos[1], poi[2] - pos[2]])? + } else { + rotation + }; + let orientation_finite = std::iter::once(&orientation.value) + .chain(orientation.keys.iter().map(|key| &key.value)) + .all(|value| value.as_vec3().iter().all(|v| v.is_finite())); + if !correction.0.iter().flatten().all(|v| v.is_finite()) || !orientation_finite { + return None; + } + if let Some(scale) = scale { + let values_finite = std::iter::once(&scale.value) + .chain(scale.keys.iter().map(|key| &key.value)) + .all(|value| value.as_vec3().iter().all(|v| (v * factor).is_finite())); + let speeds_finite = scale.keys.iter().all(|key| key.in_ease.iter().chain(&key.out_ease).all(|ease| (ease.speed * factor).is_finite())); + if !values_finite || !speeds_finite { + return None; + } + } + Some(Pose3d { correction, factor }) }); fixes.push(ParentFix { layer: layer.id, m: effectcraft_geom::Mat3::IDENTITY, rotation: 0.0, scale: [1.0; 2], position_3d: Some(m), pose_3d }); } @@ -309,6 +510,8 @@ fn check_parent_chain(comp: &Comp, ids: &[LayerId], parent: Option, max pub(super) fn set_parent(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; + // Locked layers keep their parent (several at once: the others are parented). + let ids = unlocked(s, cid, ids, "layer.setParent")?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; let parent = match p.get("parent") { None | Some(Value::Null) => None, @@ -323,7 +526,7 @@ pub(super) fn set_parent(s: &mut Session, p: &Value) -> Result { let fixes = if compensate { parent_fixes(s, cid, &ids, parent) } else { vec![] }; s.edit("Parent", None, |proj, _| { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; - for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) { + for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id)) { l.parent = parent; if let Some(f) = fixes.iter().find(|f| f.layer == l.id) { f.apply(l); diff --git a/crates/engine/src/cmd/layer/switches.rs b/crates/engine/src/cmd/layer/switches.rs index 38689afbb..366c1eeb1 100644 --- a/crates/engine/src/cmd/layer/switches.rs +++ b/crates/engine/src/cmd/layer/switches.rs @@ -1,6 +1,6 @@ use super::{ BlendMode, EngineError, FrameBlend, ItemId, KV, Layer, LayerId, MatteKind, Quality, Result, Session, TrackMatte, Value, b_p, bad, json, layer_mut, layer_p, - layers_p, merge_p, resolve_layer, str_p, + layers_p, merge_p, resolve_layer, str_p, unlocked, }; pub(super) fn set_switch(s: &mut Session, p: &Value) -> Result { @@ -19,7 +19,7 @@ pub(super) fn set_switch(s: &mut Session, p: &Value) -> Result { let first = comp.layers.iter().find(|l| ids.contains(&l.id)).map(|l| switch_value(l, &name)).unwrap_or(Some(false)); let Some(first) = first else { return Err(bad("layer.setSwitch", format!("unknown switch `{name}`"))) }; let target = v.unwrap_or(!first); - for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) { + for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id)) { set_switch_value(l, &name, target); last = target; } @@ -55,7 +55,7 @@ fn set_switch_others(s: &mut Session, cid: ItemId, ids: &[LayerId], name: &str, alone &= on == (ids.contains(&l.id) == active); } let others = value.unwrap_or(alone == active); - for l in comp.layers.iter_mut().filter(|l| applies(l)) { + for l in comp.layers.iter_mut_where(|l| applies(l)) { let v = if ids.contains(&l.id) { active } else { others }; set_switch_value(l, name, v); } @@ -152,7 +152,7 @@ pub(super) fn set_comment(s: &mut Session, p: &Value) -> Result { let text = str_p(p, "comment").ok_or_else(|| bad("layer.setComment", "missing `comment`"))?.to_string(); s.edit("Layer Comment", None, |proj, _| { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; - for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) { + for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id)) { l.comment = text.clone(); } Ok(()) @@ -169,7 +169,7 @@ pub(super) fn blend(s: &mut Session, p: &Value) -> Result { } s.edit("Blending Mode", None, |proj, _| { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; - for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) { + for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id)) { l.blend_mode = match mode { Some(m) => m, None if step > 0 => l.blend_mode.next(), @@ -181,33 +181,122 @@ pub(super) fn blend(s: &mut Session, p: &Value) -> Result { Ok(Value::Null) } +/// `layer.setTrackMatte`: `layers` (or `layer`, else the first selected layer; locked ones left +/// as they are) use `matte` as their track matte (none when null), in one undo step. `kind` sets the mode; `preserve` keeps +/// each layer's own (Alpha for a layer without a matte), so changing the matte of several layers +/// at once keeps their modes. pub(super) fn track_matte(s: &mut Session, p: &Value) -> Result { - let (cid, lid) = layer_p(s, p, "layer.setTrackMatte")?; + let (cid, ids) = match p.get("layers") { + Some(_) => layers_p(s, p)?, + None => layer_p(s, p, "layer.setTrackMatte").map(|(c, l)| (c, vec![l]))?, + }; + // Locked layers keep their matte (several at once: the others get it). + let ids = unlocked(s, cid, ids, "layer.setTrackMatte")?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; let matte = match p.get("matte") { None | Some(Value::Null) => None, Some(v) => Some(resolve_layer(comp, v).ok_or_else(|| bad("layer.setTrackMatte", "no such matte layer"))?), }; - let kind = str_p(p, "kind") - .map(|k| match k.to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() { + if ids.is_empty() { + return Err(bad("layer.setTrackMatte", "no layers")); + } + if matte.is_some_and(|m| ids.contains(&m)) { + return Err(bad("layer.setTrackMatte", "a layer can't be its own matte")); + } + // None: each layer keeps its own mode. + let kind = match str_p(p, "kind").map(|k| k.to_ascii_lowercase().replace([' ', '_', '-'], "")) { + Some(k) if k == "preserve" => None, + Some(k) => Some(match k.as_str() { "alphainverted" | "alphainv" => MatteKind::AlphaInverted, "luma" => MatteKind::Luma, "lumainverted" | "lumainv" => MatteKind::LumaInverted, _ => MatteKind::Alpha, - }) - .unwrap_or(MatteKind::Alpha); + }), + None => Some(MatteKind::Alpha), + }; s.edit("Track Matte", None, |proj, _| { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; - if let Some(m) = matte { - if m == lid { - return Err(bad("layer.setTrackMatte", "a layer can't be its own matte")); - } - if let Some(ml) = comp.layer_mut(m) { - ml.switches.video = false; - } + if let Some(ml) = matte.and_then(|m| comp.layer_mut(m)) { + ml.switches.video = false; + } + for lid in &ids { + let l = comp.layer_mut(*lid).ok_or(EngineError::NoComp)?; + let kind = kind.unwrap_or_else(|| l.track_matte.map(|m| m.kind).unwrap_or_default()); + l.track_matte = matte.map(|layer| TrackMatte { layer, kind }); } - comp.layer_mut(lid).ok_or(EngineError::NoComp)?.track_matte = matte.map(|layer| TrackMatte { layer, kind }); Ok(()) })?; Ok(Value::Null) } + +#[cfg(test)] +mod track_matte_batch_tests { + use super::*; + use std::sync::Arc; + + fn matte_of(s: &Session, id: u64) -> Option<(u64, MatteKind)> { + s.active_comp().unwrap().layer(LayerId(id)).unwrap().track_matte.map(|m| (m.layer.0, m.kind)) + } + + /// #1260: several layers take one matte layer at once, in one undo step; `kind: "preserve"` + /// keeps each one's mode; refused calls change nothing; one `layer` still works. + #[test] + fn several_layers_take_one_matte_and_keep_their_modes() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name":"Mattes","width":16,"height":16,"duration":1})).unwrap(); + let [a, b, c, x, y] = ["A", "B", "C", "X", "Y"].map(|n| s.execute("layer.newSolid", json!({"name": n})).unwrap()["layer"].as_u64().unwrap()); + s.execute("layer.setTrackMatte", json!({"layer": a, "matte": x, "kind": "luma"})).unwrap(); + s.execute("layer.setTrackMatte", json!({"layer": b, "matte": x})).unwrap(); + assert_eq!((matte_of(&s, a), matte_of(&s, b)), (Some((x, MatteKind::Luma)), Some((x, MatteKind::Alpha))), "one layer, as before"); + let (before, undo) = (s.project.clone(), s.history.undo.len()); + s.execute("layer.setTrackMatte", json!({"layers": [a, b, c], "matte": y, "kind": "preserve"})).unwrap(); + assert_eq!( + [matte_of(&s, a), matte_of(&s, b), matte_of(&s, c)], + [Some((y, MatteKind::Luma)), Some((y, MatteKind::Alpha)), Some((y, MatteKind::Alpha))], + "each keeps its mode (Alpha without one)" + ); + assert!(!s.active_comp().unwrap().layer(LayerId(y)).unwrap().switches.video, "the matte is hidden"); + assert_eq!(s.history.undo.len(), undo + 1, "one undo step"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.project.same_content(&before)); + // Refused: a layer as its own matte, an unknown layer, no layers. Nothing changes. + for p in [ + json!({"layers": [a, y], "matte": y}), + json!({"layers": [a, 9999], "matte": y}), + json!({"layers": [], "matte": y}), + json!({"layers": [a], "matte": 9999}), + ] { + let (project, revision) = (s.project.clone(), s.revision); + assert!(s.execute("layer.setTrackMatte", p.clone()).is_err(), "{p}"); + assert!(Arc::ptr_eq(&project, &s.project) && s.revision == revision, "{p} changed nothing"); + } + s.execute("layer.setTrackMatte", json!({"layers": [a, b], "matte": null})).unwrap(); + assert_eq!((matte_of(&s, a), matte_of(&s, b)), (None, None), "both lose it"); + } + + /// Locked layers keep their parent and matte when they are among several (the others change), + /// and alone they are an error that changes nothing. + #[test] + fn locked_layers_keep_their_parent_and_matte() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name":"Locks","width":16,"height":16,"duration":1})).unwrap(); + let [a, b, p, m] = ["A", "B", "P", "M"].map(|n| s.execute("layer.newSolid", json!({"name": n})).unwrap()["layer"].as_u64().unwrap()); + s.execute("layer.setSwitch", json!({"layers": [b], "switch": "lock", "value": true})).unwrap(); + let layer = |s: &Session, id: u64| s.active_comp().unwrap().layer(LayerId(id)).unwrap().clone(); + s.execute("layer.setParent", json!({"layers": [a, b], "parent": p})).unwrap(); + s.execute("layer.setTrackMatte", json!({"layers": [a, b], "matte": m, "kind": "preserve"})).unwrap(); + s.execute("layer.trackMatte", json!({"layers": [a, b], "op": "luma"})).unwrap(); + assert_eq!((layer(&s, a).parent, matte_of(&s, a)), (Some(LayerId(p)), Some((m, MatteKind::Luma)))); + assert_eq!((layer(&s, b).parent, matte_of(&s, b)), (None, None), "the locked layer is left as it was"); + for (cmd, p) in [ + ("layer.setParent", json!({"layers": [b], "parent": p})), + ("layer.setTrackMatte", json!({"layers": [b], "matte": m})), + ("layer.trackMatte", json!({"layers": [b], "op": "alpha"})), + ] { + let (project, revision) = (s.project.clone(), s.revision); + let err = s.execute(cmd, p).unwrap_err(); + assert!(err.to_string().contains("is locked"), "{cmd}: {err}"); + assert!(Arc::ptr_eq(&project, &s.project) && s.revision == revision, "{cmd} changed nothing"); + } + } +} diff --git a/crates/engine/src/cmd/layer/timing.rs b/crates/engine/src/cmd/layer/timing.rs index 711eefee2..0427dcf0d 100644 --- a/crates/engine/src/cmd/layer/timing.rs +++ b/crates/engine/src/cmd/layer/timing.rs @@ -18,7 +18,7 @@ pub(super) fn timing(s: &mut Session, p: &Value) -> Result { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; let fd = comp.frame_duration(); let comp_dur = comp.duration; - for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) { + for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id)) { let shift = |l: &mut Layer, d: Tick| { l.start_time += d; l.in_point += d; @@ -42,7 +42,9 @@ pub(super) fn timing(s: &mut Session, p: &Value) -> Result { shift(l, d); } "trimInToTime" => l.in_point = t.min(l.out_point - fd), - "trimOutToTime" => l.out_point = t.max(l.in_point + fd), + // Alt+]: the Out edge lands one frame after the playhead, so its frame stays the + // layer's last visible one (as in After Effects), never before the In point. + "trimOutToTime" => l.out_point = t.max(l.in_point) + fd, _ => { if let Some(d) = delta { shift(l, d); @@ -159,14 +161,14 @@ pub(super) fn arrange(s: &mut Session, p: &Value) -> Result { for i in 1..n { if picked(&comp.layers[i]) && !picked(&comp.layers[i - 1]) { let run_end = (i..n).find(|j| !picked(&comp.layers[*j])).unwrap_or(n); - comp.layers[i - 1..run_end].rotate_left(1); + comp.layers.rotate_range_left(i - 1..run_end, 1); } } } else { for i in (0..n.saturating_sub(1)).rev() { if picked(&comp.layers[i]) && !picked(&comp.layers[i + 1]) { let run_start = (0..=i).rev().find(|j| !picked(&comp.layers[*j])).map_or(0, |j| j + 1); - comp.layers[run_start..=i + 1].rotate_right(1); + comp.layers.rotate_range_right(run_start..=i + 1, 1); } } } diff --git a/crates/engine/src/cmd/layer/transform.rs b/crates/engine/src/cmd/layer/transform.rs index 8b75dcbc8..eb0f610f9 100644 --- a/crates/engine/src/cmd/layer/transform.rs +++ b/crates/engine/src/cmd/layer/transform.rs @@ -74,14 +74,39 @@ pub(super) fn transform_op(s: &mut Session, p: &Value) -> Result { let op = str_p(p, "op").unwrap_or("reset").to_string(); let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); let t = s.time(); - let sizes: Vec<(LayerId, (u32, u32))> = - comp.layers.iter().filter(|l| ids.contains(&l.id)).map(|l| (l.id, effectcraft_render::source_size(&s.project, l))).collect(); + // A shape or text layer has no source item, so source_size is (0, 0): the fit ops size it by + // its painted bounds at the current time instead, and anchor at the bounds' centre — content + // bounds live in layer space, where a shape is centred on the origin and text sits above its + // baseline, so (w/2, h/2) would push it off-centre (#2198). Reset and the other ops keep the + // source size. + let ectx = effectcraft_render::EvalCtx { + expr: s.expr.as_deref(), + footage: Some(s.footage.as_ref()), + ..effectcraft_render::EvalCtx::new(&s.project, cid, &comp, t) + }; + let fitting = matches!(op.as_str(), "fit" | "fitWidth" | "fitHeight"); + let sizes: Vec<(LayerId, [f64; 2], [f64; 2])> = comp + .layers + .iter() + .filter(|l| ids.contains(&l.id)) + .map(|l| { + let (w, h) = effectcraft_render::source_size(&s.project, l); + if fitting && (w == 0 || h == 0) { + match effectcraft_render::content_bounds(&ectx, l).filter(|b| b.iter().all(|v| v.is_finite()) && b[2] > b[0] && b[3] > b[1]) { + Some(b) => (l.id, [b[2] - b[0], b[3] - b[1]], [(b[0] + b[2]) / 2.0, (b[1] + b[3]) / 2.0]), + None => (l.id, [0.0; 2], [0.0; 2]), + } + } else { + (l.id, [w as f64, h as f64], [w as f64 / 2.0, h as f64 / 2.0]) + } + }) + .collect(); s.edit("Transform", None, |proj, _| { - for (lid, (w, h)) in &sizes { + for (lid, size, anchor) in &sizes { let l = layer_mut(proj, cid, *lid)?; let lt = l.layer_time(t); let (cw, ch) = (comp.width as f64, comp.height as f64); - let (w, h) = (*w as f64, *h as f64); + let (w, h) = (size[0], size[1]); let Some(tr) = l.transform_mut() else { continue }; let set = |tr: &mut PropGroup, m: &str, v: KV| { if let Some(pr) = tr.get_mut(m) { @@ -105,11 +130,15 @@ pub(super) fn transform_op(s: &mut Session, p: &Value) -> Result { "fitHeight" => (ch / h, ch / h), _ => (cw / w, ch / h), }; + // A tiny shape's bounds can overflow the scale; refuse rather than write infinity. + if !(sx * 100.0).is_finite() || !(sy * 100.0).is_finite() { + return Err(EngineError::Other(format!("layer {} is too small to fit to the composition", lid.0))); + } let z = |m: &str, d: f64| tr.get(m).map(|p| p.value_at(lt).as_vec3()[2]).unwrap_or(d); let (sz, az) = (z("scale", 100.0), z("anchor", 0.0)); set(tr, "scale", KV::Vec3([sx * 100.0, sy * 100.0, sz])); update_position(tr, lt, |p| [cw / 2.0, ch / 2.0, p[2]]); - set(tr, "anchor", KV::Vec3([w / 2.0, h / 2.0, az])); + set(tr, "anchor", KV::Vec3([anchor[0], anchor[1], az])); } "flipH" | "flipV" => { let cur = tr.get("scale").map(|p| p.value_at(lt).as_vec3()).unwrap_or([100.0; 3]); diff --git a/crates/engine/src/cmd/layer_menu.rs b/crates/engine/src/cmd/layer_menu.rs index 33d17bb4f..e0c4efc72 100644 --- a/crates/engine/src/cmd/layer_menu.rs +++ b/crates/engine/src/cmd/layer_menu.rs @@ -61,7 +61,7 @@ fn quality(s: &mut Session, p: &Value) -> Result { q => return Err(bad("layer.quality", format!("quality must be best|draft|wireframe, not `{q}`"))), }; s.edit("Layer Quality", None, |proj, _| { - for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut().filter(|l| ids.contains(&l.id)) { + for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut_where(|l| ids.contains(&l.id)) { l.switches.quality = q; } Ok(()) @@ -84,7 +84,7 @@ fn sampling(s: &mut Session, p: &Value) -> Result { q => q, }; s.edit("Layer Sampling", None, |proj, _| { - for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut().filter(|l| ids.contains(&l.id)) { + for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut_where(|l| ids.contains(&l.id)) { l.switches.sampling = q; } Ok(()) @@ -101,7 +101,7 @@ fn frame_blending(s: &mut Session, p: &Value) -> Result { m => return Err(bad("layer.frameBlending", format!("mode must be off|frameMix|pixelMotion, not `{m}`"))), }; s.edit("Frame Blending", None, |proj, _| { - for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut().filter(|l| ids.contains(&l.id)) { + for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut_where(|l| ids.contains(&l.id)) { l.switches.frame_blend = m; } Ok(()) @@ -116,7 +116,7 @@ fn hide_other_video(s: &mut Session, p: &Value) -> Result { s.edit("Hide Other Video", None, |proj, _| { // Every layer has a Video switch (nulls, cameras and lights included, as the Timeline's // eye cell shows): the selection stays shown. - for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut().filter(|l| !ids.contains(&l.id)) { + for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut_where(|l| !ids.contains(&l.id)) { l.switches.video = false; } Ok(()) @@ -130,7 +130,7 @@ fn show_all_video(s: &mut Session, p: &Value) -> Result { let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; // Track mattes stay hidden (their video switch is off by design). let mattes: Vec = c.layers.iter().filter_map(|l| l.track_matte.map(|m| m.layer)).collect(); - for l in c.layers.iter_mut().filter(|l| !mattes.contains(&l.id)) { + for l in c.layers.iter_mut_where(|l| !mattes.contains(&l.id)) { l.switches.video = true; } Ok(()) @@ -154,7 +154,7 @@ fn expressions(s: &mut Session, p: &Value) -> Result { let on = b_p(p, "enabled").unwrap_or(true); let n = s.edit(if on { "Enable Expressions" } else { "Disable Expressions" }, None, |proj, _| { let mut n = 0; - for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut().filter(|l| ids.contains(&l.id)) { + for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut_where(|l| ids.contains(&l.id)) { l.props.walk_mut(&mut |pr| { if let Some(e) = &mut pr.expr { e.enabled = on; @@ -586,7 +586,7 @@ fn markers_lock(s: &mut Session, p: &Value) -> Result { let r = s.edit("Lock Markers", None, |proj, _| { let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; let target = v.unwrap_or_else(|| !c.layers.iter().find(|l| ids.contains(&l.id)).is_some_and(|l| l.markers_locked)); - for l in c.layers.iter_mut().filter(|l| ids.contains(&l.id)) { + for l in c.layers.iter_mut_where(|l| ids.contains(&l.id)) { l.markers_locked = target; } Ok(target) @@ -597,7 +597,7 @@ fn markers_lock(s: &mut Session, p: &Value) -> Result { fn delete_all_markers(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; s.edit("Delete All Markers", None, |proj, _| { - for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut().filter(|l| ids.contains(&l.id) && !l.markers_locked) { + for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut_where(|l| ids.contains(&l.id) && !l.markers_locked) { l.markers.clear(); } Ok(()) @@ -609,6 +609,7 @@ fn delete_all_markers(s: &mut Session, p: &Value) -> Result { fn track_matte_op(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; + let ids = super::unlocked(s, cid, ids, "layer.trackMatte")?; let op = str_p(p, "op").ok_or_else(|| bad("layer.trackMatte", "missing `op`"))?.to_string(); let kind = match op.as_str() { "alpha" => Some(MatteKind::Alpha), diff --git a/crates/engine/src/cmd/layer_time.rs b/crates/engine/src/cmd/layer_time.rs index d98bfdea7..4ecf725ec 100644 --- a/crates/engine/src/cmd/layer_time.rs +++ b/crates/engine/src/cmd/layer_time.rs @@ -34,7 +34,7 @@ fn time_stretch(s: &mut Session, p: &Value) -> Result { let fd = comp_rate.frame_duration(); s.edit(if reverse { "Time-Reverse Layer" } else { "Time Stretch" }, None, |proj, _| { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; - for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) { + for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id)) { let old = l.stretch; if reverse { // Keep the in/out points; play the visible span backwards. @@ -109,7 +109,7 @@ fn enable_time_remap(s: &mut Session, p: &Value) -> Result { let mut next = proj.next_id; let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; let mut on = false; - for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id) && remappable(l)) { + for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id) && remappable(l)) { let has = l.props.get("timeRemap").is_some(); let target = want.unwrap_or(!has); on |= target; @@ -185,7 +185,16 @@ fn freeze(s: &mut Session, p: &Value, last: bool) -> Result { return Err(bad("layer.freezeFrame", "freezing needs a footage or composition layer")); } // What each layer shows now (honouring an existing remap) — evaluated before the edit. - let ectx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: cti, expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) }; + let ectx = effectcraft_render::EvalCtx { + project: &s.project, + comp_id: cid, + comp, + time: cti, + expr: s.expr.as_deref(), + footage: Some(s.footage.as_ref()), + property_overrides: &[], + media_overrides: &[], + }; let now: Vec<(effectcraft_project::LayerId, f64)> = comp .layers .iter() diff --git a/crates/engine/src/cmd/markers.rs b/crates/engine/src/cmd/markers.rs index 4c979619e..3530eaa4b 100644 --- a/crates/engine/src/cmd/markers.rs +++ b/crates/engine/src/cmd/markers.rs @@ -296,8 +296,9 @@ fn convert(s: &mut Session, p: &Value) -> Result { Ok(Value::Null) } -/// Layer ▸ Markers ▸ Update Markers From Source: copy a precomp's composition markers onto the -/// layer (layer time is the nested comp's time), skipping ones already there. +/// Layer ▸ Markers ▸ Update Markers From Source: copy a precomp's composition markers, or the +/// markers a footage file carries (a WAV's cue points), onto the layer (layer time is the source's +/// time), skipping ones already there. fn update_from_source(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; let src: Vec<(LayerId, Vec)> = { @@ -307,13 +308,17 @@ fn update_from_source(s: &mut Session, p: &Value) -> Result { let l = comp.layer(*id)?; match &l.source { LayerSource::Comp { item } => Some((*id, s.project.comp(*item)?.markers.clone())), + LayerSource::Footage { item } => match &s.project.item(*item)?.kind { + effectcraft_project::ItemKind::Footage(f) => Some((*id, f.markers.clone())), + _ => None, + }, _ => None, } }) .collect() }; if src.is_empty() { - return Err(bad("layer.updateMarkersFromSource", "select a precomposition layer")); + return Err(bad("layer.updateMarkersFromSource", "select a precomposition or footage layer")); } let n = s.edit("Update Markers From Source", None, |proj, _| { let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; @@ -323,8 +328,11 @@ fn update_from_source(s: &mut Session, p: &Value) -> Result { if l.markers_locked { continue; } + // One set of the times already on the layer: a file with thousands of markers must not + // scan the layer's list for each of them. + let mut times: std::collections::HashSet<_> = l.markers.iter().map(|x| x.time).collect(); for m in ms { - if !l.markers.iter().any(|x| x.time == m.time) { + if times.insert(m.time) { l.markers.push(m.clone()); n += 1; } @@ -339,10 +347,10 @@ fn update_from_source(s: &mut Session, p: &Value) -> Result { fn has_precomp_layer(s: &Session) -> std::result::Result<(), String> { has_layers(s)?; let c = s.active_comp().ok_or("no composition")?; - if s.state.selected_layers.iter().filter_map(|l| c.layer(*l)).any(|l| matches!(l.source, LayerSource::Comp { .. })) { + if s.state.selected_layers.iter().filter_map(|l| c.layer(*l)).any(|l| matches!(l.source, LayerSource::Comp { .. } | LayerSource::Footage { .. })) { Ok(()) } else { - Err("select a precomposition layer".into()) + Err("select a precomposition or footage layer".into()) } } diff --git a/crates/engine/src/cmd/model3d.rs b/crates/engine/src/cmd/model3d.rs index 28b7274ef..2cc4d34e6 100644 --- a/crates/engine/src/cmd/model3d.rs +++ b/crates/engine/src/cmd/model3d.rs @@ -64,6 +64,12 @@ fn comp_renderer(s: &mut Session, p: &Value) -> Result { if r == Renderer::Classic3D && s.project.comp(cid).is_some_and(|c| c.layers.iter().any(|l| l.source.is_model())) { s.toast("Classic 3D doesn't draw 3D model layers: they show with the Advanced 3D renderer"); } + if r == Renderer::Classic3D { + let env = |l: &Layer| matches!(l.source, LayerSource::Light { kind: effectcraft_project::LightKind::Environment }); + if s.project.comp(cid).is_some_and(|c| c.layers.iter().any(env)) { + s.toast("Classic 3D doesn't light Environment light layers: they light with the Advanced 3D renderer"); + } + } Ok(json!({"renderer": renderer_id(r)})) } @@ -77,7 +83,7 @@ fn renderer_id(r: Renderer) -> &'static str { /// Only the Advanced 3D renderer draws model and primitive layers, so adding one switches a /// Classic 3D comp to it, like After Effects (inside the adding edit: one undo step). Whether /// the renderer changed. -fn use_advanced_3d(proj: &mut effectcraft_project::Project, cid: ItemId) -> Result { +pub(crate) fn use_advanced_3d(proj: &mut effectcraft_project::Project, cid: ItemId) -> Result { let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; let switched = c.renderer != Renderer::Advanced3D; c.renderer = Renderer::Advanced3D; @@ -86,10 +92,10 @@ fn use_advanced_3d(proj: &mut effectcraft_project::Project, cid: ItemId) -> Resu } /// Tell the user when [`use_advanced_3d`] changed the comp's renderer. -fn report_switch(s: &mut Session, cid: ItemId, switched: bool) { +pub(crate) fn report_switch(s: &mut Session, cid: ItemId, switched: bool, why: &str) { if switched { let name = s.project.item(cid).map_or("the composition", |i| i.name.as_str()).to_string(); - s.toast(format!("\"{name}\" now uses the Advanced 3D renderer, which draws 3D models")); + s.toast(format!("\"{name}\" now uses the Advanced 3D renderer, {why}")); } } @@ -156,7 +162,7 @@ pub(crate) fn new_model(s: &mut Session, p: &Value) -> Result { let id = insert_layer_at(proj, st, cid, l, index)?; Ok((id, use_advanced_3d(proj, cid)?)) })?; - report_switch(s, cid, switched); + report_switch(s, cid, switched, "which draws 3D models"); Ok(json!({"layer": id.0, "item": item.0, "advanced3d": true})) } @@ -226,7 +232,7 @@ fn new_primitive(s: &mut Session, p: &Value) -> Result { let id = insert_layer(proj, st, cid, l)?; Ok((id, use_advanced_3d(proj, cid)?)) })?; - report_switch(s, cid, switched); + report_switch(s, cid, switched, "which draws 3D models"); Ok(json!({"layer": id.0, "kind": kind.label(), "advanced3d": true})) } diff --git a/crates/engine/src/cmd/path_nulls.rs b/crates/engine/src/cmd/path_nulls.rs index bb74ee77c..93d71cd67 100644 --- a/crates/engine/src/cmd/path_nulls.rs +++ b/crates/engine/src/cmd/path_nulls.rs @@ -78,7 +78,16 @@ fn info(s: &Session, cid: ItemId, lid: LayerId, uid: Uid, cmd: &str) -> Result

Result Result { let y = v[1] * scale[1]; [x * c - y * sn, x * sn + y * c] }; - let n = s.edit("Free Transform Points", None, |proj, _| { + let n = s.edit("Free Transform Points", merge_p(p), |proj, _| { let mut n = 0; for (lid, uid) in &targets { let l = layer_mut(proj, cid, *lid)?; @@ -360,7 +360,7 @@ fn free_transform(s: &mut Session, p: &Value) -> Result { const SHAPE_ITEMS: &[&str] = &[ "group", "rect", "ellipse", "star", "path", "fill", "stroke", "gfill", "gstroke", "trim", "repeater", "round", "offset", "pucker", "twist", "zigzag", - "wiggle", "merge", "front", "bevel", "side", "back", + "wiggle", "wiggler", "merge", "front", "bevel", "side", "back", ]; /// Selected shape items (contents entries) of shape layers: (layer, uid). @@ -542,7 +542,7 @@ pub fn specs() -> Vec { "Free Transform Points", ["Layer", "Mask and Shape Path"], None, - "{layer?, mask?, scale?: % | [x%, y%], rotation?: deg, offset?: [dx, dy], anchor?: [x, y]}", + "{layer?, mask?, scale?: % | [x%, y%], rotation?: deg, offset?: [dx, dy], anchor?: [x, y], merge?: string}", has_layers, free_transform ), diff --git a/crates/engine/src/cmd/prop.rs b/crates/engine/src/cmd/prop.rs index 00232967e..663429fb2 100644 --- a/crates/engine/src/cmd/prop.rs +++ b/crates/engine/src/cmd/prop.rs @@ -1189,6 +1189,15 @@ fn toggle_hold(s: &mut Session, _: &Value) -> Result { }) } +fn set_muted(s: &mut Session, p: &Value) -> Result { + let muted = b_p(p, "muted").unwrap_or(true); + let label = if muted { "Mute Keyframes" } else { "Unmute Keyframes" }; + edit_keys(s, label, None, move |keys, i, _| { + keys[i].muted = muted; + false + }) +} + /// A number or a per-dimension array of numbers. fn nums_p(p: &Value, k: &str, scale: f64) -> Option> { match p.get(k)? { @@ -1285,8 +1294,16 @@ fn convert_one_expr(s: &mut Session, cid: ItemId, lid: LayerId, uid: Uid) -> Res let fd = comp.frame_duration(); let mut t = layer.in_point; while t < layer.out_point { - let ctx = - effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp: &comp, time: t, expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) }; + let ctx = effectcraft_render::EvalCtx { + project: &s.project, + comp_id: cid, + comp: &comp, + time: t, + expr: s.expr.as_deref(), + footage: Some(s.footage.as_ref()), + property_overrides: &[], + media_overrides: &[], + }; keys.push(Keyframe::new(layer.layer_time(t), ctx.value(&layer, &pr))); t += fd; } @@ -1373,6 +1390,7 @@ pub fn specs() -> Vec { &json!({"which": "out"}) )), cmd!("keys.toggleHold", "Toggle Hold Keyframe", ["Animation"], Some("Cmd+Alt+H"), "{}", has_keys, toggle_hold), + cmd!("keys.setMuted", "Mute Keyframes", ["Animation"], None, "{muted?: bool (default true)}", has_keys, set_muted), cmd!( "keys.interpolation", "Keyframe Interpolation...", diff --git a/crates/engine/src/cmd/proxy.rs b/crates/engine/src/cmd/proxy.rs index d15f93730..99650875c 100644 --- a/crates/engine/src/cmd/proxy.rs +++ b/crates/engine/src/cmd/proxy.rs @@ -117,6 +117,7 @@ fn interpret_proxy(s: &mut Session, p: &Value) -> Result { return Err(bad(C, "the selected items have no proxy")); } let it = super::file_more::Interpretation::parse(p, C)?; + it.check(items.iter().filter_map(|i| s.project.item(*i).and_then(|x| x.proxy.as_ref()).map(|px| &px.footage)), C)?; let guesses: Vec<_> = items .iter() .map(|i| { @@ -229,7 +230,7 @@ pub fn specs() -> Vec { "Proxy...", ["File", "Interpret Footage"], None, - "{items?|item?, alpha?, guessAlpha?, matteColor?|premulColor?, invertAlpha?, frameRate?, loop?, pixelAspect?, fields?, colorProfile?, linearLight?, preserveRgb?, overrideStart?, startTimecode? (as file.interpretFootage)}", + "{items?|item?, alpha?, guessAlpha?, matteColor?|premulColor?, invertAlpha?, frameRate?, loop?, pixelAspect?, fields?, colorProfile?, linearLight?, preserveRgb?, overrideStart?, startTimecode? (as file.interpretFootage), logConversion?, cineonUnits?, convertedBlack?, convertedWhite?, pointUnits?, tenBitBlack?, tenBitWhite?, currentGamma?, highlightRolloff? (Cineon Settings of DPX and Cineon proxies)}", has_proxy, interpret_proxy ), diff --git a/crates/engine/src/cmd/puppet.rs b/crates/engine/src/cmd/puppet.rs index 320e1d415..1d080e773 100644 --- a/crates/engine/src/cmd/puppet.rs +++ b/crates/engine/src/cmd/puppet.rs @@ -38,7 +38,8 @@ pub fn puppet_eval(s: &Session, cid: ItemId, lid: LayerId, fx_uid: Uid, t: Tick) let fx = layer.effects()?; let index = fx.groups().position(|g| g.uid == fx_uid)?; let g = fx.groups().nth(index)?; - let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None }; + let ctx = + EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None, property_overrides: &[], media_overrides: &[] }; let mut r = Renderer::new(&s.project, &*s.footage, RenderOpts::default()); r.expr = s.expr.as_deref(); r.cache = Some(&s.layer_cache); diff --git a/crates/engine/src/cmd/render_queue.rs b/crates/engine/src/cmd/render_queue.rs index 079c9bd30..00e078372 100644 --- a/crates/engine/src/cmd/render_queue.rs +++ b/crates/engine/src/cmd/render_queue.rs @@ -619,7 +619,7 @@ fn set_output_module(s: &mut Session, p: &Value) -> Result { let (w, h) = item.settings.output_size(comp); let mut crop_only = om.clone(); crop_only.resize.enabled = false; - let (cw, ch) = crop_only.frame_size(w, h, item.settings.resolution.clamp(0.01, 4.0)); + let (cw, ch) = crop_only.frame_size(w, h, item.settings.effective_scale(comp)); om.resize.width = ((om.resize.height as f64 * cw as f64 / ch as f64).round() as u32).clamp(1, 30_000); } let post = match str_p(p, "postRenderAction") { diff --git a/crates/engine/src/cmd/rig3d.rs b/crates/engine/src/cmd/rig3d.rs index f034fa641..dbcaa3c3c 100644 --- a/crates/engine/src/cmd/rig3d.rs +++ b/crates/engine/src/cmd/rig3d.rs @@ -48,7 +48,16 @@ impl Env { Env { time: s.time_of(cid), expr: s.expr.clone(), footage: s.footage.clone() } } fn ctx<'a>(&'a self, proj: &'a Project, cid: ItemId, comp: &'a Comp) -> EvalCtx<'a> { - EvalCtx { project: proj, comp_id: cid, comp, time: self.time, expr: self.expr.as_deref(), footage: Some(self.footage.as_ref()) } + EvalCtx { + project: proj, + comp_id: cid, + comp, + time: self.time, + expr: self.expr.as_deref(), + footage: Some(self.footage.as_ref()), + property_overrides: &[], + media_overrides: &[], + } } } @@ -268,7 +277,7 @@ fn stereo_rig(s: &mut Session, p: &Value) -> Result { set_expr(&mut cam, "cameraOptions/zoom", ze); cam.auto_orient = AutoOrient::TowardsPointOfInterest; let comp = proj.comp_mut(eid).ok_or(EngineError::NoComp)?; - comp.layers = vec![cam, nest]; + comp.layers = vec![cam, nest].into(); eye_comps.push(eid); } // The output comp: both eyes through 3D Glasses. @@ -291,7 +300,7 @@ fn stereo_rig(s: &mut Session, p: &Value) -> Result { fxg.children.push(g.into()); } let (lid, rid) = (left.id, right.id); - proj.comp_mut(oid).ok_or(EngineError::NoComp)?.layers = vec![left, right]; + proj.comp_mut(oid).ok_or(EngineError::NoComp)?.layers = vec![left, right].into(); st.project_selection = vec![oid]; Ok(json!({ "controls": ctl_id.0, diff --git a/crates/engine/src/cmd/scripts.rs b/crates/engine/src/cmd/scripts.rs index f4162043d..7ad0d34c3 100644 --- a/crates/engine/src/cmd/scripts.rs +++ b/crates/engine/src/cmd/scripts.rs @@ -7,6 +7,9 @@ //! panels appear at the bottom of the Window menu and open as dockable panels //! (`window.scriptPanel`), running with `this` = the panel as in After Effects. A subfolder of //! either folder (one level) is a submenu named after it; its scripts are named `Folder/File.jsx`. +//! * **Startup and Shutdown scripts** in `Scripts/Startup/` and `Scripts/Shutdown/` run by +//! themselves, in alphabetical order: at launch (`file.runStartupScripts`) and as EffectCraft +//! quits, after the project is closed (`file.runShutdownScripts`). //! * **ScriptUI windows** (`scriptui.*`): list the open script windows, read their control //! trees, click buttons and set values like a user, close them (see [`crate::scriptui`]). //! * **Script settings** (`script.settings.*`): After Effects' `app.settings`, string values by @@ -23,6 +26,18 @@ use crate::{EngineError, Event, Result, Session, cmd, scriptui}; /// The settings-store folders of installed scripts. pub const SCRIPTS_DIR: &str = "Scripts"; pub const PANELS_DIR: &str = "Scripts/ScriptUI Panels"; +/// The settings-store folders whose scripts run at launch and as EffectCraft quits. +pub const STARTUP_DIR: &str = "Scripts/Startup"; +pub const SHUTDOWN_DIR: &str = "Scripts/Shutdown"; +/// Commands that run script files or script code (a ScriptUI panel runs its script when it +/// opens). Scripts and batches can't run them, and confined automation sessions refuse them. +pub const RUNS_SCRIPTS: &[&str] = + &["script.run", "file.runScript", "file.runStartupScripts", "file.runShutdownScripts", "window.scriptPanel", "window.restoreScriptPanels"]; +/// Largest script file the Startup and Shutdown folders run (bytes), as `#include` and +/// `$.evalFile` allow. +const MAX_SCRIPT_BYTES: usize = 16 << 20; +/// Modal dialogs a Shutdown script may open one after another; each closes as if canceled. +const MAX_SHUTDOWN_DIALOGS: usize = 64; /// [`PANELS_DIR`]'s name in [`SCRIPTS_DIR`]: it holds the panels, so it is not a folder of scripts. const PANELS_FOLDER: &str = "ScriptUI Panels"; /// Subfolders of Scripts that hold scripts the app runs itself (panels; launch and quit, as in @@ -221,6 +236,7 @@ fn install(s: &mut Session, p: &Value, panel: bool) -> Result { let cfg = s.config.clone().ok_or_else(|| EngineError::Other("this session has no settings folder to install scripts into".into()))?; let dir = if panel { PANELS_DIR } else { SCRIPTS_DIR }; cfg.write(&format!("{dir}/{name}"), &code).map_err(|e| EngineError::Other(format!("cannot install {name}: {e}")))?; + s.scripts_changed(); // After Effects asks for a restart; our menus pick it up at once. s.toast(if panel { format!("Installed ScriptUI panel {name}: find it at the bottom of the Window menu") @@ -256,6 +272,7 @@ fn uninstall(s: &mut Session, p: &Value) -> Result { removed = true; } } + s.scripts_changed(); if removed { Ok(json!({"removed": name})) } else { Err(bad(c, format!("`{name}` is not installed"))) } } @@ -337,6 +354,85 @@ fn restore_panels(s: &mut Session, _: &Value) -> Result { Ok(json!({"opened": opened, "failed": failed})) } +/// Run the scripts in `dir` of the settings store one after another, in alphabetical order (case +/// ignored), as After Effects runs its Startup and Shutdown folders. A script that fails is +/// reported in a toast and the log and listed in `failed`; the next one still runs. A script waiting in a +/// modal dialog is listed in `waiting` and goes on when the dialog closes; the next one runs +/// meanwhile. With `quitting`, no window is left to show a dialog in, so each dialog closes as +/// if canceled (`show()` returns 2) and the script runs on. +fn run_folder(s: &mut Session, dir: &str, quitting: bool) -> Value { + let mut names: Vec = s.config.as_ref().map(|c| c.list(dir)).unwrap_or_default().into_iter().filter(|n| is_script(n) && is_script_name(n)).collect(); + names.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase()).then_with(|| a.cmp(b))); + let (mut ran, mut waiting, mut failed) = (vec![], vec![], vec![]); + for name in names { + let code = match s.config.as_ref().map(|c| c.read_bounded(&format!("{dir}/{name}"), MAX_SCRIPT_BYTES)) { + Some(Ok(Some(code))) => code, + read => { + let why = match read { + Some(Err(e)) => format!(": {e}"), + _ => String::new(), + }; + let error = format!("{name}: cannot read the script{why}"); + log::warn!("{dir}: {error}"); + s.events.push(Event::Toast { message: error.clone(), error: true }); + failed.push(json!({"name": name, "error": error})); + continue; + } + }; + let is_waiting = |r: &Result| r.as_ref().is_ok_and(|v| v.get("waiting").and_then(Value::as_bool) == Some(true)); + // In a settings folder on disk, the script runs as its file, so `#include`, `$.fileName` + // and `$.evalFile` find the files beside it. + let path = s.config.as_ref().and_then(|c| c.dir()).map(|d| d.join(dir).join(&name).to_string_lossy().into_owned()).unwrap_or_else(|| name.clone()); + // Windows open before this script runs belong to other scripts, perhaps of the same name. + let before: Vec = s.script_ui.windows.iter().map(|w| w.id).collect(); + let hosts_before = s.script_ui.task_hosts.clone(); + let mut r = super::file_more::run_js_file(s, &code, &path, &name); + if !quitting { + // The Startup script's context: the one that scheduled tasks or holds the windows it + // opened (a task scheduled after its dialog closes runs there too). + let tasks = s.script_ui.task_hosts.iter().filter(|h| !hosts_before.contains(h)).copied(); + let windows = s.script_ui.windows.iter().filter(|w| !before.contains(&w.id)).map(|w| w.host); + let hosts: Vec = tasks.chain(windows).collect(); + for h in hosts { + if !s.script_ui.startup_hosts.contains(&h) { + s.script_ui.startup_hosts.push(h); + } + } + } + let mut closed = 0; + while quitting && closed < MAX_SHUTDOWN_DIALOGS && is_waiting(&r) { + let Some(id) = s.script_ui.windows.iter().rev().find(|w| w.modal && w.script == path && !before.contains(&w.id)).map(|w| w.id) else { break }; + // report_js reports an error the script throws after its dialog closes, as it does for an + // error in the script's first run. + r = scriptui::close(s, &json!({"window": id})).and_then(|out| super::file_more::report_js(s, &name, out)); + closed += 1; + } + match r { + Ok(_) if is_waiting(&r) => waiting.push(name), + Ok(_) => ran.push(name), + Err(e) => { + // Also in the log: no frame draws a Shutdown script's toast. + log::warn!("{dir}: {e}"); + failed.push(json!({"name": name, "error": e.to_string()})); + } + } + } + json!({"ran": ran, "waiting": waiting, "failed": failed}) +} + +/// `file.runStartupScripts`: run the scripts in `Scripts/Startup` (frontends call it once at +/// launch, before a project opens). +fn run_startup(s: &mut Session, _: &Value) -> Result { + Ok(run_folder(s, STARTUP_DIR, false)) +} + +/// `file.runShutdownScripts`: close the project, then run the scripts in `Scripts/Shutdown` +/// (frontends call it as the app quits, after the unsaved-changes prompt). +fn run_shutdown(s: &mut Session, _: &Value) -> Result { + s.replace_project(effectcraft_project::Project::default(), None); + Ok(run_folder(s, SHUTDOWN_DIR, true)) +} + /// Window ▸ ``: run the panel script (or bring its panel forward). fn open_panel(s: &mut Session, p: &Value) -> Result { let c = "window.scriptPanel"; @@ -414,6 +510,24 @@ pub fn specs() -> Vec { always, restore_panels ), + cmd!( + "file.runStartupScripts", + "Run Startup Scripts", + [], + None, + "{} → runs the scripts in Scripts/Startup in alphabetical order (frontends call it at launch) → {ran: [name], waiting: [name] (in a modal dialog), failed: [{name, error}]}", + always, + run_startup + ), + cmd!( + "file.runShutdownScripts", + "Run Shutdown Scripts", + [], + None, + "{} → closes the project, then runs the scripts in Scripts/Shutdown in alphabetical order (frontends call it as the app quits; their modal dialogs close as if canceled) → {ran: [name], waiting: [name], failed: [{name, error}]}", + always, + run_shutdown + ), crate::query!("script.settings.get", "Get Script Setting", "{section, key} → {have, value} (app.settings.haveSetting / getSetting)", settings_get), cmd!( "script.settings.save", @@ -463,7 +577,7 @@ pub fn specs() -> Vec { "Mouse Event on Script Window Control", [], None, - "{window?, widget, type: mousedown | mouseup | mousemove | mouseover | mouseout | click (default), x?, y? (relative to the control; default its centre), button? (0 left, 1 middle, 2 right), detail? (click count), screenX?, screenY?, altKey?, ctrlKey?, metaKey?, shiftKey?} → the script's addEventListener listeners on the control and its containers receive a MouseEvent", + "{window?, widget, type: mousedown | mouseup | mousemove | mouseover | mouseout | click (default), x?, y? (relative to the control; default its centre), button? (0 left, 1 middle, 2 right), buttons? (held: 1 left, 2 right, 4 middle), detail? (click count), screenX?, screenY?, altKey?, ctrlKey?, metaKey?, shiftKey?} → the script's addEventListener listeners on the control and its containers receive a MouseEvent", always, scriptui::mouse ), @@ -948,4 +1062,64 @@ mod tests { assert!(bad_params(&s.execute("file.uninstallScript", json!({"name": "Hello.jsx"}))), "no longer installed"); let _ = std::fs::remove_dir_all(&root); } + + /// Edit ▸ Keyboard Shortcuts lists the scripts File ▸ Scripts lists (not ScriptUI panels), + /// as soon as one is installed and until it is uninstalled; a key given to one is saved with + /// the preset. + #[test] + fn file_scripts_entries_take_keyboard_shortcuts() { + use crate::shortcuts::{Keymaps, SHORTCUTS_FILE, binding_key}; + let (mut s, root) = session_in_tmp("shortcuts"); + let (script, panel) = (root.join("Hello.jsx"), root.join("Hello Panel.jsx")); + std::fs::write(&script, "hello").unwrap(); + std::fs::write(&panel, "panel").unwrap(); + let listed = |s: &Session, name: &str| { + s.shortcuts().find(&binding_key("file.runScript", &json!({"name": name}))).map(|b| (b.label.clone(), b.path.join(" ▸ "), b.defaults.clone())) + }; + assert_eq!(listed(&s, "Rename Layers.jsx"), Some(("Rename Layers.jsx".into(), "File ▸ Scripts".into(), vec![])), "the bundled samples"); + assert_eq!(listed(&s, "Hello.jsx"), None); + s.execute("file.installScript", json!({"path": script.to_string_lossy()})).unwrap(); + s.execute("file.installScriptUIPanel", json!({"path": panel.to_string_lossy()})).unwrap(); + assert_eq!(listed(&s, "Hello.jsx"), Some(("Hello.jsx".into(), "File ▸ Scripts".into(), vec![]))); + assert_eq!(listed(&s, "Hello Panel.jsx"), None, "panels are in the Window menu"); + let r = s.execute("shortcuts.set", json!({"command": "file.runScript", "params": {"name": "Hello.jsx"}, "keys": "Cmd+Alt+Shift+F7"})).unwrap(); + let key = binding_key("file.runScript", &json!({"name": "Hello.jsx"})); + assert_eq!((&r["command"], &r["keys"]), (&json!(key), &json!(["Cmd+Alt+Shift+F7"])), "{r}"); + assert_eq!(s.shortcuts().shortcut_of("file.runScript", &json!({"name": "Hello.jsx"})), Some("Cmd+Alt+Shift+F7")); + let saved = Keymaps::from_json(&std::fs::read_to_string(root.join("settings").join(SHORTCUTS_FILE)).unwrap()); + assert_eq!(saved.active_preset().and_then(|p| p.overrides.get(&key)), Some(&vec!["Cmd+Alt+Shift+F7".to_string()])); + s.execute("file.uninstallScript", json!({"name": "Hello.jsx"})).unwrap(); + assert_eq!(listed(&s, "Hello.jsx"), None); + let _ = std::fs::remove_dir_all(&root); + } + + /// `shortcuts.list` and `shortcuts.set` see a script copied into the Scripts folder by hand + /// after the shortcut table was built; a script the folder doesn't hold (never there, or + /// removed by hand) can't take a key, and Run Script File… keeps its own keys. + #[test] + fn shortcut_commands_find_scripts_copied_into_the_folder() { + let (mut s, root) = session_in_tmp("copied"); + assert!(s.shortcuts().find("file.runScript").is_some(), "the table is built"); + std::fs::create_dir_all(root.join("settings").join(SCRIPTS_DIR)).unwrap(); + std::fs::write(root.join("settings").join(SCRIPTS_DIR).join("Copied.jsx"), "copied").unwrap(); + let list = s.execute("shortcuts.list", json!({"query": "copied.jsx"})).unwrap(); + assert_eq!(list["count"], 1, "{list}"); + let r = s.execute("shortcuts.set", json!({"command": "file.runScript", "params": {"name": "Copied.jsx"}, "keys": "Cmd+Alt+Shift+F7"})).unwrap(); + assert_eq!(r["command"], r#"file.runScript {"name":"Copied.jsx"}"#, "{r}"); + let r = s.execute("shortcuts.set", json!({"command": "file.runScript", "params": {"name": "Missing.jsx"}, "keys": "Cmd+Alt+Shift+F8"})); + assert!(bad_params(&r), "{r:?}"); + // The binding-key form finds a script copied in since the last lookup, too. + std::fs::write(root.join("settings").join(SCRIPTS_DIR).join("Second.jsx"), "second").unwrap(); + let r = s.execute("shortcuts.set", json!({"command": r#"file.runScript {"name":"Second.jsx"}"#, "keys": "Cmd+Alt+Shift+F10"})).unwrap(); + assert_eq!(r["command"], r#"file.runScript {"name":"Second.jsx"}"#, "{r}"); + // Removed by hand: no longer in the folder. + std::fs::remove_file(root.join("settings").join(SCRIPTS_DIR).join("Copied.jsx")).unwrap(); + std::fs::remove_file(root.join("settings").join(SCRIPTS_DIR).join("Second.jsx")).unwrap(); + let r = s.execute("shortcuts.set", json!({"command": r#"file.runScript {"name":"Second.jsx"}"#, "keys": "Cmd+Alt+Shift+F11"})); + assert!(bad_params(&r), "{r:?}"); + let r = s.execute("shortcuts.set", json!({"command": "file.runScript", "params": {"name": "Copied.jsx"}, "keys": "Cmd+Alt+Shift+F9"})); + assert!(bad_params(&r), "{r:?}"); + assert_eq!(s.shortcuts().shortcut_of("file.runScript", &Value::Null), None, "Run Script File… has no key"); + let _ = std::fs::remove_dir_all(&root); + } } diff --git a/crates/engine/src/cmd/settings.rs b/crates/engine/src/cmd/settings.rs index a6621c3f4..272903c69 100644 --- a/crates/engine/src/cmd/settings.rs +++ b/crates/engine/src/cmd/settings.rs @@ -73,13 +73,21 @@ fn prefs_pages(_: &mut Session, _: &Value) -> Result { // ---------------------------------------------------------------- shortcuts -/// The bindable named by `command` (+ `params`), or by a binding key (`app.settings {...}`). -fn bindable(s: &Session, p: &Value, cmd: &str) -> Result { +/// The bindable named by `command` (+ `params`), or by a binding key (`app.settings {...}`). A +/// File ▸ Scripts entry (`file.runScript {"name": …}`) is looked up in File ▸ Scripts' list as it +/// is now (the Scripts folder and the bundled scripts), and a script it doesn't list is an error, +/// not Run Script File…. +fn bindable(s: &mut Session, p: &Value, cmd: &str) -> Result { let c = str_p(p, "command").ok_or_else(|| bad(cmd, "missing `command`"))?; let params = p.get("params").cloned().unwrap_or(Value::Null); - let t = s.shortcuts(); let key = binding_key(c, ¶ms); - t.find(&key).or_else(|| t.find(c)).cloned().ok_or_else(|| bad(cmd, format!("`{key}` can't have a shortcut (see shortcuts.list)"))) + let script = (c == "file.runScript" && params.get("name").is_some()) || c.starts_with("file.runScript {"); + if script { + s.scripts_changed(); + } + let t = s.shortcuts(); + let found = if script { t.find(&key) } else { t.find(&key).or_else(|| t.find(c)) }; + found.cloned().ok_or_else(|| bad(cmd, format!("`{key}` can't have a shortcut (see shortcuts.list)"))) } fn keys_param(p: &Value, cmd: &str) -> Result> { @@ -93,6 +101,8 @@ fn keys_param(p: &Value, cmd: &str) -> Result> { } fn sc_list(s: &mut Session, p: &Value) -> Result { + // Scripts copied into the Scripts folder since the table was built are listed too. + s.scripts_changed(); let q = str_p(p, "query").unwrap_or("").to_lowercase(); let assigned = p.get("assigned").and_then(Value::as_bool).unwrap_or(false); let keys_q = str_p(p, "keys").and_then(normalize); diff --git a/crates/engine/src/cmd/shape_tool.rs b/crates/engine/src/cmd/shape_tool.rs index e1f3607b1..3323d61d6 100644 --- a/crates/engine/src/cmd/shape_tool.rs +++ b/crates/engine/src/cmd/shape_tool.rs @@ -180,16 +180,17 @@ fn rgba(c: [f32; 3]) -> [f64; 4] { [c[0] as f64, c[1] as f64, c[2] as f64, 1.0] } -/// The Rounded Rectangle tool's corner roundness for `size`. -fn roundness(size: [f64; 2]) -> f64 { - size[0].min(size[1]) * 0.15 +/// The Rounded Rectangle tool's corner roundness: a fixed default, as in After Effects +/// (it does not grow with the drawn size). +fn roundness() -> f64 { + 20.0 } /// The Polygon and Star tools' Polystar for `size`: (star, points, outer radius, inner radius). fn polystar_of(kind: &str, size: [f64; 2]) -> Option<(bool, f64, f64, f64)> { match kind { "star" => Some((true, 5.0, size[0] / 2.0, size[0] / 4.0)), - "polygon" => Some((false, 6.0, size[0] / 2.0, 0.0)), + "polygon" => Some((false, 5.0, size[0] / 2.0, 0.0)), _ => None, } } @@ -199,7 +200,7 @@ fn polystar_of(kind: &str, size: [f64; 2]) -> Option<(bool, f64, f64, f64)> { pub(crate) fn shape_group(ids: &mut Ids, kind: &str, size: [f64; 2], paint: &ShapeTool) -> Option { let (path, gname) = match kind { "rect" | "rectangle" => (build::shape_rect(ids, size, [0.0, 0.0], 0.0), "Rectangle 1"), - "rounded" | "roundedRect" => (build::shape_rect(ids, size, [0.0, 0.0], roundness(size)), "Rectangle 1"), + "rounded" | "roundedRect" => (build::shape_rect(ids, size, [0.0, 0.0], roundness()), "Rectangle 1"), "ellipse" => (build::shape_ellipse(ids, size, [0.0, 0.0]), "Ellipse 1"), k => { let (star, points, outer, inner) = polystar_of(k, size)?; @@ -218,7 +219,7 @@ pub fn mask_path(kind: &str, centre: [f64; 2], size: [f64; 2]) -> Option return Some(ShapePath::rect(centre, size[0], size[1])), "ellipse" => return Some(ShapePath::ellipse(centre, size[0], size[1])), - "rounded" | "roundedRect" => effectcraft_path::rect(size, centre, roundness(size)), + "rounded" | "roundedRect" => effectcraft_path::rect(size, centre, roundness()), k => { let (star, points, outer, inner) = polystar_of(k, size)?; effectcraft_path::polystar(effectcraft_path::Polystar { diff --git a/crates/engine/src/cmd/text_anim.rs b/crates/engine/src/cmd/text_anim.rs index 92ab23f17..296dc4c37 100644 --- a/crates/engine/src/cmd/text_anim.rs +++ b/crates/engine/src/cmd/text_anim.rs @@ -142,6 +142,7 @@ fn add_animator_prop(s: &mut Session, p: &Value) -> Result { } let layer = s.project.comp(cid).and_then(|c| c.layer(lid)).cloned().ok_or_else(|| bad(C, "no layer"))?; let auid = animator_uid(s, &layer, p, C)?; + let mut index = 0; s.edit("Add Text Animator Property", None, |proj, _| { let mut next = proj.next_id; let mut ids = Ids(&mut next); @@ -151,10 +152,13 @@ fn add_animator_prop(s: &mut Session, p: &Value) -> Result { for k in &kinds { add_props(&mut ids, anim, k, three); } + // The requested property's 1-based position in the Properties group. A companion (such as + // Tracking Type) is added before it, so the first new child is not always the one asked for. + index = kinds.first().and_then(|k| anim.sub("properties")?.children.iter().position(|n| n.match_id() == k.as_str())).map_or(0, |i| i + 1); proj.next_id = next; Ok(()) })?; - Ok(json!({"animator": auid})) + Ok(json!({"animator": auid, "index": index})) } pub(crate) fn add_selector(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/text_edit.rs b/crates/engine/src/cmd/text_edit.rs index 0d9b25aec..cdb650486 100644 --- a/crates/engine/src/cmd/text_edit.rs +++ b/crates/engine/src/cmd/text_edit.rs @@ -71,7 +71,8 @@ pub struct TextEdit { /// Character panel changes made with only a caret: the style the next typed text takes. #[serde(default)] pub pending: Option, - /// The layer was made by a Type tool click; leaving it empty deletes it. + /// The layer was made by a Type tool click. Kept when left empty, as After Effects does, so this + /// only records how the edit began. #[serde(default)] pub created: bool, /// Line position kept by repeated up / down moves (layout space). @@ -372,18 +373,14 @@ fn edit(s: &mut Session, p: &Value) -> Result { Ok(state_json(s)) } -/// `text.endEdit`: stop editing (a Type-tool layer left empty is deleted). +/// `text.endEdit`: stop editing. +/// +/// A Type-tool layer the user clicked away from without typing is **kept**: After Effects leaves it in +/// the timeline as `<空白のテキストレイヤー>`, and the layer came from the user's click rather than from a +/// mistake to clean up. Deleting it took away something they could still type into. pub(crate) fn end_edit(s: &mut Session, _: &Value) -> Result { - let Some(e) = s.state.text_edit.take() else { return Ok(Value::Null) }; - if e.created && layer_doc(s, e.layer).is_some_and(|d| d.text.is_empty()) { - let cid = s.active_comp_id().ok_or(EngineError::NoComp)?; - s.edit("Delete Empty Text Layer", None, |proj, st| { - if let Some(c) = proj.comp_mut(cid) { - c.layers.retain(|l| l.id != e.layer); - } - st.selected_layers.retain(|l| *l != e.layer); - Ok(()) - })?; + if s.state.text_edit.take().is_none() { + return Ok(Value::Null); } s.bump(); Ok(Value::Null) diff --git a/crates/engine/src/cmd/three_d.rs b/crates/engine/src/cmd/three_d.rs index e143690d3..f7b1c3e08 100644 --- a/crates/engine/src/cmd/three_d.rs +++ b/crates/engine/src/cmd/three_d.rs @@ -12,6 +12,7 @@ use effectcraft_time::Tick; use serde_json::{Value, json}; use super::layer::{color_p, insert_layer}; +use super::model3d::{report_switch, use_advanced_3d}; use super::{CommandSpec, b_p, bad, comp_id, f_p, has_comp, layer_mut, layer_p, merge_p, str_p, time_p}; use crate::{EngineError, Result, Session, cmd, query}; @@ -227,12 +228,17 @@ fn new_light(s: &mut Session, p: &Value) -> Result { }; let name = str_p(p, "name").unwrap_or("Light 1").to_string(); let t = time_p(s, p, Some(&comp)); - let id = s.edit("New Light", None, |proj, st| { + // Only the Advanced 3D renderer lights with Environment lights, so adding one switches a + // Classic 3D comp to it, like After Effects (inside the adding edit: one undo step). + let env = kind == LightKind::Environment; + let (id, switched) = s.edit("New Light", None, |proj, st| { let mut l = build::layer(proj, &comp, &name, LayerSource::Light { kind }, (comp.width, comp.height), None); apply_light(&mut l, p, t, "layer.newLight")?; - insert_layer(proj, st, cid, l) + let id = insert_layer(proj, st, cid, l)?; + Ok((id, if env { use_advanced_3d(proj, cid)? } else { false })) })?; - Ok(json!({"layer": id.0})) + report_switch(s, cid, switched, "which Environment lights need to light the scene"); + Ok(json!({"layer": id.0, "advanced3d": env})) } fn light_settings(s: &mut Session, p: &Value) -> Result { @@ -248,7 +254,7 @@ fn light_settings(s: &mut Session, p: &Value) -> Result { ), None => None, }; - s.edit("Light Settings", None, |proj, _| { + let switched = s.edit("Light Settings", None, |proj, _| { let old = proj.comp(cid).and_then(|c| c.layer(lid)).cloned().ok_or(EngineError::NoComp)?; let mut l = old.clone(); if let Some(k) = kind @@ -288,9 +294,14 @@ fn light_settings(s: &mut Session, p: &Value) -> Result { l.name = n.clone(); } apply_light(&mut l, p, t, "layer.lightSettings")?; + // A light that becomes an Environment light switches a Classic 3D comp to Advanced 3D, + // like adding one does; editing one that already is leaves the renderer to the user. + let was_env = matches!(old.source, LayerSource::Light { kind: LightKind::Environment }); + let is_env = matches!(l.source, LayerSource::Light { kind: LightKind::Environment }); *layer_mut(proj, cid, lid)? = l; - Ok(()) + Ok(if !was_env && is_env { use_advanced_3d(proj, cid)? } else { false }) })?; + report_switch(s, cid, switched, "which Environment lights need to light the scene"); Ok(Value::Null) } @@ -413,7 +424,8 @@ fn get_3d(s: &mut Session, p: &Value) -> Result { let (w, h) = (comp.width as f64, comp.height as f64); let vs = s.state.views3d.get(&cid).cloned().unwrap_or_default(); let t = s.state.times.get(&cid).copied().unwrap_or(Tick::ZERO); - let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None }; + let ctx = + EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None, property_overrides: &[], media_overrides: &[] }; let cam = vs.override_camera(w, h).unwrap_or_else(|| camera::active_camera(&ctx)); let f = cam.forward(); let lights: Vec = comp @@ -532,7 +544,7 @@ fn auto_orient(s: &mut Session, p: &Value) -> Result { }; s.edit("Auto-Orient", None, |proj, _| { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; - for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) { + for l in comp.layers.iter_mut_where(|l| ids.contains(&l.id)) { let rig = l.is_camera() || l.is_light(); let ok = match mode { AutoOrient::Off | AutoOrient::AlongPath => true, @@ -555,7 +567,8 @@ fn auto_orient(s: &mut Session, p: &Value) -> Result { /// through each layer's world matrix; cameras and lights contribute their position). fn layer_points(s: &Session, cid: ItemId, ids: &[LayerId], t: Tick) -> Vec { let Some(comp) = s.project.comp(cid) else { return vec![] }; - let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None }; + let ctx = + EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None, property_overrides: &[], media_overrides: &[] }; let mut out = vec![]; for l in comp.layers.iter().filter(|l| ids.contains(&l.id)) { let m = ctx.world_matrix(l); @@ -681,7 +694,7 @@ pub fn specs() -> Vec { "Light...", ["Layer", "New"], Some("Cmd+Alt+Shift+L"), - "{kind?: Parallel|Spot|Point|Ambient|Environment (default Spot; Environment lights and reflects from an equirectangular image layer: prop.set its lightOptions/source to that layer's id, or leave it empty for the comp's Environment Layer, see layer.environment), name?, color?, intensity?, coneAngle?, coneFeather?, falloff?: None|Smooth|Inverse Square Clamped, radius?, falloffDistance?, castsShadows?, shadowDarkness?, shadowDiffusion?, position? [x,y,z], poi? [x,y,z]}", + "{kind?: Parallel|Spot|Point|Ambient|Environment (default Spot; Environment lights and reflects from an equirectangular image layer: prop.set its lightOptions/source to that layer's id, or leave it empty for the comp's Environment Layer or a flat default environment of its colour and intensity, see layer.environment; an Environment light switches a Classic 3D comp to Advanced 3D), name?, color?, intensity?, coneAngle?, coneFeather?, falloff?: None|Smooth|Inverse Square Clamped, radius?, falloffDistance?, castsShadows?, shadowDarkness?, shadowDiffusion?, position? [x,y,z], poi? [x,y,z]}", has_comp, new_light ), @@ -708,7 +721,7 @@ pub fn specs() -> Vec { "Light Settings...", [], None, - "{layer?, name?, kind?: Parallel|Spot|Point|Ambient|Environment, color?, intensity?, coneAngle?, coneFeather?, falloff?, radius?, falloffDistance?, castsShadows?, shadowDarkness?, shadowDiffusion?, position?, poi?}", + "{layer?, name?, kind?: Parallel|Spot|Point|Ambient|Environment (changing to Environment switches a Classic 3D comp to Advanced 3D), color?, intensity?, coneAngle?, coneFeather?, falloff?, radius?, falloffDistance?, castsShadows?, shadowDarkness?, shadowDiffusion?, position?, poi?}", has_comp, light_settings ), diff --git a/crates/engine/src/footage/footage_reload.rs b/crates/engine/src/footage/footage_reload.rs index 75fd0f936..267985e37 100644 --- a/crates/engine/src/footage/footage_reload.rs +++ b/crates/engine/src/footage/footage_reload.rs @@ -81,6 +81,8 @@ fn refreshed(s: &Session, f: &Footage, probed: Footage) -> Footage { nf.codec = probed.codec; nf.has_video = probed.has_video; nf.has_audio = probed.has_audio; + // The markers the file carries now (a WAV's cue points), for Update Markers From Source. + nf.markers = probed.markers; if matches!(f.kind, FootageKind::Video | FootageKind::Audio) { nf.duration = probed.duration; // Interpret Footage ▸ Conform to frame rate stays; the file's own rate is refreshed. diff --git a/crates/engine/src/footage/sequence.rs b/crates/engine/src/footage/sequence.rs index 46dca154c..fc2dab2b0 100644 --- a/crates/engine/src/footage/sequence.rs +++ b/crates/engine/src/footage/sequence.rs @@ -45,14 +45,13 @@ impl SequenceOptions { /// `file.import`'s options. Dropped files (`drag: true`) import as stills, as in After /// Effects, while a folder's numbered stills import as sequences unless `sequence: false` /// (Alt-drag of a folder). The Import dialog passes its `" Sequence"` checkbox (off - /// unless ticked) as `sequence`. Without `sequence` or `drag` (scripts, agents) a numbered - /// still brings its run, as it always has. + /// unless ticked) as `sequence`. Without `sequence` (scripts, agents) a numbered still imports + /// as itself, as `ImportOptions.sequence` defaults to false in After Effects (#2018). pub fn for_import(p: &serde_json::Value) -> SequenceOptions { let flag = |k: &str| p.get(k).and_then(serde_json::Value::as_bool); let (sequence, alphabetical) = (flag("sequence"), flag("alphabetical").unwrap_or(false)); - let dropped = flag("drag").unwrap_or(false); // Force Alphabetical Order is part of the Sequence option. - SequenceOptions { sequence: sequence.unwrap_or(alphabetical || !dropped), folders: sequence.unwrap_or(true), alphabetical } + SequenceOptions { sequence: sequence.unwrap_or(alphabetical), folders: sequence.unwrap_or(true), alphabetical } } } diff --git a/crates/engine/src/home/demo.rs b/crates/engine/src/home/demo.rs index 656bd7708..7503178fa 100644 --- a/crates/engine/src/home/demo.rs +++ b/crates/engine/src/home/demo.rs @@ -121,7 +121,7 @@ fn lower_third(p: &mut Project) -> Comp { [440.0, 914.0], ); anim(&mut title, "transform/opacity", keys(&[(0.4, Value::Scalar(0.0)), (0.9, Value::Scalar(100.0))])); - c.layers = vec![title, bar]; + c.layers = vec![title, bar].into(); c } @@ -270,7 +270,7 @@ fn showcase_3d(p: &mut Project, solids: ItemId) -> Comp { ); anim(&mut caption, "transform/opacity", keys(&[(0.3, Value::Scalar(0.0)), (1.2, Value::Scalar(100.0))])); - c.layers = vec![caption, cam, key, fill, title, left, right, x1, x2, floor]; + c.layers = vec![caption, cam, key, fill, title, left, right, x1, x2, floor].into(); c } @@ -463,7 +463,7 @@ pub fn demo_project() -> Project { lower.out_point = t(9.5); // Stack: top first. - comp.layers = vec![lower, title, sub, line, burst, rings, null, bg]; + comp.layers = vec![lower, title, sub, line, burst, rings, null, bg].into(); for l in &mut comp.layers { if l.name == "Controller" { l.switches.video = false; diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index 9296e1322..a7418ae58 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -1036,6 +1036,8 @@ Animation --- Add Keyframe | anim.addKeyframe Toggle Hold Keyframe | keys.toggleHold + Mute Keyframes | keys.setMuted + Unmute Keyframes | keys.setMuted {"muted":false} Keyframe Interpolation... | keys.interpolation Keyframe Velocity... | keys.velocity Keyframe Assistant diff --git a/crates/engine/src/render_queue.rs b/crates/engine/src/render_queue.rs index a83f494d2..d8f8cf7b5 100644 --- a/crates/engine/src/render_queue.rs +++ b/crates/engine/src/render_queue.rs @@ -686,7 +686,7 @@ impl Session { continue; } if let Some(c) = proj.comp_mut(cid) { - for l in c.layers.iter_mut().filter(|l| matches!(l.source, LayerSource::Comp { item } if item == comp)) { + for l in c.layers.iter_mut_where(|l| matches!(l.source, LayerSource::Comp { item } if item == comp)) { l.source = LayerSource::Footage { item: fid }; } } diff --git a/crates/engine/src/scriptui.rs b/crates/engine/src/scriptui.rs index 6d218a7bb..1ad4b9a40 100644 --- a/crates/engine/src/scriptui.rs +++ b/crates/engine/src/scriptui.rs @@ -402,12 +402,16 @@ pub struct ScriptUi { #[serde(skip)] pub tasks: Option, /// Bumped whenever the project is closed or replaced (New / Open / Close Project): repeating - /// tasks of scripts without an open window stop then. + /// tasks of scripts without an open window stop then, except those of Startup scripts. #[serde(skip)] pub project_epoch: u64, /// The script contexts of this session with scheduled tasks (kept by the scripting engine). #[serde(skip)] pub task_hosts: Vec, + /// The script contexts of Startup scripts: their tasks go on when the project is closed or + /// replaced, until EffectCraft quits. + #[serde(skip)] + pub startup_hosts: Vec, } impl ScriptUi { @@ -755,6 +759,7 @@ pub(crate) fn mouse(s: &mut Session, p: &Value) -> Result { "screenX": num("screenX", -FAR, FAR, 0.0), "screenY": num("screenY", -FAR, FAR, 0.0), "button": num("button", 0.0, 2.0, 0.0).round(), + "buttons": num("buttons", 0.0, 7.0, 0.0).round(), "detail": num("detail", 0.0, 1000.0, clicks).round(), "altKey": flag("altKey"), "ctrlKey": flag("ctrlKey"), diff --git a/crates/engine/src/services.rs b/crates/engine/src/services.rs index adbd3ab81..2d425a4e6 100644 --- a/crates/engine/src/services.rs +++ b/crates/engine/src/services.rs @@ -50,8 +50,20 @@ pub trait Services: Send + Sync { pub struct FsServices; impl Services for FsServices { + /// Refuses what isn't a regular file: a project can name `/dev/zero` or a FIFO, and reading + /// those never ends (#2134). fn read_file(&self, path: &str) -> std::io::Result> { - std::fs::read(path) + use std::io::Read; + let mut file = std::fs::File::open(path)?; + let meta = file.metadata()?; + if !meta.is_file() { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("{path} is not a file"))); + } + let mut bytes = Vec::new(); + // A sparse file can claim far more than memory: an error, not an abort. + bytes.try_reserve_exact(usize::try_from(meta.len()).unwrap_or(usize::MAX)).map_err(|e| std::io::Error::new(std::io::ErrorKind::OutOfMemory, e))?; + file.read_to_end(&mut bytes)?; + Ok(bytes) } fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> { config::atomic_write(std::path::Path::new(path), data) @@ -70,3 +82,21 @@ pub trait Importer: Send + Sync { /// media layer reads from memory (the browser) register them; file-based ones need nothing. fn add_frames(&self, _paths: &[String]) {} } + +#[cfg(test)] +mod tests { + use super::{FsServices, Services}; + + /// #2134: a project can name a folder, `/dev/zero` or a FIFO; reading those is an error. + #[test] + fn fs_services_read_only_regular_files() { + let dir = std::env::temp_dir(); + assert!(FsServices.read_file(&dir.to_string_lossy()).is_err()); + #[cfg(unix)] + assert!(FsServices.read_file("/dev/zero").is_err()); + let f = dir.join(format!("ec-services-read-{}.txt", std::process::id())); + std::fs::write(&f, b"data").unwrap(); + assert_eq!(FsServices.read_file(&f.to_string_lossy()).unwrap(), b"data"); + let _ = std::fs::remove_file(f); + } +} diff --git a/crates/engine/src/session/render.rs b/crates/engine/src/session/render.rs index 6f2d273ad..86127a510 100644 --- a/crates/engine/src/session/render.rs +++ b/crates/engine/src/session/render.rs @@ -96,7 +96,7 @@ impl Session { effectcraft_project::ItemKind::Footage(f) if f.has_video => { let f = f.clone(); Some(Box::new(move || { - let img = footage.frame(item, &f, Tick(0))?; + let img = effectcraft_render::cineon_frame(footage.frame(item, &f, Tick(0))?, &f); // Point-sample down to at most `max_side` on the long side. let step = (img.width.max(img.height) as f32 / max_side.max(1) as f32).ceil().max(1.0) as u32; let (w, h) = (img.width.div_ceil(step), img.height.div_ceil(step)); diff --git a/crates/engine/src/settings/prefs/choices.rs b/crates/engine/src/settings/prefs/choices.rs index 4145e0161..8935507a6 100644 --- a/crates/engine/src/settings/prefs/choices.rs +++ b/crates/engine/src/settings/prefs/choices.rs @@ -8,6 +8,7 @@ pub const LANGUAGES: &[(&str, &str)] = &[ ("Deutsch", "de"), ("Español", "es"), ("Français", "fr"), + ("Polski", "pl"), ("Português (Brasil)", "pt-br"), ("Русский", "ru"), ("Українська", "uk"), diff --git a/crates/engine/src/settings/prefs/dialog.rs b/crates/engine/src/settings/prefs/dialog.rs index e3f2006d8..d330ce190 100644 --- a/crates/engine/src/settings/prefs/dialog.rs +++ b/crates/engine/src/settings/prefs/dialog.rs @@ -122,6 +122,11 @@ pub fn pages() -> Vec { Section("New Project"), s("project.useTemplate", "New Project Loads Template", B, true), s("project.templatePath", "Template Project", Kind::Path, true), + Section("Project Files"), + s("project.compactProjectFiles", "Save Compact Project Files", B, true), + Note( + "Saves .ecproj files as compact JSON on one line: smaller and faster to save and open, but hard to read or diff in version control. Off, they are indented, one value per line.", + ), Section("Auto-Save"), s("autoSave.enabled", "Automatically Save Projects", B, true), s("autoSave.intervalMinutes", "Save Every", Kind::Int(1, 240, "Minutes"), true), diff --git a/crates/engine/src/settings/prefs/sections.rs b/crates/engine/src/settings/prefs/sections.rs index e0bea8b85..aa683e2b8 100644 --- a/crates/engine/src/settings/prefs/sections.rs +++ b/crates/engine/src/settings/prefs/sections.rs @@ -62,6 +62,9 @@ page!(ProjectPrefs { /// Open a template project for File ▸ New ▸ New Project. use_template: bool = false, template_path: String = String::new(), + /// Write `.ecproj` files as compact JSON on one line (smaller, faster to save and open). Off: + /// indented, one value per line, so they read and diff cleanly in version control. + compact_project_files: bool = false, }); page!(AutoSave { diff --git a/crates/engine/src/settings/session_settings.rs b/crates/engine/src/settings/session_settings.rs index 609ad5987..4fe47e0ef 100644 --- a/crates/engine/src/settings/session_settings.rs +++ b/crates/engine/src/settings/session_settings.rs @@ -195,9 +195,19 @@ impl Session { self.shortcut_table = std::sync::OnceLock::new(); } - /// The active shortcut preset resolved against every bindable command. + /// The scripts in the Scripts folder changed: list File ▸ Scripts' entries again in the + /// shortcut table. + pub fn scripts_changed(&mut self) { + self.shortcut_table = std::sync::OnceLock::new(); + } + + /// The active shortcut preset resolved against every bindable command, File ▸ Scripts' + /// entries included. pub fn shortcuts(&self) -> &ShortcutTable { - self.shortcut_table.get_or_init(|| ShortcutTable::build(&self.keymaps, &self.ui_commands)) + self.shortcut_table.get_or_init(|| { + let scripts: Vec = crate::commands::scripts::scripts(self).into_iter().filter(|e| !e.panel).map(|e| e.name).collect(); + ShortcutTable::build_with_scripts(&self.keymaps, &self.ui_commands, &scripts) + }) } /// Remember a project in File ▸ Open Recent and in the crash-recovery sentinel. diff --git a/crates/engine/src/settings/shortcuts.rs b/crates/engine/src/settings/shortcuts.rs index 2eb6fa680..1d60971ac 100644 --- a/crates/engine/src/settings/shortcuts.rs +++ b/crates/engine/src/settings/shortcuts.rs @@ -1,6 +1,7 @@ //! Keyboard shortcut presets (Edit ▸ Keyboard Shortcuts). //! -//! Every menu entry, registry command and frontend command is *bindable*: it has a binding key +//! Every menu entry, registry command and frontend command, and every script File ▸ Scripts +//! lists, is *bindable*: it has a binding key //! (the command id, plus its bound parameters for entries such as `app.settings //! {"page":"general"}`), a scope (application-wide or a panel) and default keys. The built-in //! preset "EffectCraft Default" is After Effects' default keyboard layout and is read-only; custom @@ -157,6 +158,27 @@ pub fn bindables(ui: &[UiCommand]) -> Vec { out } +/// The File ▸ Scripts entries of the scripts `names` (file names, as the menu lists them), with +/// no default keys. A key is bound to the script's name, so it stays with that script when +/// scripts are added to or removed from the folder. +pub fn script_bindables(names: &[String]) -> Vec { + names + .iter() + .map(|n| { + let params = json!({"name": n}); + Bindable { + key: binding_key("file.runScript", ¶ms), + command: "file.runScript".into(), + params, + label: n.clone(), + path: vec!["File".into(), "Scripts".into()], + scope: APP_SCOPE.into(), + defaults: vec![], + } + }) + .collect() +} + /// Canonical shortcut text (`cmd+shift+k`, `Shift+Cmd+K`, `⌘⇧K` → `Cmd+Shift+K`); `None` if it /// has no key. pub fn normalize(s: &str) -> Option { @@ -436,7 +458,16 @@ pub struct ShortcutTable { impl ShortcutTable { pub fn build(maps: &Keymaps, ui: &[UiCommand]) -> ShortcutTable { - let bindables = bindables(ui); + Self::build_with_scripts(maps, ui, &[]) + } + + /// [`ShortcutTable::build`] with the File ▸ Scripts entries of the scripts `scripts`, listed + /// after the other File ▸ Scripts entries. + pub fn build_with_scripts(maps: &Keymaps, ui: &[UiCommand], scripts: &[String]) -> ShortcutTable { + let mut bindables = bindables(ui); + let at = bindables.iter().rposition(|b| b.path.starts_with(&["File".to_string(), "Scripts".to_string()])).map_or(bindables.len(), |i| i + 1); + let new: Vec = script_bindables(scripts).into_iter().filter(|s| !bindables.iter().any(|b| b.key == s.key)).collect(); + bindables.splice(at..at, new); let keys = bindables.iter().map(|b| (b.key.clone(), maps.keys_of(b))).collect(); ShortcutTable { bindables, keys } } diff --git a/crates/engine/src/system/perf.rs b/crates/engine/src/system/perf.rs index ab1723d9f..929726c95 100644 --- a/crates/engine/src/system/perf.rs +++ b/crates/engine/src/system/perf.rs @@ -180,7 +180,7 @@ pub fn large_project(spec: &LargeSpec) -> Project { *n += 1; layers.push(l); } - comp.layers = layers; + comp.layers = layers.into(); }; let others = spec.comps.saturating_sub(1); let depth = spec.nest_depth.min(others); @@ -215,7 +215,7 @@ pub fn large_project(spec: &LargeSpec) -> Project { n += 1; layers.push(l); } - main.layers = layers; + main.layers = layers.into(); p.add_item("Main", crate::color::Label::None, None, ItemKind::Comp(Arc::new(main))); // `Main` must be the first comp `replace_project` opens: give it the smallest id. let main_id = p.items.values().find(|i| i.name == "Main").map(|i| i.id).expect("main"); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 25237493c..82aa011a2 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -15,6 +15,7 @@ mod frame_export; mod fuzz_open; mod history; mod import_project; +mod instance3d; mod key_metadata_order; mod key_time; mod keyframe_text; @@ -40,6 +41,7 @@ pub(crate) mod roto; mod sequence; mod session; mod settings; +mod shared_layers; mod shortcut_cmds; pub(crate) mod stubs; mod styles; diff --git a/crates/engine/src/tests/crypto_pick.rs b/crates/engine/src/tests/crypto_pick.rs index eacae55af..2e61f57a1 100644 --- a/crates/engine/src/tests/crypto_pick.rs +++ b/crates/engine/src/tests/crypto_pick.rs @@ -190,7 +190,16 @@ fn rendered_matte_of_pick(s: &mut Session, x: f64, y: f64, scale: f64) -> (Strin s.execute_checked("prop.set", json!({"path": format!("effects/#{fx}/matteOnly"), "value": true})).unwrap(); let (cid, comp) = (s.active_comp_id().unwrap(), s.active_comp().unwrap()); let layer = &comp.layers[0]; - let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: Tick::ZERO, expr: None, footage: None }; + let ctx = effectcraft_render::EvalCtx { + project: &s.project, + comp_id: cid, + comp, + time: Tick::ZERO, + expr: None, + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let buf = s.renderer(effectcraft_render::RenderOpts { scale, ..Default::default() }).content_buf(&ctx, layer).unwrap(); let (px, py) = buf.to_px([x, y]); let m = buf.img.get(px.floor() as i64, py.floor() as i64)[0]; diff --git a/crates/engine/src/tests/effects.rs b/crates/engine/src/tests/effects.rs index 64baf14fe..4b3b0188e 100644 --- a/crates/engine/src/tests/effects.rs +++ b/crates/engine/src/tests/effects.rs @@ -550,7 +550,7 @@ fn opening_an_old_project_upgrades_effect_instances() { effectcraft_project::ItemKind::Comp(c) => c, _ => panic!("not a composition"), }); - let l = comp.layers.iter_mut().find(|l| l.id == LayerId(a)).unwrap(); + let l = comp.layers.find_mut(|l| l.id == LayerId(a)).unwrap(); for n in &mut l.props.sub_mut("effects").unwrap().children { let Node::Group(g) = n else { continue }; if g.match_id == "ec.blur.gaussian" { diff --git a/crates/engine/src/tests/essential.rs b/crates/engine/src/tests/essential.rs index 191416b9d..db5bf0751 100644 --- a/crates/engine/src/tests/essential.rs +++ b/crates/engine/src/tests/essential.rs @@ -367,6 +367,128 @@ fn media_replacement_swaps_footage_per_instance() { assert!(center(&s, main)[0] > 0.99); } +/// #1747: a collapsed precomp with an Essential Property override must keep drawing its nested +/// layers past the precomp's frame instead of falling back to a flattened, clipped frame. +#[test] +fn collapsed_precomp_applies_property_overrides_without_clipping() { + struct AddY; + impl effectcraft_render::ExprHost for AddY { + fn eval( + &self, + _: &effectcraft_render::EvalCtx, + _: &effectcraft_project::Layer, + _: &effectcraft_project::Property, + value: &effectcraft_keyframe::Value, + ) -> Result { + Ok(match value { + effectcraft_keyframe::Value::Vec3([x, y, z]) => effectcraft_keyframe::Value::Vec3([*x, y + 100.0, *z]), + _ => value.clone(), + }) + } + } + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Inner", "width": 400, "height": 300, "frameRate": 30, "duration": 2})).unwrap(); + let inner = s.active_comp_id().unwrap(); + let box_ = s.execute("layer.newSolid", json!({"name": "Box", "color": "#ffffff", "width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": box_, "path": "transform/position", "value": [200, 50, 0]})).unwrap(); + // The source property's expression still runs after the instance override. + s.execute("prop.setExpression", json!({"layer": box_, "path": "transform/position", "expression": "value + [0, 100, 0]"})).unwrap(); + let position = s.execute("essential.addProperty", json!({"layer": box_, "path": "transform/position"})).unwrap()["controls"][0].as_u64().unwrap(); + s.execute("comp.new", json!({"name": "Outer", "width": 400, "height": 600, "frameRate": 30, "duration": 2})).unwrap(); + let outer = s.active_comp_id().unwrap(); + let instance = s.execute("layer.addItem", json!({"item": inner.0})).unwrap()["layer"].as_u64().unwrap(); + let other = s.execute("layer.addItem", json!({"item": inner.0})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [instance, other], "switch": "collapse", "value": true})).unwrap(); + s.expr = Some(std::sync::Arc::new(AddY)); + s.execute("layer.select", json!({"layers": [instance]})).unwrap(); + s.execute("essential.set", json!({"layer": instance, "control": position, "value": [200, -120, 0]})).unwrap(); + s.execute("layer.select", json!({"layers": [other]})).unwrap(); + s.execute("essential.set", json!({"layer": other, "control": position, "value": [200, 220, 0]})).unwrap(); + let img = s.render(outer, Tick::ZERO, RenderOpts::default()); + // The expression adds 100 px: the overrides give inner y = -20 and 320. The first maps to + // outer y = 130 (the precomp is centred at 300) and its 100 px box spans 80..179, 70 px above + // the instance's frame (150..449), where a flattened precomp would stop. The second maps to + // 470 and spans 420..519, past the bottom. + assert!(img.get(200, 100)[3] > 0.99, "the override draws above the nested frame: {:?}", img.get(200, 100)); + assert!(img.get(200, 90)[3] > 0.99, "the box reaches the top of its overridden bounds"); + assert!(img.get(200, 500)[3] > 0.99, "the other instance keeps its own override: {:?}", img.get(200, 500)); + assert!(img.get(200, 200)[3] < 0.01, "nothing is left between the instances"); +} + +/// #1747: two collapsed instances of the same comp keep their own source-affecting property +/// overrides in the layer cache (the nested layers' ids and static values are identical). +#[test] +fn collapsed_instances_keep_distinct_property_overrides_in_the_cache() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Card", "width": 64, "height": 64, "frameRate": 30, "duration": 2})).unwrap(); + let card = s.active_comp_id().unwrap(); + let solid = s.execute("layer.newSolid", json!({"name": "BG", "color": "#ffffff", "width": 64, "height": 64})).unwrap()["layer"].as_u64().unwrap(); + s.execute("effect.apply", json!({"layer": solid, "effect": "Fill"})).unwrap(); + let color = s.execute("essential.addProperty", json!({"layer": solid, "path": "effects/#1/color"})).unwrap()["controls"][0].as_u64().unwrap(); + s.execute("comp.new", json!({"name": "Main", "width": 200, "height": 100, "frameRate": 30, "duration": 2})).unwrap(); + let main = s.active_comp_id().unwrap(); + let a = s.execute("layer.addItem", json!({"item": card.0})).unwrap()["layer"].as_u64().unwrap(); + let b = s.execute("layer.addItem", json!({"item": card.0})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [a, b], "switch": "collapse", "value": true})).unwrap(); + s.execute("prop.set", json!({"layer": a, "path": "transform/position", "value": [60, 50, 0]})).unwrap(); + s.execute("prop.set", json!({"layer": b, "path": "transform/position", "value": [140, 50, 0]})).unwrap(); + s.execute("layer.select", json!({"layers": [a]})).unwrap(); + s.execute("essential.set", json!({"layer": a, "control": color, "value": [1.0, 0.0, 0.0, 1.0]})).unwrap(); + s.execute("layer.select", json!({"layers": [b]})).unwrap(); + s.execute("essential.set", json!({"layer": b, "control": color, "value": [0.0, 0.0, 1.0, 1.0]})).unwrap(); + let img = s.render(main, Tick::ZERO, RenderOpts::default()); + let left = img.get(60, 50); + let right = img.get(140, 50); + assert!(left[0] > 0.99 && left[2] < 0.01, "left instance is red: {left:?}"); + assert!(right[2] > 0.99 && right[0] < 0.01, "right instance is blue: {right:?}"); +} + +/// #1747 with Media Replacement: the replacement footage and the position override both apply +/// while the precomp stays collapsed, so the box is blue and still draws above the frame. +#[test] +fn collapsed_precomp_applies_media_replacement_without_clipping() { + struct Colors; + impl crate::render::FootageSource for Colors { + fn frame(&self, _: ItemId, f: &effectcraft_project::Footage, _: Tick) -> Option> { + let c = if f.path.contains("blue") { [0.0, 0.0, 1.0, 1.0] } else { [1.0, 0.0, 0.0, 1.0] }; + Some(std::sync::Arc::new(effectcraft_raster::Image::filled(100, 100, c))) + } + } + let mut s = Session { footage: std::sync::Arc::new(Colors), ..Default::default() }; + let footage = |path: &str| effectcraft_project::Footage { + path: path.into(), + kind: effectcraft_project::FootageKind::Still, + width: 100, + height: 100, + has_video: true, + ..Default::default() + }; + let (red, blue) = s + .edit("test", None, |proj, _| { + let red = proj.add_item("red.png", Default::default(), None, effectcraft_project::ItemKind::Footage(footage("red.png"))); + let blue = proj.add_item("blue.png", Default::default(), None, effectcraft_project::ItemKind::Footage(footage("blue.png"))); + Ok((red, blue)) + }) + .unwrap(); + s.execute("comp.new", json!({"name": "Inner", "width": 400, "height": 300, "frameRate": 30, "duration": 2})).unwrap(); + let inner = s.active_comp_id().unwrap(); + let clip = s.execute("layer.addItem", json!({"item": red.0})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": clip, "path": "transform/position", "value": [200, 50, 0]})).unwrap(); + let media = s.execute("essential.addMedia", json!({"layer": clip})).unwrap()["control"].as_u64().unwrap(); + let position = s.execute("essential.addProperty", json!({"layer": clip, "path": "transform/position"})).unwrap()["controls"][0].as_u64().unwrap(); + s.execute("comp.new", json!({"name": "Outer", "width": 400, "height": 600, "frameRate": 30, "duration": 2})).unwrap(); + let outer = s.active_comp_id().unwrap(); + let instance = s.execute("layer.addItem", json!({"item": inner.0})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [instance], "switch": "collapse", "value": true})).unwrap(); + s.execute("layer.select", json!({"layers": [instance]})).unwrap(); + s.execute("essential.set", json!({"layer": instance, "control": position, "value": [200, -20, 0]})).unwrap(); + s.execute("essential.set", json!({"layer": instance, "control": media, "item": blue.0})).unwrap(); + let img = s.render(outer, Tick::ZERO, RenderOpts::default()); + let above = img.get(200, 100); + assert!(above[2] > 0.99 && above[0] < 0.01, "the replacement media is blue above the frame: {above:?}"); + assert!(img.get(200, 200)[3] < 0.01, "the collapsed box is not clipped at the nested frame"); +} + /// Responsive Design — Time: stretching a precomp keeps its protected regions at their /// original speed; the unprotected parts absorb the stretch. #[test] diff --git a/crates/engine/src/tests/essential_more.rs b/crates/engine/src/tests/essential_more.rs index a8d8dc808..ea4aa42e5 100644 --- a/crates/engine/src/tests/essential_more.rs +++ b/crates/engine/src/tests/essential_more.rs @@ -2,6 +2,7 @@ use effectcraft_keyframe::Value as KV; use effectcraft_project::essential::{self, ControlType}; +use effectcraft_time::Tick; use serde_json::json; use crate::{Event, Session}; @@ -102,3 +103,33 @@ fn open_in_essential_graphics_sets_the_primary_comp() { ); assert_eq!(s.execute("essential.list", json!({})).unwrap()["name"], "A"); } + +/// #1747: through a collapsed precomp, a Text override and a Font override of the same Source +/// Text both apply: the Font merges into the overridden text, as `essential::with_overrides` +/// does on the flattened path, instead of one entry replacing the other. +#[test] +fn collapsed_instance_merges_text_and_font_overrides() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Title", "width": 64, "height": 64, "frameRate": 30, "duration": 1})).unwrap(); + let title = s.active_comp_id().unwrap(); + let text = s.execute("layer.newText", json!({"text": "Hello"})).unwrap()["layer"].as_u64().unwrap(); + let body = s.execute("essential.addProperty", json!({"layer": text, "path": "text/sourceText"})).unwrap()["controls"][0].as_u64().unwrap(); + let font = s.execute("essential.addProperty", json!({"layer": text, "path": "text/sourceText", "as": "font"})).unwrap()["controls"][0].as_u64().unwrap(); + s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 64, "frameRate": 30, "duration": 1})).unwrap(); + let main = s.active_comp_id().unwrap(); + let inst = s.execute("layer.addItem", json!({"item": title.0})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [inst], "switch": "collapse", "value": true})).unwrap(); + s.execute("essential.set", json!({"layer": inst, "control": body, "value": "Bye"})).unwrap(); + s.execute("essential.set", json!({"layer": inst, "control": font, "value": {"font": "Serif Test", "size": 40}})).unwrap(); + // Evaluate the nested text in the collapsed instance's context, as the renderer does. + let r = effectcraft_render::Renderer::new(&s.project, &*s.footage, crate::render::RenderOpts::default()); + let ctx = effectcraft_render::EvalCtx::new(&s.project, main, s.project.comp(main).unwrap(), Tick::ZERO); + let layer = ctx.comp.layer(effectcraft_project::LayerId(inst)).unwrap(); + let collapsed = r.collapsed(&ctx, layer).expect("the instance collapses"); + let (_, nctx) = r.collapse_into(&ctx, layer, &collapsed, 1.0).expect("a nested context"); + let src = nctx.comp.layer(effectcraft_project::LayerId(text)).unwrap(); + match nctx.value(src, src.props.prop("text/sourceText").unwrap()) { + KV::Text(d) => assert_eq!((d.text.as_str(), d.font.as_str(), d.size), ("Bye", "Serif Test", 40.0)), + o => panic!("{o:?}"), + } +} diff --git a/crates/engine/src/tests/frame_export.rs b/crates/engine/src/tests/frame_export.rs index a45c5d444..a8dc938fb 100644 --- a/crates/engine/src/tests/frame_export.rs +++ b/crates/engine/src/tests/frame_export.rs @@ -164,3 +164,69 @@ fn copy_frame_hands_the_rendered_frame_to_the_frontend() { assert!(Session::default().execute("comp.copyFrame", json!({})).is_err()); assert!(crate::menus::entries().iter().any(|(path, e)| e.command == "comp.copyFrame" && path.first().map(String::as_str) == Some("Composition"))); } + +#[test] +fn save_frame_as_png_preserves_alpha_channel() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "AlphaComp", "width": 32, "height": 32, "frameRate": 24, "duration": 1})).unwrap(); + let red = s.execute("layer.newSolid", json!({"name": "Red", "color": "#ff0000", "width": 16, "height": 16})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": red, "path": "transform/position", "value": [8, 8, 0]})).unwrap(); + let dir = out_dir("frame-png-alpha"); + let path = dir.join("alpha.png").to_string_lossy().to_string(); + + s.execute("comp.saveFrameAs", json!({"path": path})).unwrap(); + let img = image::load_from_memory(&std::fs::read(&path).unwrap()).unwrap().to_rgba8(); + assert_eq!((img.width(), img.height()), (32, 32)); + let solid_px = img.get_pixel(8, 8); + assert_eq!(solid_px[3], 255, "solid region must be opaque: {solid_px:?}"); + assert_eq!(solid_px[0], 255); + let empty_px = img.get_pixel(24, 24); + assert_eq!(empty_px[3], 0, "outside solid region must have alpha 0: {empty_px:?}"); + + let flat_path = dir.join("flat.png").to_string_lossy().to_string(); + s.execute_checked("comp.saveFrameAs", json!({"path": flat_path, "transparent": false})).unwrap(); + let flat_img = image::load_from_memory(&std::fs::read(&flat_path).unwrap()).unwrap().to_rgba8(); + let flat_empty_px = flat_img.get_pixel(24, 24); + assert_eq!(flat_empty_px[3], 255, "transparent: false must composite over background: {flat_empty_px:?}"); +} + +#[test] +fn save_frame_as_queued_respects_frame_default_template() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Comp", "width": 32, "height": 32, "frameRate": 24, "duration": 1})).unwrap(); + + // Default Still template is "PNG Sequence" (Channels::Rgb) + let r = s.execute_checked("comp.saveFrameAs", json!({"queue": true})).unwrap(); + assert_eq!(r["queued"], true); + let it = s.project.render_queue.last().unwrap(); + assert_eq!(it.output.name, "PNG Sequence"); + assert_eq!(it.output.channels, effectcraft_project::render_queue::Channels::Rgb); + + // Test undo and redo of queuing a frame + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.project.render_queue.is_empty()); + s.execute("edit.redo", json!({})).unwrap(); + let it = s.project.render_queue.last().unwrap(); + assert_eq!(it.output.name, "PNG Sequence"); + + // Explicit transparent: false forces Channels::Rgb + s.execute_checked("comp.saveFrameAs", json!({"queue": true, "transparent": false})).unwrap(); + let it = s.project.render_queue.last().unwrap(); + assert_eq!(it.output.channels, effectcraft_project::render_queue::Channels::Rgb); + + // When Frame Default is set to an RGBA template, it is respected + s.execute_checked("renderQueue.setTemplateDefault", json!({"kind": "outputModule", "slot": "still", "name": "PNG Sequence with Alpha"})).unwrap(); + s.execute_checked("comp.saveFrameAs", json!({"queue": true})).unwrap(); + let it = s.project.render_queue.last().unwrap(); + assert_eq!(it.output.name, "PNG Sequence with Alpha"); + assert_eq!(it.output.channels, effectcraft_project::render_queue::Channels::Rgba); + + // Passing transparent: false forces Channels::Rgb even with an RGBA template + s.execute_checked("comp.saveFrameAs", json!({"queue": true, "transparent": false})).unwrap(); + let it = s.project.render_queue.last().unwrap(); + assert_eq!(it.output.channels, effectcraft_project::render_queue::Channels::Rgb); + + // With JPEG Sequence as Frame Default (non-alpha format), queue succeeds without error + s.execute_checked("renderQueue.setTemplateDefault", json!({"kind": "outputModule", "slot": "still", "name": "JPEG Sequence"})).unwrap(); + assert!(s.execute_checked("comp.saveFrameAs", json!({"queue": true})).is_ok()); +} diff --git a/crates/engine/src/tests/instance3d.rs b/crates/engine/src/tests/instance3d.rs new file mode 100644 index 000000000..48f4b7730 --- /dev/null +++ b/crates/engine/src/tests/instance3d.rs @@ -0,0 +1,257 @@ +//! Create 3D Layer Instance (Layer ▸ Create): a comp-sized solid at the top of the layer stack +//! with a 3D Layer Instance effect referencing the selected 3D model layer — a 2D copy of its +//! render that 2D effects can be applied to (behaviour, pixels, undo/redo, serde). + +use std::sync::Arc; + +use effectcraft_keyframe::Value as KV; +use effectcraft_project::{Footage, FootageKind, GroupKind, ItemId, Layer, LayerId, Project}; +use effectcraft_render::{FootageSource, RenderOpts}; +use effectcraft_time::{FrameRate, Tick}; +use serde_json::json; + +use crate::{Importer, Session}; + +fn session() -> Session { + let mut s = Session { footage: Arc::new(DiskModels), importer: Some(Arc::new(ModelImporter)), ..Default::default() }; + s.execute("comp.new", json!({"name": "Scene", "width": 640, "height": 360, "frameRate": 30, "duration": 2})).unwrap(); + s +} + +fn cid(s: &Session) -> ItemId { + s.active_comp_id().unwrap() +} + +fn layer_in(s: &Session, comp: ItemId, id: u64) -> Layer { + s.project.comp(comp).unwrap().layer(LayerId(id)).unwrap().clone() +} + +fn px(img: &effectcraft_raster::Image, x: u32, y: u32) -> [f32; 4] { + img.data[(y * img.width + x) as usize] +} + +fn close(a: &effectcraft_raster::Image, b: &effectcraft_raster::Image, tol: f32) -> bool { + a.data.iter().zip(b.data.iter()).all(|(p, q)| p.iter().zip(q.iter()).all(|(x, y)| (x - y).abs() <= tol)) +} + +struct DiskModels; + +impl FootageSource for DiskModels { + fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { + None + } + fn model(&self, _: ItemId, f: &Footage) -> Option> { + let bytes = std::fs::read(&f.path).ok()?; + effectcraft_model::load(&f.path, &bytes, &|_| None).ok().map(Arc::new) + } +} + +struct ModelImporter; + +impl Importer for ModelImporter { + fn probe(&self, path: &str) -> Result { + effectcraft_model::format_of(path).ok_or("not a model")?; + Ok(Footage { path: path.into(), kind: FootageKind::Model, frame_rate: FrameRate::FPS_30, codec: "glTF".into(), ..Default::default() }) + } +} + +/// A red triangle in the Z = 0 plane (buffer embedded as a data URI). +const TRIANGLE: &str = r#"{ + "asset": {"version": "2.0"}, + "scenes": [{"nodes": [0]}], + "nodes": [{"mesh": 0}], + "meshes": [{"primitives": [{"attributes": {"POSITION": 0, "NORMAL": 1}, "material": 0}]}], + "materials": [{"pbrMetallicRoughness": {"baseColorFactor": [1.0, 0.0, 0.0, 1.0]}}], + "buffers": [{"byteLength": 72, "uri": "data:application/octet-stream;base64,AACAvwAAgL8AAAAAAACAPwAAgL8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/"}], + "bufferViews": [ + {"buffer": 0, "byteOffset": 0, "byteLength": 36}, + {"buffer": 0, "byteOffset": 36, "byteLength": 36} + ], + "accessors": [ + {"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3, "min": [-1.0, -1.0, 0.0], "max": [1.0, 1.0, 0.0]}, + {"bufferView": 1, "componentType": 5126, "type": "VEC3", "count": 3} + ] +}"#; + +fn triangle_model(s: &mut Session) -> u64 { + let dir = std::env::temp_dir().join(format!("ec-instance3d-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Triangle.gltf"); + std::fs::write(&path, TRIANGLE).unwrap(); + s.execute("layer.newModel", json!({"path": path.to_string_lossy(), "name": "Triangle"})).unwrap()["layer"].as_u64().unwrap() +} + +#[test] +fn instance3d_creates_a_2d_copy_of_the_model() { + let mut s = session(); + let model = triangle_model(&mut s); + // The model renders through the comp's camera. + let before = s.render(cid(&s), Tick::ZERO, RenderOpts::default()); + assert!(px(&before, 320, 180)[3] > 0.9, "the model covers the centre: {:?}", px(&before, 320, 180)); + assert!(px(&before, 4, 4)[3] < 0.1, "the comp is transparent around it: {:?}", px(&before, 4, 4)); + // Create 3D Layer Instance. + s.execute("layer.select", json!({"layers": [model]})).unwrap(); + let r = s.execute("layer.create", json!({"op": "instance3d"})).unwrap(); + let solid = r["layers"][0].as_u64().unwrap(); + let comp = cid(&s); + let l = layer_in(&s, comp, solid); + assert_eq!(l.name, "Triangle Instance"); + assert_eq!(s.active_comp().unwrap().layers[0].id.0, solid, "at the top of the layer stack"); + // A 3D Layer Instance effect referencing the 3D layer. + let g = l.effects().unwrap().groups().next().unwrap().clone(); + assert!(matches!(&g.kind, GroupKind::Effect { effect } if effect == "ec.3d.instance3d"), "{:?}", g.kind); + assert_eq!(g.get("layer").unwrap().value, KV::Layer(Some(model))); + // The model is switched off, the copy selected. + assert!(!layer_in(&s, comp, model).switches.video, "the referenced 3D layer is off, as After Effects does"); + assert_eq!(s.state.selected_layers, vec![LayerId(solid)]); + // The comp renders the same through the copy: a 2D copy of the 3D layer, transparent + // around it. + let after = s.render(comp, Tick::ZERO, RenderOpts::default()); + assert!(close(&before, &after, 2e-2), "the copy shows the model:\n{:?}\n{:?}", px(&before, 320, 180), px(&after, 320, 180)); + assert!(px(&after, 320, 180)[3] > 0.9 && px(&after, 4, 4)[3] < 0.1, "{:?} {:?}", px(&after, 320, 180), px(&after, 4, 4)); + // Undo removes the copy and switches the model back on; redo returns it. + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.active_comp().unwrap().layer(LayerId(solid)).is_none()); + assert!(layer_in(&s, comp, model).switches.video); + s.execute("edit.redo", json!({})).unwrap(); + assert!(s.active_comp().unwrap().layer(LayerId(solid)).is_some()); + assert!(!layer_in(&s, comp, model).switches.video); + // Serde round-trips the layer parameter. + let j = serde_json::to_string(&*s.project).unwrap(); + let back: Project = serde_json::from_str(&j).unwrap(); + assert_eq!(back, *s.project); + // 2D effects can be applied to the copy (the model layer itself takes none): Fill in blue + // recolours the triangle. + s.execute("effect.apply", json!({"layer": solid, "effect": "ec.generate.fill"})).unwrap(); + s.execute("prop.set", json!({"layer": solid, "path": "effects/#2/color", "value": [0.0, 0.0, 1.0, 1.0]})).unwrap(); + let with_fx = s.render(comp, Tick::ZERO, RenderOpts::default()); + let p = px(&with_fx, 320, 180); + assert!(p[2] > 0.9 && p[0] < 0.1, "the fill recolours the copy: {p:?}"); +} + +#[test] +fn instance3d_also_takes_3d_primitives() { + let mut s = session(); + let prim = s.execute("layer.new3dPrimitive", json!({"kind": "cube", "width": 200, "height": 200, "depth": 200})).unwrap()["layer"].as_u64().unwrap(); + let before = s.render(cid(&s), Tick::ZERO, RenderOpts::default()); + let r = s.execute("layer.create", json!({"op": "instance3d", "layers": [prim]})).unwrap(); + let solid = r["layers"][0].as_u64().unwrap(); + let l = layer_in(&s, cid(&s), solid); + assert_eq!(l.name, "Cube Instance"); + assert!(!layer_in(&s, cid(&s), prim).switches.video); + let after = s.render(cid(&s), Tick::ZERO, RenderOpts::default()); + assert!(close(&before, &after, 2e-2), "the copy shows the cube"); +} + +#[test] +fn instance3d_needs_a_3d_layer() { + let mut s = session(); + let solid = s.execute("layer.newSolid", json!({"color": "#808080", "width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap(); + let err = s.execute_checked("layer.create", json!({"op": "instance3d", "layers": [solid]})); + assert!(err.is_err()); + let text = s.execute("layer.newText", json!({"text": "hi"})).unwrap()["layer"].as_u64().unwrap(); + assert!(s.execute_checked("layer.create", json!({"op": "instance3d", "layers": [text]})).is_err()); + // A 3D-switched text layer is not a model layer either (it has 2D pixels of its own). + s.execute("layer.setSwitch", json!({"layers": [text], "switch": "threeD", "value": true})).unwrap(); + assert!(s.execute_checked("layer.create", json!({"op": "instance3d", "layers": [text]})).is_err()); +} + +#[test] +fn instance3d_creates_every_selected_copy_in_one_undo() { + let mut s = session(); + let a = s.execute("layer.new3dPrimitive", json!({"kind": "cube", "width": 120, "height": 120, "depth": 120})).unwrap()["layer"].as_u64().unwrap(); + let b = s.execute("layer.new3dPrimitive", json!({"kind": "cube", "width": 120, "height": 120, "depth": 120})).unwrap()["layer"].as_u64().unwrap(); + let r = s.execute("layer.create", json!({"op": "instance3d", "layers": [a, b]})).unwrap(); + let copies = r["layers"].as_array().unwrap(); + assert_eq!(copies.len(), 2); + let (ia, ib) = (copies[0].as_u64().unwrap(), copies[1].as_u64().unwrap()); + let comp = cid(&s); + assert_ne!(ia, ib); + assert_ne!(layer_in(&s, comp, ia).name, layer_in(&s, comp, ib).name, "each copy has a name of its own"); + // Both sources are off, both copies selected, above their sources in the stack. + assert!(!layer_in(&s, comp, a).switches.video && !layer_in(&s, comp, b).switches.video); + let mut sel = s.state.selected_layers.clone(); + let mut want = vec![LayerId(ia), LayerId(ib)]; + sel.sort_by_key(|l| l.0); + want.sort_by_key(|l| l.0); + assert_eq!(sel, want); + let c = s.active_comp().unwrap(); + let pos = |id: u64| c.layers.iter().position(|l| l.id.0 == id).unwrap(); + assert!(pos(ia) < pos(a) && pos(ib) < pos(b), "the copies sit above their sources"); + // One undo removes both copies and switches both models back on; one redo returns them. + s.execute("edit.undo", json!({})).unwrap(); + let c = s.active_comp().unwrap(); + assert!(c.layer(LayerId(ia)).is_none() && c.layer(LayerId(ib)).is_none(), "one undo removes every instance"); + assert!(layer_in(&s, comp, a).switches.video && layer_in(&s, comp, b).switches.video); + s.execute("edit.redo", json!({})).unwrap(); + let c = s.active_comp().unwrap(); + assert!(c.layer(LayerId(ia)).is_some() && c.layer(LayerId(ib)).is_some(), "one redo returns every instance"); + assert!(!layer_in(&s, comp, a).switches.video && !layer_in(&s, comp, b).switches.video); +} + +#[test] +fn instance3d_in_a_mixed_selection_only_takes_the_models() { + let mut s = session(); + let model = triangle_model(&mut s); + let solid = s.execute("layer.newSolid", json!({"color": "#808080", "width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap(); + let r = s.execute("layer.create", json!({"op": "instance3d", "layers": [model, solid]})).unwrap(); + let copies = r["layers"].as_array().unwrap(); + assert_eq!(copies.len(), 1, "only the model gets a copy: {r}"); + let copy = copies[0].as_u64().unwrap(); + let comp = cid(&s); + assert!(!layer_in(&s, comp, model).switches.video, "the model is switched off"); + assert!(layer_in(&s, comp, solid).switches.video, "the plain solid is untouched"); + let skipped = r["skipped"].as_array().unwrap(); + assert_eq!(skipped.len(), 1); + assert!(skipped[0].as_str().unwrap().contains("not a 3D model layer"), "{skipped:?}"); + // The copy references the model, not the solid. + let l = layer_in(&s, comp, copy); + let g = l.effects().unwrap().groups().next().unwrap().clone(); + assert_eq!(g.get("layer").unwrap().value, KV::Layer(Some(model))); +} + +#[test] +fn instance3d_shows_nothing_outside_the_source_layer_range() { + let mut s = session(); + let model = triangle_model(&mut s); + // The model's range ends at 1 s; the comp runs to 2 s. + let comp = cid(&s); + s.edit("trim", None, |p, _| { + p.comp_mut(comp).unwrap().layer_mut(LayerId(model)).unwrap().out_point = Tick::from_seconds_f64(1.0); + Ok(()) + }) + .unwrap(); + s.execute("layer.select", json!({"layers": [model]})).unwrap(); + let r = s.execute("layer.create", json!({"op": "instance3d"})).unwrap(); + let _copy = r["layers"][0].as_u64().unwrap(); + // Inside the model's range the copy shows it, transparent around it. + let inside = s.render(comp, Tick::from_seconds_f64(0.5), RenderOpts::default()); + assert!(px(&inside, 320, 180)[3] > 0.9, "the copy shows the model: {:?}", px(&inside, 320, 180)); + // After its out point the source has nothing to read: the copy is transparent. + let outside = s.render(comp, Tick::from_seconds_f64(1.5), RenderOpts::default()); + assert!(outside.data.iter().all(|p| p[3] < 1e-3), "transparent after the source's out point: {:?}", px(&outside, 320, 180)); +} + +#[test] +fn instance3d_keeps_the_copy_visible_when_the_source_is_soloed() { + let mut s = session(); + let model = triangle_model(&mut s); + let comp = cid(&s); + // Solo the model, then create the instance: any_solo hides non-soloed layers and the + // source's video goes off, so the copy has to take the Solo over. + s.edit("solo", None, |p, _| { + p.comp_mut(comp).unwrap().layer_mut(LayerId(model)).unwrap().switches.solo = true; + Ok(()) + }) + .unwrap(); + let before = s.render(comp, Tick::ZERO, RenderOpts::default()); + assert!(px(&before, 320, 180)[3] > 0.9, "the soloed model shows: {:?}", px(&before, 320, 180)); + s.execute("layer.select", json!({"layers": [model]})).unwrap(); + let r = s.execute("layer.create", json!({"op": "instance3d"})).unwrap(); + let solid = r["layers"][0].as_u64().unwrap(); + assert!(layer_in(&s, comp, solid).switches.solo, "the copy takes the source's Solo switch"); + assert!(!layer_in(&s, comp, model).switches.video, "the source keeps its video off"); + let after = s.render(comp, Tick::ZERO, RenderOpts::default()); + assert!(px(&after, 320, 180)[3] > 0.9, "the soloed copy shows the model: {:?}", px(&after, 320, 180)); + assert!(close(&before, &after, 2e-2), "the comp renders the same through the copy:\n{:?}\n{:?}", px(&before, 320, 180), px(&after, 320, 180)); +} diff --git a/crates/engine/src/tests/menu_cmds.rs b/crates/engine/src/tests/menu_cmds.rs index eb99d809f..cfbbb4966 100644 --- a/crates/engine/src/tests/menu_cmds.rs +++ b/crates/engine/src/tests/menu_cmds.rs @@ -1103,6 +1103,66 @@ fn transform_commands_keep_depth_and_follow_separated_dimensions() { assert_eq!(pos(&s, b)[..2], [200.0, 100.0], "the 200×100 solid's centre"); } +/// The layer's painted bounds mapped to comp space — what the viewer shows. +fn seen_bounds(s: &Session, lid: u64) -> [f64; 4] { + let cid = s.active_comp_id().unwrap(); + let comp = s.project.comp(cid).unwrap().clone(); + let layer = comp.layer(effectcraft_project::LayerId(lid)).unwrap(); + let ectx = effectcraft_render::EvalCtx::new(&s.project, cid, &comp, s.time()); + let b = effectcraft_render::content_bounds(&ectx, layer).expect("painted bounds"); + let m = ectx.layer_to_comp(layer).0; + let corners = [[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]]]; + let pts: Vec<[f64; 2]> = corners + .iter() + .map(|p| { + let q = m.apply(effectcraft_geom::vec2(p[0], p[1])); + [q.x, q.y] + }) + .collect(); + let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY); + for p in &pts { + x0 = x0.min(p[0]); + y0 = y0.min(p[1]); + x1 = x1.max(p[0]); + y1 = y1.max(p[1]); + } + [x0, y0, x1, y1] +} + +#[test] +fn fit_to_comp_fills_the_comp_with_shape_and_text_bounds() { + let mut s = comp(); + let close = |x: f64, y: f64| (x - y).abs() < 1e-6; + // A shape or text layer has no source item: fit sizes it by its painted bounds, anchored at + // the bounds' centre, so what the viewer shows fills the frame (#2198). + let a = s.execute("layer.newShape", json!({"kind": "rect", "size": [180, 80]})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.transform", json!({"layers": [a], "op": "fit"})).unwrap(); + let seen = seen_bounds(&s, a); + assert!(close(seen[0], 0.0) && close(seen[1], 0.0) && close(seen[2], 640.0) && close(seen[3], 360.0), "the rect's painted bounds fill the comp: {seen:?}"); + + let t = s.execute("layer.newText", json!({"text": "Title", "size": 80})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.transform", json!({"layers": [t], "op": "fit"})).unwrap(); + let seen = seen_bounds(&s, t); + assert!(close(seen[0], 0.0) && close(seen[1], 0.0) && close(seen[2], 640.0) && close(seen[3], 360.0), "the text's painted bounds fill the comp: {seen:?}"); + + // fitWidth spans the comp's width and centres vertically. + for lid in [a, t] { + s.execute("layer.transform", json!({"layers": [lid], "op": "fitWidth"})).unwrap(); + let seen = seen_bounds(&s, lid); + assert!(close(seen[0], 0.0) && close(seen[2], 640.0), "fitWidth spans the comp width: {seen:?}"); + assert!(close((seen[1] + seen[3]) / 2.0, 180.0), "fitWidth centres vertically: {seen:?}"); + } +} + +#[test] +fn fit_to_comp_refuses_a_scale_that_overflows() { + let mut s = comp(); + let a = s.execute("layer.newShape", json!({"kind": "rect", "size": [1e-305, 80]})).unwrap()["layer"].as_u64().unwrap(); + let before = s.project.clone(); + assert!(s.execute("layer.transform", json!({"layers": [a], "op": "fit"})).is_err(), "an infinite scale is refused"); + assert_eq!(s.project, before, "nothing was written"); +} + #[test] fn duplicated_and_pasted_layers_keep_their_links() { let mut s = comp(); @@ -1423,6 +1483,68 @@ fn key_selection_toggles_and_skips_locked_layers_and_hold_restores_bezier() { assert!(s.state.selected_keys.is_empty()); } +#[test] +fn muting_keys_skips_them_in_the_timeline_value_and_undoes() { + let mut s = comp(); + let a = solid(&mut s, "#ff0000"); + for (t, v) in [(0.0, 0.0), (1.0, 100.0), (2.0, 300.0)] { + s.execute("prop.addKey", json!({"layer": a, "path": "transform/opacity", "time": t, "value": v})).unwrap(); + } + let value_at = |s: &Session, t: f64| layer(s, a).props.prop("transform/opacity").unwrap().value_at(Tick::from_seconds_f64(t)).as_f64(); + let select = |s: &mut Session| s.execute("keys.select", json!({"keys": [{"layer": a, "path": "transform/opacity", "time": 1.0}]})).unwrap(); + select(&mut s); + let before = value_at(&s, 0.5); + s.execute("keys.setMuted", json!({"muted": true})).unwrap(); + assert!(layer(&s, a).props.prop("transform/opacity").unwrap().keys[1].muted); + // The muted key no longer bends the 0 → 300 segment. + assert!((value_at(&s, 0.5) - 75.0).abs() < 1e-6); + assert!((value_at(&s, 1.5) - 225.0).abs() < 1e-6); + s.undo(); + assert!(!layer(&s, a).props.prop("transform/opacity").unwrap().keys[1].muted); + assert!((value_at(&s, 0.5) - before).abs() < 1e-6); + // The same command unmutes. + select(&mut s); + s.execute("keys.setMuted", json!({"muted": true})).unwrap(); + s.execute("keys.setMuted", json!({"muted": false})).unwrap(); + assert!(!layer(&s, a).props.prop("transform/opacity").unwrap().keys[1].muted); +} + +/// Both Animation-menu rows mute and unmute through `Session::execute`, and undo/redo work. +#[test] +fn mute_and_unmute_keyframes_menu_rows_work_and_undo() { + fn rows(nodes: &[crate::menus::MenuNode], out: &mut Vec<(String, Value)>) { + for n in nodes { + match n { + crate::menus::MenuNode::Item(e) if e.command == "keys.setMuted" => out.push((e.label.clone(), e.params.clone())), + crate::menus::MenuNode::Submenu { children, .. } => rows(children, out), + _ => {} + } + } + } + let mut menu = vec![]; + rows(&crate::menus::parse(crate::menus::TREE, false).unwrap(), &mut menu); + assert_eq!(menu.iter().map(|(l, _)| l.as_str()).collect::>(), ["Mute Keyframes", "Unmute Keyframes"], "{menu:?}"); + + let mut s = comp(); + let a = solid(&mut s, "#ff0000"); + for (t, v) in [(0.0, 0.0), (1.0, 100.0)] { + s.execute("prop.addKey", json!({"layer": a, "path": "transform/opacity", "time": t, "value": v})).unwrap(); + } + let muted = |s: &Session| layer(s, a).props.prop("transform/opacity").unwrap().keys[1].muted; + s.execute("keys.select", json!({"keys": [{"layer": a, "path": "transform/opacity", "time": 1.0}]})).unwrap(); + let run = |s: &mut Session, params: &Value| { + s.execute("keys.setMuted", if params.is_null() { json!({}) } else { params.clone() }).unwrap(); + }; + run(&mut s, &menu[0].1); + assert!(muted(&s)); + s.undo(); + assert!(!muted(&s)); + s.redo(); + assert!(muted(&s)); + run(&mut s, &menu[1].1); + assert!(!muted(&s)); +} + #[test] fn transform_drags_apply_their_whole_offset_to_the_keys_they_started_with() { let mut s = comp(); diff --git a/crates/engine/src/tests/proxy.rs b/crates/engine/src/tests/proxy.rs index 941a9a96b..40927b8b8 100644 --- a/crates/engine/src/tests/proxy.rs +++ b/crates/engine/src/tests/proxy.rs @@ -1,6 +1,6 @@ //! Proxies (Set Proxy, Use Proxy, Proxy Use render settings, comp proxies, Create Proxy) and -//! Interpret Footage (fields, pixel aspect, alpha guess / invert, linear light), rendered through -//! a synthetic footage source. +//! Interpret Footage (fields, pixel aspect, alpha guess / invert, linear light, Cineon Settings), +//! rendered through a synthetic footage source. use std::sync::Arc; @@ -36,6 +36,12 @@ fn file_px(path: &str, x: u32, y: u32) -> [f32; 4] { "matte_black.png" => [0.0, 0.5, 0.0, 0.5], "matte_white.png" => [0.5, 1.0, 0.5, 0.5], "half.png" => [1.0, 1.0, 1.0, 0.25], + // A log film scan: code values 95, 685, 400 and 1023 across, as the DPX reader hands + // them out (code / 1023). + "scan.dpx" | "plate.dpx" => { + let v = [95.0, 685.0, 400.0, 1023.0][x.min(3) as usize] / 1023.0; + [v, v, v, 1.0] + } _ => { let _ = x; [1.0, 1.0, 1.0, 1.0] @@ -47,7 +53,7 @@ fn size_of(path: &str) -> (u32, u32) { match std::path::Path::new(path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("") { "red16.png" => (16, 16), "blue8.png" => (8, 8), - "lines.mov" | "matte_black.png" | "matte_white.png" | "half.png" => (4, 4), + "lines.mov" | "matte_black.png" | "matte_white.png" | "half.png" | "scan.dpx" | "plate.dpx" => (4, 4), _ => (10, 10), } } @@ -64,7 +70,12 @@ impl FootageSource for Synthetic { for y in 0..h { for x in 0..w { // Decoders hand out premultiplied pixels (straight file colour × alpha). - let p = file_px(&f.path, x, y); + let mut p = file_px(&f.path, x, y); + // A log scan sequence: code 95 on even frames, 685 on odd ones. + if f.path.ends_with("frames.dpx") { + let v = if f.frame_rate.frame_at(t) % 2 == 0 { 95.0 } else { 685.0 } / 1023.0; + p = [v, v, v, 1.0]; + } img.set(x, y, [p[0] * p[3], p[1] * p[3], p[2] * p[3], p[3]]); } } @@ -76,7 +87,7 @@ struct Probe; impl crate::Importer for Probe { fn probe(&self, path: &str) -> Result { let (w, h) = size_of(path); - let video = path.ends_with(".mov"); + let video = path.ends_with(".mov") || path.ends_with("frames.dpx"); Ok(Footage { path: path.into(), kind: if video { FootageKind::Video } else { FootageKind::Still }, @@ -431,3 +442,125 @@ fn guess_alpha_invert_and_linear_light() { s.execute("file.applyInterpretation", json!({"items": [w.0]})).unwrap(); assert!(foot(&s, w).preserve_rgb); } + +/// Color ▸ Cineon Settings: Logarithmic Conversion turns a DPX file's log code values into the +/// Current Gamma between the converted points (off, they show as stored); the converted points +/// are read and reported in the chosen Units; out-of-range values and footage that is not DPX +/// or Cineon are refused; Remember / Apply Interpretation and undo carry the settings. +#[test] +fn cineon_settings_convert_dpx_code_values() { + let mut s = session(); + let d = import(&mut s, "/m/scan.dpx"); + let other = import(&mut s, "/m/plate.dpx"); + let png = import(&mut s, "/m/red16.png"); + let foot = |s: &Session, i: ItemId| match &s.project.item(i).unwrap().kind { + ItemKind::Footage(f) => f.clone(), + _ => panic!("not footage"), + }; + s.execute("comp.new", json!({"name": "C", "width": 4, "height": 4, "frameRate": 10, "duration": 1})).unwrap(); + let c = s.active_comp_id().unwrap(); + s.execute("layer.addItem", json!({"item": d.0})).unwrap(); + let at = |s: &Session, x: i64| px(s, c, 0.0, RenderOpts::default(), x, 0)[0]; + let near = |a: f32, b: f32| (a - b).abs() < 1.5 / 255.0; + // Off: the code values as stored. + assert!(near(at(&s, 2), 400.0 / 1023.0), "{}", at(&s, 2)); + // The checked path (control channel, MCP) accepts every Cineon parameter the doc lists. + let out = s.execute_checked("file.interpretFootage", json!({"items": [d.0], "logConversion": true, "highlightRolloff": 0})).unwrap(); + assert_eq!(out["items"][0]["cineon"]["logConversion"], true, "{out}"); + assert!(near(at(&s, 0), 0.0) && near(at(&s, 1), 1.0), "{} {}", at(&s, 0), at(&s, 1)); + assert!(near(at(&s, 2), 0.26190), "code 400 at gamma 1.7: {}", at(&s, 2)); + s.execute("file.interpretFootage", json!({"item": d.0, "currentGamma": 1})).unwrap(); + assert!(near(at(&s, 2), 0.10251), "code 400 in linear light: {}", at(&s, 2)); + // Converted points, in 8-bit units. + let out = s + .execute_checked("file.interpretFootage", json!({"items": [d.0], "cineonUnits": "8bit", "convertedBlack": 16, "convertedWhite": 235, "currentGamma": 1, "tenBitBlack": 95, "tenBitWhite": 685})) + .unwrap(); + assert_eq!(out["items"][0]["cineon"]["units"], "8bit"); + assert!((out["items"][0]["cineon"]["convertedWhite"].as_f64().unwrap() - 235.0).abs() < 1e-3, "{out}"); + assert!(near(at(&s, 0), 16.0 / 255.0) && near(at(&s, 1), 235.0 / 255.0), "{} {}", at(&s, 0), at(&s, 1)); + // The form sends the points in the units it showed them in (`pointUnits`) with a new Units. + s.execute_checked("file.interpretFootage", json!({"items": [d.0], "pointUnits": "8bit", "cineonUnits": "float", "convertedWhite": 255})).unwrap(); + let f = foot(&s, d); + assert_eq!(f.cineon.units, effectcraft_project::CineonUnits::Float); + assert!((f.cineon.converted_white - 1023.0).abs() < 1e-3, "{:?}", f.cineon); + // Refused: a black point at or above the white point, values out of range, and footage + // that is not DPX or Cineon. Nothing changes. + let before = foot(&s, d).cineon; + for bad in [ + json!({"item": d.0, "tenBitBlack": 700}), + json!({"item": d.0, "tenBitWhite": 2000}), + json!({"item": d.0, "currentGamma": 0}), + json!({"item": d.0, "highlightRolloff": -1}), + json!({"item": d.0, "convertedWhite": 1.5}), + json!({"item": d.0, "cineonUnits": "12bit"}), + json!({"item": png.0, "logConversion": true}), + ] { + assert!(s.execute("file.interpretFootage", bad.clone()).is_err(), "{bad}"); + } + assert_eq!(foot(&s, d).cineon, before); + assert!(foot(&s, png).cineon == effectcraft_project::CineonSettings::default()); + // With DPX and other footage selected, only the DPX file takes the settings. + s.execute("file.interpretFootage", json!({"items": [png.0, other.0], "logConversion": true})).unwrap(); + assert!(foot(&s, other).cineon.log_conversion && !foot(&s, png).cineon.log_conversion); + s.execute("edit.undo", json!({})).unwrap(); + assert!(!foot(&s, other).cineon.log_conversion); + // Remember / Apply Interpretation. + s.state.project_selection = vec![d]; + s.execute("file.rememberInterpretation", json!({"item": d.0})).unwrap(); + s.execute("file.applyInterpretation", json!({"items": [other.0, png.0]})).unwrap(); + assert_eq!(foot(&s, other).cineon, before); + assert_eq!(foot(&s, png).cineon, effectcraft_project::CineonSettings::default()); + // Off again: as stored. + s.execute("file.interpretFootage", json!({"item": d.0, "logConversion": false})).unwrap(); + assert!(near(at(&s, 2), 400.0 / 1023.0), "{}", at(&s, 2)); + // Frame Mix blends converted frames: halfway between code 95 (black) and 685 (white) at + // Current Gamma 1 is 0.5, not the conversion of the blended code values. + let seq = import(&mut s, "/m/frames.dpx"); + s.execute("file.interpretFootage", json!({"item": seq.0, "logConversion": true, "currentGamma": 1, "highlightRolloff": 0})).unwrap(); + s.execute("comp.new", json!({"name": "Blend", "width": 10, "height": 10, "frameRate": 20, "duration": 1})).unwrap(); + let bc = s.active_comp_id().unwrap(); + s.execute("layer.addItem", json!({"item": seq.0})).unwrap(); + s.execute("layer.frameBlending", json!({"mode": "frameMix"})).unwrap(); + let mid = px(&s, bc, 0.05, RenderOpts::default(), 5, 5)[0]; + assert!(near(mid, 0.5), "frame-mixed log frames: {mid}"); + // A DPX proxy of a composition: Interpret Proxy's settings convert it where it stands in. + s.execute("comp.new", json!({"name": "Inner", "width": 4, "height": 4, "frameRate": 10, "duration": 1})).unwrap(); + let inner = s.active_comp_id().unwrap(); + s.execute("comp.new", json!({"name": "Outer", "width": 4, "height": 4, "frameRate": 10, "duration": 1})).unwrap(); + let outer = s.active_comp_id().unwrap(); + s.execute("layer.addItem", json!({"item": inner.0})).unwrap(); + s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/scan.dpx"})).unwrap(); + let proxied = |s: &Session, x: i64| px(s, outer, 0.0, RenderOpts::default(), x, 0)[0]; + assert!(near(proxied(&s, 1), 685.0 / 1023.0), "{}", proxied(&s, 1)); + s.execute_checked("file.interpretProxy", json!({"items": [inner.0], "logConversion": true, "highlightRolloff": 0})).unwrap(); + assert!(near(proxied(&s, 0), 0.0) && near(proxied(&s, 1), 1.0), "{} {}", proxied(&s, 0), proxied(&s, 1)); +} + +/// The Footage panel, the Project panel thumbnail and the tracker's source frames show DPX +/// footage with its Cineon Settings, as a composition renders it. +#[test] +fn cineon_settings_apply_to_footage_panel_thumbnail_and_tracking() { + let mut s = session(); + let d = import(&mut s, "/m/scan.dpx"); + s.execute("comp.new", json!({"name": "C", "width": 4, "height": 4, "frameRate": 10, "duration": 1})).unwrap(); + let c = s.active_comp_id().unwrap(); + s.execute("layer.addItem", json!({"item": d.0})).unwrap(); + s.execute("file.interpretFootage", json!({"item": d.0, "logConversion": true, "highlightRolloff": 0})).unwrap(); + let near = |a: f32, b: f32| (a - b).abs() < 1.5 / 255.0; + let rendered = px(&s, c, 0.0, RenderOpts::default(), 2, 0)[0]; + assert!(near(rendered, 0.26190), "code 400 at gamma 1.7: {rendered}"); + let f = match &s.project.item(d).unwrap().kind { + ItemKind::Footage(f) => f.clone(), + _ => panic!("not footage"), + }; + let panel = crate::commands::footage_panel::frame(&s, d, &f, Tick::ZERO).unwrap().get(2, 0)[0]; + assert!(near(panel, rendered), "Footage panel: {panel}"); + let (w, _, rgba) = (s.thumbnail_job(d, Tick::ZERO, 64).unwrap())().unwrap(); + assert_eq!(w, 4); + let thumb = rgba[8] as f32 / 255.0; + assert!(near(thumb, rendered), "Project panel thumbnail: {thumb}"); + let comp = s.project.comp(c).unwrap(); + let r = crate::render::Renderer::new(&s.project, s.footage.as_ref(), RenderOpts::default()); + let (img, _) = crate::tracking::source_frame(&r, &s.project, c, comp, &comp.layers[0], Tick::ZERO, None).unwrap(); + assert!(near(img.get(2, 0)[0], rendered), "tracker source frame: {}", img.get(2, 0)[0]); +} diff --git a/crates/engine/src/tests/rig3d.rs b/crates/engine/src/tests/rig3d.rs index d801a743a..cf2518447 100644 --- a/crates/engine/src/tests/rig3d.rs +++ b/crates/engine/src/tests/rig3d.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use effectcraft_geom::{Vec3, vec3}; use effectcraft_keyframe::Value as KV; use effectcraft_project::{Footage, FootageKind, GroupKind, ItemId, Layer, LayerId, LayerSource, LightKind, Project}; -use effectcraft_render::three_d::camera::layer_frame; +use effectcraft_render::three_d::camera::{is_two_node, layer_frame}; use effectcraft_render::{EvalCtx, FootageSource, RenderOpts}; use effectcraft_time::{FrameRate, Tick}; use serde_json::{Value, json}; @@ -40,7 +40,7 @@ fn close3(a: [f64; 3], b: [f64; 3], tol: f64) -> bool { fn frame(s: &Session, id: u64) -> (Vec3, Vec3) { let c = cid(s); let comp = s.project.comp(c).unwrap(); - let ctx = EvalCtx { project: &s.project, comp_id: c, comp, time: Tick::ZERO, expr: None, footage: None }; + let ctx = EvalCtx { project: &s.project, comp_id: c, comp, time: Tick::ZERO, expr: None, footage: None, property_overrides: &[], media_overrides: &[] }; let (e, f, _) = layer_frame(&ctx, comp.layer(LayerId(id)).unwrap()); (e, f) } @@ -217,8 +217,189 @@ fn control_light_with_camera_follows_and_releases() { assert!(s.execute("light.controlWithCamera", json!({"layers": [cam]})).is_err()); } +/// Parenting keeps a camera where it is (After Effects' Parent & Link): Position and Point of +/// Interest are re-expressed in the null's space, so the frame does not move. +#[test] +fn parented_camera_keeps_its_frame_under_an_offset_null() { + let mut s = session(); + let cam = camera(&mut s); + let (eye0, f0) = frame(&s, cam); + let nul = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": nul, "path": "transform/position", "value": [100, 60, 0]})).unwrap(); + s.execute("layer.setParent", json!({"layers": [cam], "parent": nul})).unwrap(); + let (eye1, f1) = frame(&s, cam); + assert!((eye1 - eye0).length() < 1e-6 && (f1 - f0).length() < 1e-6, "{eye0:?} {eye1:?}"); + // The null's origin is Position minus its default anchor (50, 50): (50, 10) in the comp. + let c = layer_in(&s, cid(&s), cam); + assert!(close3(v3(&c, "transform/position"), [270.0, 170.0, -800.0], 1e-6), "{:?}", v3(&c, "transform/position")); + assert!(close3(v3(&c, "transform/poi"), [270.0, 170.0, 0.0], 1e-6), "{:?}", v3(&c, "transform/poi")); + // Moving the null carries the camera and keeps its aim. + s.execute("prop.set", json!({"layer": nul, "path": "transform/position", "value": [140, 90, 0]})).unwrap(); + let (eye2, f2) = frame(&s, cam); + assert!((eye2 - (eye0 + vec3(40.0, 30.0, 0.0))).length() < 1e-6, "{eye2:?}"); + assert!((f2 - f0).length() < 1e-6, "{f2:?}"); +} + +/// World (eye, forward, down) of a layer in the active comp at time `t`. +fn frame_at(s: &Session, id: u64, t: Tick) -> (Vec3, Vec3, Vec3) { + let c = cid(s); + let comp = s.project.comp(c).unwrap(); + let ctx = EvalCtx { project: &s.project, comp_id: c, comp, time: t, expr: None, footage: None, property_overrides: &[], media_overrides: &[] }; + layer_frame(&ctx, comp.layer(LayerId(id)).unwrap()) +} + +/// Whether two frames agree on eye, forward and down. +fn frames_match(a: (Vec3, Vec3, Vec3), b: (Vec3, Vec3, Vec3)) -> bool { + (a.0 - b.0).length() < 1e-6 && (a.1 - b.1).length() < 1e-6 && (a.2 - b.2).length() < 1e-6 +} + +/// A 3D null off-centre in the comp, with an Orientation and a Z rotation. +fn rotated_null(s: &mut Session, orientation: [f64; 3], turn: f64) -> u64 { + let nul = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [nul], "switch": "threeD", "value": true})).unwrap(); + s.execute("prop.set", json!({"layer": nul, "path": "transform/position", "value": [100, 60, 40]})).unwrap(); + s.execute("prop.set", json!({"layer": nul, "path": "transform/orientation", "value": orientation})).unwrap(); + s.execute("prop.set", json!({"layer": nul, "path": "transform/rotation", "value": turn})).unwrap(); + nul +} + +/// A two-node camera under a rotated null keeps its eye, aim and roll: its Orientation is +/// re-expressed against the new aim, so Up does not drift. +#[test] +fn two_node_camera_under_rotated_null_keeps_its_roll() { + let mut s = session(); + let cam = camera(&mut s); + s.execute("prop.set", json!({"layer": cam, "path": "transform/rotation", "value": 25})).unwrap(); + assert!(is_two_node(&layer_in(&s, cid(&s), cam))); + let before = frame_at(&s, cam, Tick::ZERO); + let nul = rotated_null(&mut s, [20.0, -35.0, 60.0], 30.0); + s.execute("layer.setParent", json!({"layers": [cam], "parent": nul})).unwrap(); + let after = frame_at(&s, cam, Tick::ZERO); + assert!(frames_match(before, after), "{before:?} {after:?}"); +} + +#[test] +fn parented_point_light_keeps_its_direction_under_a_rotated_null() { + let mut s = session(); + let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": light, "path": "transform/orientation", "value": [30, 0, 0]})).unwrap(); + let (eye0, f0) = frame(&s, light); + let nul = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": nul, "path": "transform/position", "value": [100, 60, 0]})).unwrap(); + s.execute("prop.set", json!({"layer": nul, "path": "transform/rotation", "value": 30})).unwrap(); + s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap(); + let (eye1, f1) = frame(&s, light); + assert!((eye1 - eye0).length() < 1e-6 && (f1 - f0).length() < 1e-6, "{eye0:?} {eye1:?} {f0:?} {f1:?}"); +} + +/// Keyed Orientation under a Z-only turn is shifted exactly, so the light keeps its direction at +/// every time, between the keys too. +#[test] +fn keyed_orientation_under_a_z_turn_keeps_the_direction_at_every_time() { + let mut s = session(); + let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap(); + for (time, value) in [(0.0, json!([-8.0, 12.0, -13.0])), (1.0, json!([5.0, 18.0, 14.0]))] { + s.execute("prop.addKey", json!({"layer": light, "path": "transform/orientation", "time": time, "value": value})).unwrap(); + } + let times = [0.0, 0.25, 0.5, 0.75, 1.0].map(Tick::from_seconds_f64); + let before = times.map(|t| frame_at(&s, light, t)); + let nul = rotated_null(&mut s, [0.0, 0.0, 0.0], 30.0); + s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap(); + for (t, b) in times.into_iter().zip(before) { + assert!(frames_match(b, frame_at(&s, light, t)), "a Z-only turn keeps the light's direction"); + } +} + +/// Under a tilted null the keys alone cannot follow (Euler angles are blended between them), so keys are +/// added between them: the light keeps its direction within a small angle at every time. +#[test] +fn keyed_orientation_under_a_tilted_null_follows_between_the_keys() { + { + let mut s = session(); + let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap(); + for (time, value) in [(0.0, json!([-8.0, 12.0, -13.0])), (1.5, json!([50.0, 118.0, 74.0]))] { + s.execute("prop.addKey", json!({"layer": light, "path": "transform/orientation", "time": time, "value": value})).unwrap(); + } + let times = [0.0, 0.2, 0.5, 0.9, 1.3, 1.5].map(Tick::from_seconds_f64); + let before = times.map(|t| frame_at(&s, light, t)); + let nul = rotated_null(&mut s, [20.0, -35.0, 60.0], 30.0); + s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap(); + for (t, b) in times.into_iter().zip(before) { + let a = frame_at(&s, light, t); + // 0.05 degrees is about 9e-4 of a radian on a unit vector. + assert!((a.0 - b.0).length() < 1e-6 && (a.1 - b.1).length() < 2e-3, "at {t:?}: {:?} vs {:?}", a.1, b.1); + } + assert!(layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().keys.len() > 2, "keys were added between the two"); + } +} + +/// A one-node light parented under a mirrored null (Z scale -100%) keeps the Position and +/// Orientation it was authored with: it has no Scale to carry the mirror, so none of it is +/// compensated rather than reversing its frame (a half-compensated light would point backwards). +#[test] +fn one_node_light_under_a_mirrored_null_keeps_its_authored_values() { + let mut s = session(); + let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": light, "path": "transform/orientation", "value": [30, 0, 0]})).unwrap(); + let before = layer_in(&s, cid(&s), light); + let nul = rotated_null(&mut s, [0.0, 0.0, 0.0], 0.0); + s.execute("prop.set", json!({"layer": nul, "path": "transform/scale", "value": [100, 100, -100]})).unwrap(); + s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap(); + let after = layer_in(&s, cid(&s), light); + assert_eq!(after.parent, Some(LayerId(nul))); + assert_eq!(v3(&after, "transform/position"), v3(&before, "transform/position")); + assert_eq!(v3(&after, "transform/orientation"), v3(&before, "transform/orientation")); +} + +/// The cap on keys added by baking is for the whole Orientation property, not for each segment: +/// many strongly eased segments under a tilted parent stay within it. +#[test] +fn baked_orientation_keys_are_capped_across_the_whole_property() { + let mut s = session(); + let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap(); + let count = 120; + for i in 0..count { + let swing = if i % 2 == 0 { json!([0.0, 0.0, 0.0]) } else { json!([170.0, 60.0, 120.0]) }; + s.execute("prop.addKey", json!({"layer": light, "path": "transform/orientation", "time": i as f64 * 0.05, "value": swing})).unwrap(); + } + let uid = layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().uid; + let keys: Vec<_> = (0..count).map(|i| json!({"layer": light, "prop": uid, "time": i as f64 * 0.05})).collect(); + s.execute("keys.select", json!({"keys": keys})).unwrap(); + s.execute("keys.easyEase", json!({})).unwrap(); + let nul = rotated_null(&mut s, [20.0, -35.0, 60.0], 30.0); + s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap(); + let n = layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().keys.len(); + assert!(n <= 4096, "{n} keys: the cap is for the property, not per segment"); + // the segments the cap left unchecked keep their authored easing instead of turning into straight blends + let orientation = layer_in(&s, cid(&s), light); + let keys = &orientation.props.prop("transform/orientation").unwrap().keys; + let last = keys.iter().rev().nth(1).expect("the penultimate key"); + assert_ne!(last.out_interp, effectcraft_keyframe::Interp::Linear, "the last segment keeps its authored easing"); +} + // ---------------------------------------------------------------- from 3D model +/// Easy Ease agrees with a straight blend at the midpoint, but not at the quarter points. +#[test] +fn eased_orientation_under_x_parent_keeps_motion_between_keys() { + let mut s = session(); + let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap(); + for (time, value) in [(0.0, json!([0.0, 0.0, 0.0])), (1.0, json!([90.0, 0.0, 0.0]))] { + s.execute("prop.addKey", json!({"layer": light, "path": "transform/orientation", "time": time, "value": value})).unwrap(); + } + let uid = layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().uid; + s.execute("keys.select", json!({"keys": [{"layer": light, "prop": uid, "time": 0.0}, {"layer": light, "prop": uid, "time": 1.0}]})).unwrap(); + s.execute("keys.easyEase", json!({})).unwrap(); + let times: Vec<_> = (0..=100).map(|i| Tick::from_seconds_f64(i as f64 / 100.0)).collect(); + let before: Vec<_> = times.iter().map(|t| frame_at(&s, light, *t)).collect(); + let parent = rotated_null(&mut s, [30.0, 0.0, 0.0], 0.0); + s.execute("layer.setParent", json!({"layers": [light], "parent": parent})).unwrap(); + for (t, b) in times.into_iter().zip(before) { + let a = frame_at(&s, light, t); + assert!((a.0 - b.0).length() < 1e-6 && (a.1 - b.1).length() < 1e-3, "at {t:?}: {:?} vs {:?}", a.1, b.1); + } +} + struct DiskModels; impl FootageSource for DiskModels { diff --git a/crates/engine/src/tests/sequence.rs b/crates/engine/src/tests/sequence.rs index 3e81961ce..cf6968e3f 100644 --- a/crates/engine/src/tests/sequence.rs +++ b/crates/engine/src/tests/sequence.rs @@ -104,10 +104,11 @@ fn numbered_stills_import_as_stills_by_default() { let r = s.execute("file.import", json!({"paths": [dir.clone()], "drag": true})).unwrap(); let (name, f) = footage(&s, &r["items"][0]); assert_eq!((name.as_str(), f.kind), ("shot_[0001-0005].png", FootageKind::Sequence)); - // Scripts and agents naming one frame without `sequence` still get its run. - let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0003.png")]})).unwrap(); - let (_, f) = footage(&s, &r["items"][0]); - assert_eq!(f.kind, FootageKind::Sequence); + // #2018: scripts and agents naming one frame without `sequence` get that still, as + // `ImportOptions.sequence` defaults to false in After Effects. + let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0005.png")]})).unwrap(); + let (name, f) = footage(&s, &r["items"][0]); + assert_eq!((name.as_str(), f.kind), ("shot_0005.png", FootageKind::Still)); } #[test] @@ -181,14 +182,58 @@ fn interpret_footage_start_timecode_relabels_source_time() { } #[test] -fn replace_footage_and_reload_keep_sequences() { +fn replace_footage_keeps_a_still_a_still() { let dir = folder(&RUN); let mut s = session(); let item = s.execute("file.import", json!({"paths": [at(&dir, "shot_0001.png")], "sequence": false})).unwrap()["items"][0].clone(); s.state.project_selection = vec![ItemId(item.as_u64().unwrap())]; + // Replacing with a numbered still does not silently turn the item into a sequence (#2073). s.execute("file.replaceFootage", json!({"path": at(&dir, "shot_0004.png")})).unwrap(); let (name, f) = footage(&s, &item); - assert_eq!((name.as_str(), f.sequence.len()), ("shot_[0001-0005].png", 4)); + assert_eq!((name.as_str(), f.kind), ("shot_0004.png", FootageKind::Still)); + // JSON null is unspecified, not "on": the still stays a still. + s.execute("file.replaceFootage", json!({"path": at(&dir, "shot_0004.png"), "sequence": null})).unwrap(); + let (name, f) = footage(&s, &item); + assert_eq!((name.as_str(), f.kind), ("shot_0004.png", FootageKind::Still)); + // Alphabetical is a sequence modifier, not a request to make one. + s.execute("file.replaceFootage", json!({"path": at(&dir, "shot_0004.png"), "alphabetical": true})).unwrap(); + let (name, f) = footage(&s, &item); + assert_eq!((name.as_str(), f.kind), ("shot_0004.png", FootageKind::Still)); + // Asking for the run still brings it. + s.execute("file.replaceFootage", json!({"path": at(&dir, "shot_0004.png"), "sequence": true})).unwrap(); + let (name, f) = footage(&s, &item); + assert_eq!((name.as_str(), f.kind, f.sequence.len()), ("shot_[0001-0005].png", FootageKind::Sequence, 4)); +} + +#[test] +fn replace_footage_keeps_a_one_frame_run_a_sequence() { + let dir = folder(&["shot_0001.png", "shot_0002.png", "solo_0007.png", "cover.png"]); + let mut s = session(); + let item = s.execute("file.import", json!({"paths": [at(&dir, "shot_0001.png")], "sequence": true})).unwrap()["items"][0].clone(); + s.state.project_selection = vec![ItemId(item.as_u64().unwrap())]; + // A numbered file with no other frame in its run is still the replacement for a sequence. + s.execute("file.replaceFootage", json!({"path": at(&dir, "solo_0007.png")})).unwrap(); + let (name, f) = footage(&s, &item); + assert_eq!((name.as_str(), f.kind, f.sequence.len()), ("solo_0007.png", FootageKind::Sequence, 1)); + // A single-file source keeps the sequence kind too. + s.execute("file.replaceFootage", json!({"path": at(&dir, "cover.png")})).unwrap(); + let (name, f) = footage(&s, &item); + assert_eq!((name.as_str(), f.kind, f.sequence.len()), ("cover.png", FootageKind::Sequence, 1)); + // An explicit `sequence: false` still asks for one frame. + s.execute("file.replaceFootage", json!({"path": at(&dir, "cover.png"), "sequence": false})).unwrap(); + let (_, f) = footage(&s, &item); + assert_eq!((f.kind, f.sequence.len()), (FootageKind::Still, 0)); +} + +#[test] +fn replace_footage_and_reload_keep_sequences() { + let dir = folder(&RUN); + let mut s = session(); + let item = s.execute("file.import", json!({"paths": [at(&dir, "shot_0001.png")], "sequence": true})).unwrap()["items"][0].clone(); + s.state.project_selection = vec![ItemId(item.as_u64().unwrap())]; + s.execute("file.replaceFootage", json!({"path": at(&dir, "shot_0004.png")})).unwrap(); + let (name, f) = footage(&s, &item); + assert_eq!((name.as_str(), f.kind, f.sequence.len()), ("shot_[0001-0005].png", FootageKind::Sequence, 4)); // Reload finds a new frame and keeps the interpretation. s.execute("file.interpretFootage", json!({"items": [item], "frameRate": 12, "startTimecode": 2})).unwrap(); std::fs::write(at(&dir, "shot_0003.png"), b"").unwrap(); diff --git a/crates/engine/src/tests/session.rs b/crates/engine/src/tests/session.rs index b5d1cbb5f..a29de9f1c 100644 --- a/crates/engine/src/tests/session.rs +++ b/crates/engine/src/tests/session.rs @@ -86,7 +86,7 @@ fn imported_files_go_into_the_comp_where_they_were_dropped() { s.execute("file.import", params).unwrap(); let comp = s.active_comp().unwrap(); assert_eq!(comp.layers.iter().map(|l| l.name.as_str()).collect::>(), ["a.png", "b.png", "Bg"]); - for l in &comp.layers[..2] { + for l in comp.layers.iter().take(2) { assert_eq!(l.props.prop("transform/position").map(|p| p.value.clone()), Some(effectcraft_keyframe::Value::Vec3([40.0, 50.0, 0.0]))); assert_eq!(l.in_point, effectcraft_time::Tick::from_seconds_f64(1.0)); } @@ -159,6 +159,46 @@ fn background_imports_report_progress_and_select_what_arrived() { assert_eq!(r["items"].as_array().map(Vec::len), Some(1)); } +/// #2573: New Composition From Footage (`file.import {newComp}`) makes one composition of what +/// the import made, first file on top, at once or when the background job lands; an import that +/// made nothing makes no comp. +#[test] +fn new_comp_from_footage_makes_a_comp_of_what_was_imported() { + use effectcraft_project::{Footage, FootageKind, ItemKind}; + struct Probe; + impl crate::Importer for Probe { + fn probe(&self, path: &str) -> Result { + if path.ends_with(".xyz") { + return Err("unsupported file".into()); + } + Ok(Footage { path: path.into(), kind: FootageKind::Still, width: 64, height: 32, has_video: true, ..Default::default() }) + } + } + let mut s = Session { importer: Some(std::sync::Arc::new(Probe)), ..Default::default() }; + let comps = |s: &Session| s.project.items.values().filter(|i| matches!(i.kind, ItemKind::Comp(_))).count(); + let layers = |s: &Session| s.active_comp().unwrap().layers.iter().map(|l| l.name.clone()).collect::>(); + // At once. + s.execute("file.import", json!({"paths": ["/drop/a.png", "/drop/b.xyz"], "newComp": true})).unwrap(); + assert_eq!(comps(&s), 1); + assert_eq!(layers(&s), ["a.png"]); + assert_eq!((s.active_comp().unwrap().width, s.active_comp().unwrap().height), (64, 32)); + // In the background: the job's own items, whatever is selected or active meanwhile; it takes + // precedence over `addToComp`. + s.execute("file.import", json!({"paths": ["/drop/c.png", "/drop/d.png"], "background": true, "newComp": true, "addToComp": true})).unwrap(); + s.state.project_selection.clear(); + s.execute("jobs.wait", json!({})).unwrap(); + assert_eq!(comps(&s), 2); + assert_eq!(layers(&s), ["c.png", "d.png"]); + // Nothing imported: no comp, and the failure is reported. + s.execute("file.import", json!({"paths": ["/drop/e.xyz"], "background": true, "newComp": true})).unwrap(); + s.execute("jobs.wait", json!({})).unwrap(); + assert_eq!(comps(&s), 2); + assert!(s.job_log.last().unwrap().message.contains("unsupported file")); + let r = s.execute("file.import", json!({"paths": ["/drop/f.xyz"], "newComp": true})).unwrap(); + assert_eq!(comps(&s), 2); + assert_eq!(r["errors"].as_array().map(Vec::len), Some(1)); +} + /// Audio, Lock and Shy don't change pixels: toggling them keeps the cached layers (#103). #[test] fn audio_lock_and_shy_keep_cached_layers() { @@ -476,6 +516,9 @@ fn set_text_paragraph_fill_and_leading() { s.execute("layer.setText", json!({"layer": t, "hScale": 120, "vScale": 80, "baselineShift": 4, "smallCaps": true, "strokeOverFill": true})).unwrap(); let d = doc(&s); assert_eq!((d.h_scale, d.v_scale, d.baseline_shift, d.small_caps, d.stroke_over_fill), (120.0, 80.0, 4.0, true, true)); + s.execute("layer.setText", json!({"layer": t, "lineJoin": "bevel"})).unwrap(); + assert_eq!(doc(&s).line_join, effectcraft_keyframe::LineJoin::Bevel); + assert!(s.execute("layer.setText", json!({"layer": t, "lineJoin": "square"})).is_err()); } #[test] diff --git a/crates/engine/src/tests/settings.rs b/crates/engine/src/tests/settings.rs index 81bed3c43..f2fd5a3b7 100644 --- a/crates/engine/src/tests/settings.rs +++ b/crates/engine/src/tests/settings.rs @@ -389,6 +389,23 @@ fn new_project_template_and_default_renderer() { let _ = std::fs::remove_dir_all(d); } +#[test] +fn project_files_are_indented_unless_compact_is_on() { + let d = tmp("compact-project-files"); + let path = d.join("p.ecproj"); + let mut s = session_with_comp(); + s.execute("file.saveAs", json!({"path": path.to_string_lossy()})).unwrap(); + let pretty = std::fs::read_to_string(&path).unwrap(); + assert!(pretty.starts_with("{\n \"savedBy\""), "indented by default"); + s.execute("prefs.set", json!({"key": "project.compactProjectFiles", "value": true})).unwrap(); + s.execute("file.saveAs", json!({"path": path.to_string_lossy()})).unwrap(); + let compact = std::fs::read_to_string(&path).unwrap(); + assert!(!compact.contains('\n'), "one line when Save Compact Project Files is on"); + let open = |text: &str| effectcraft_project::Project::from_json(text).unwrap(); + assert_eq!(open(&compact), open(&pretty), "both read back as the same project"); + let _ = std::fs::remove_dir_all(d); +} + // ---------------------------------------------------------------- shortcuts #[test] @@ -1072,6 +1089,14 @@ fn interface_language_is_validated_persisted_and_backward_compatible() { cs.load_settings(); assert_eq!(cs.prefs.general.language, "cs"); assert_eq!(Prefs::from_json(r#"{"general":{"language":"cs"}}"#).general.language, "cs"); + // Polish, likewise. + assert!(crate::prefs::LANGUAGES.contains(&("Polski", "pl"))); + s.execute("prefs.set", json!({"key": "general.language", "value": "pl"})).unwrap(); + assert_eq!(s.execute("prefs.get", json!({"key": "general.language"})).unwrap(), json!("pl")); + let mut pl = Session { config: Some(store.clone()), ..Default::default() }; + pl.load_settings(); + assert_eq!(pl.prefs.general.language, "pl"); + assert_eq!(Prefs::from_json(r#"{"general":{"language":"pl"}}"#).general.language, "pl"); // Georgian too. assert!(crate::prefs::LANGUAGES.contains(&("ქართული", "ka"))); s.execute("prefs.set", json!({"key": "general.language", "value": "ka"})).unwrap(); diff --git a/crates/engine/src/tests/shared_layers.rs b/crates/engine/src/tests/shared_layers.rs new file mode 100644 index 000000000..275fdd624 --- /dev/null +++ b/crates/engine/src/tests/shared_layers.rs @@ -0,0 +1,42 @@ +//! An edit copies only the layer it changes: the others stay shared with the undo state, so an +//! edit's cost doesn't grow with the size of the comp (scripts setting many keys went quadratic). + +use std::sync::Arc; + +use serde_json::json; + +use crate::Session; + +#[test] +fn a_key_on_one_layer_leaves_the_other_layers_shared_with_undo() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Big", "duration": 10, "frameRate": 30})).unwrap(); + let ids: Vec = (0..20).map(|i| s.execute("layer.newNull", json!({"name": format!("N{i}")})).unwrap()["layer"].as_u64().unwrap()).collect(); + let cid = s.active_comp_id().unwrap(); + let before = s.project.clone(); + s.execute("prop.addKey", json!({"layer": ids[7], "path": "transform/position", "time": 1.0, "value": [5, 5]})).unwrap(); + let (old, new) = (before.comp(cid).unwrap(), s.project.comp(cid).unwrap()); + assert_eq!(old.layers.len(), new.layers.len()); + let edited = new.layers.iter().position(|l| l.id.0 == ids[7]).unwrap(); + for i in 0..new.layers.len() { + assert_eq!(new.layers.same_at(&old.layers, i), i != edited, "layer {i} shared: {}", new.layers.same_at(&old.layers, i)); + } + // Undo brings back the very same state, and the edited layer is the only one that differed. + s.execute("edit.undo", json!({})).unwrap(); + assert!(Arc::ptr_eq(&s.project, &before)); +} + +#[test] +fn layers_save_and_load_as_a_plain_list() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Round trip", "duration": 5, "frameRate": 25})).unwrap(); + for i in 0..3 { + s.execute("layer.newNull", json!({"name": format!("N{i}")})).unwrap(); + } + let cid = s.active_comp_id().unwrap(); + let comp = s.project.comp(cid).unwrap(); + let text = serde_json::to_value(&comp.layers).unwrap(); + assert_eq!(text.as_array().map(Vec::len), Some(3), "a JSON array of layers, as before"); + let back: effectcraft_project::Layers = serde_json::from_value(text).unwrap(); + assert_eq!(&back, &comp.layers); +} diff --git a/crates/engine/src/tests/shortcut_cmds.rs b/crates/engine/src/tests/shortcut_cmds.rs index dd306ef93..c11885db1 100644 --- a/crates/engine/src/tests/shortcut_cmds.rs +++ b/crates/engine/src/tests/shortcut_cmds.rs @@ -184,6 +184,28 @@ fn in_and_out_points_stretch_to_the_current_time() { assert!(s.execute("layer.timeStretch", json!({"to": "middle"})).is_err()); } +#[test] +fn trimming_out_to_the_playhead_keeps_its_frame_visible() { + let (mut s, ids) = session([(1.0, 9.0); 3]); + let frame = 1.0 / 30.0; + // Alt+] at 4 s: the Out edge lands one frame after the playhead, so 4 s is the layer's last + // visible frame — it must not be cut away at the playhead (as in After Effects). + s.execute("time.set", json!({"time": 4.0})).unwrap(); + s.execute("layer.timing", json!({"layers": [ids[0]], "op": "trimOutToTime"})).unwrap(); + assert!((span(&s, ids[0]).1 - (4.0 + frame)).abs() < 1e-9, "{:?}", span(&s, ids[0])); + // Alt+[ at 2 s: the playhead's frame becomes the layer's first visible one. + s.execute("time.set", json!({"time": 2.0})).unwrap(); + s.execute("layer.timing", json!({"layers": [ids[1]], "op": "trimInToTime"})).unwrap(); + assert!((span(&s, ids[1]).0 - 2.0).abs() < 1e-9, "{:?}", span(&s, ids[1])); + // Trimmed before the In point, the Out point keeps one frame of layer. + s.execute("time.set", json!({"time": 0.5})).unwrap(); + s.execute("layer.timing", json!({"layers": [ids[2]], "op": "trimOutToTime"})).unwrap(); + assert!((span(&s, ids[2]).1 - (1.0 + frame)).abs() < 1e-9, "{:?}", span(&s, ids[2])); + // One undo step back to the original span. + assert!(s.undo()); + assert!((span(&s, ids[2]).1 - 9.0).abs() < 1e-9); +} + #[test] fn masks_are_stepped_through_and_wrap() { let (mut s, ids) = session([(0.0, 10.0); 3]); diff --git a/crates/engine/src/tests/stubs.rs b/crates/engine/src/tests/stubs.rs index b36ad77be..9735f2c08 100644 --- a/crates/engine/src/tests/stubs.rs +++ b/crates/engine/src/tests/stubs.rs @@ -584,3 +584,22 @@ fn convert_to_bezier_reads_the_shape_as_it_is_set() { let mid = |v: &[f64]| (v.iter().copied().fold(f64::MAX, f64::min) + v.iter().copied().fold(f64::MIN, f64::max)) / 2.0; assert!((mid(&xs) - 10.0).abs() < 1e-6 && (mid(&ys) - 5.0).abs() < 1e-6, "centred on the position: {:?} {:?}", mid(&xs), mid(&ys)); } + +/// #2201: a viewer drag on the free-transform box sends one `path.freeTransform` per frame with a +/// shared merge key; one undo must revert the whole drag, not the last frame. +#[test] +fn free_transform_drag_undoes_in_one_step() { + let mut s = comp(); + let (l, m) = masked(&mut s); + s.state.selected_vertices.clear(); + let before = mask_path(&s, l, m); + for k in [90, 90, 90, 90] { + s.execute("path.freeTransform", json!({"layer": l, "scale": k, "merge": "viewer-drag-1"})).unwrap(); + } + let after = mask_path(&s, l, m); + assert_ne!(after, before); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(mask_path(&s, l, m), before, "one undo reverts the whole drag"); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(mask_path(&s, l, m), after, "redo restores the whole drag"); +} diff --git a/crates/engine/src/tests/tail.rs b/crates/engine/src/tests/tail.rs index 2710365eb..90cfd1bfc 100644 --- a/crates/engine/src/tests/tail.rs +++ b/crates/engine/src/tests/tail.rs @@ -421,6 +421,70 @@ fn camera_focus_commands_undo_and_serde() { assert!(s.execute("camera.linkFocusToPoi", json!({})).is_ok()); } +/// Set Focus Distance to Layer: degenerate targets (behind the camera, exactly at it) are clean +/// errors, and a depth-of-field frame renders right after the command. +#[test] +fn camera_set_focus_to_layer_degenerate_targets_and_dof_frame() { + let mut s = comp(400, 300); + let t = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [t], "switch": "threeD", "value": true})).unwrap(); + let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": cam, "path": "transform/position", "value": [200, 150, -500]})).unwrap(); + s.execute("prop.set", json!({"layer": cam, "path": "cameraOptions/dof", "value": true})).unwrap(); + s.execute("layer.select", json!({"layers": [cam, t]})).unwrap(); + // Behind the camera (which now sits at z = −500 looking towards +z). + s.execute("layer.setTransform", json!({"layer": t, "prop": "position", "value": [200, 150, -1000]})).unwrap(); + let e = s.execute("camera.setFocusToLayer", json!({})).unwrap_err().to_string(); + assert!(e.contains("behind the camera"), "{e}"); + // Exactly at the camera: depth 0, still a clean error. + s.execute("layer.setTransform", json!({"layer": t, "prop": "position", "value": [200, 150, -500]})).unwrap(); + assert!(s.execute("camera.setFocusToLayer", json!({})).is_err()); + // In front: the command works, and the depth-of-field frame renders with finite pixels. + s.execute("layer.setTransform", json!({"layer": t, "prop": "position", "value": [200, 150, 400]})).unwrap(); + let d = s.execute("camera.setFocusToLayer", json!({})).unwrap()["focusDistance"].as_f64().unwrap(); + assert!((d - 900.0).abs() < 1e-6, "{d}"); + // An out-of-focus 3D layer, which must visibly blur, and a 0%-scaled one, whose pixels cover + // ~0 screen pixels and so turn an ordinary defocus into a radius of ~1e9 buffer pixels. + s.execute("prop.set", json!({"layer": cam, "path": "cameraOptions/aperture", "value": 120})).unwrap(); + let blur = s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 60, "height": 60})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [blur], "switch": "threeD", "value": true})).unwrap(); + s.execute("layer.setTransform", json!({"layer": blur, "prop": "position", "value": [200, 150, 1200]})).unwrap(); + let invis = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 40, "height": 40})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [invis], "switch": "threeD", "value": true})).unwrap(); + s.execute("layer.setTransform", json!({"layer": invis, "prop": "position", "value": [200, 150, 1600]})).unwrap(); + s.execute("layer.setTransform", json!({"layer": invis, "prop": "scale", "value": [0.0, 0.0, 0.0]})).unwrap(); + let cid = s.active_comp_id().unwrap(); + let img = s.render(cid, effectcraft_time::Tick::ZERO, Default::default()); + assert!(img.data.iter().all(|p| p.iter().all(|c| c.is_finite())), "non-finite pixels after setFocusToLayer"); + // The DoF frame actually blurred (not a no-op blur): the same frame with DoF off differs. + s.execute("prop.set", json!({"layer": cam, "path": "cameraOptions/dof", "value": false})).unwrap(); + let plain = s.render(cid, effectcraft_time::Tick::ZERO, Default::default()); + assert_ne!(img.data, plain.data, "the out-of-focus layer should have blurred"); +} + +/// Set Focus Distance to Layer fails cleanly on a depth that isn't finite. Every stored number +/// here is finite, but their difference overflows f64 to infinity; JSON has no infinity, so +/// storing it would leave a project that can't be saved. +#[test] +fn camera_set_focus_to_layer_rejects_non_finite_depth() { + let mut s = comp(400, 300); + let t = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [t], "switch": "threeD", "value": true})).unwrap(); + s.execute("layer.setTransform", json!({"layer": t, "prop": "position", "value": [200.0, 150.0, 1e308]})).unwrap(); + let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setTransform", json!({"layer": cam, "prop": "position", "value": [200.0, 150.0, -1e308]})).unwrap(); + s.execute("layer.select", json!({"layers": [cam, t]})).unwrap(); + let before = s.execute("prop.get", json!({"layer": cam, "path": "cameraOptions/focusDistance"})).unwrap()["value"].as_f64().unwrap(); + let e = s.execute("camera.setFocusToLayer", json!({})).unwrap_err().to_string(); + assert!(e.contains("out of range"), "{e}"); + let after = s.execute("prop.get", json!({"layer": cam, "path": "cameraOptions/focusDistance"})).unwrap()["value"].as_f64().unwrap(); + assert!((after - before).abs() < 1e-9, "a non-finite focus distance was written: {after}"); + // The project still round-trips through serde (a stored infinity would come back as null). + let back = roundtrip(&s); + let f = back.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(cam)).unwrap().props.prop("cameraOptions/focusDistance").unwrap().value.as_f64(); + assert!((f - before).abs() < 1e-9, "serde focus distance: {f}"); +} + /// 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() { diff --git a/crates/engine/src/tests/text_edit.rs b/crates/engine/src/tests/text_edit.rs index 17d0345d8..7a04ea155 100644 --- a/crates/engine/src/tests/text_edit.rs +++ b/crates/engine/src/tests/text_edit.rs @@ -199,6 +199,20 @@ fn typing_deleting_and_caret_movement() { assert!(s.state.text_edit.is_none()); } +/// Duplicating during a text edit moves the selection to the copy: the edit on the original ends +/// (#2226), as in After Effects — typing must not keep editing an unselected layer. +#[test] +fn duplicate_during_a_text_edit_ends_the_edit() { + let (mut s, t) = session("Hello"); + s.execute("text.edit", json!({"layer": t, "caret": 5})).unwrap(); + let copy = s.execute("edit.duplicate", json!({"layers": [t]})).unwrap()[0].as_u64().unwrap(); + assert_ne!(copy, t, "the duplicate is created"); + assert_eq!(s.state.selected_layers, vec![effectcraft_project::LayerId(copy)], "the copy is selected"); + assert!(s.state.text_edit.is_none(), "the edit ended with the selection move (#2226)"); + assert_eq!(doc(&s, t).text, "Hello", "the original keeps its text"); + assert_eq!(doc(&s, copy).text, "Hello", "and the copy carries it"); +} + #[test] fn caret_style_applies_to_the_next_typed_text() { let (mut s, t) = session("ab"); @@ -260,25 +274,25 @@ fn clipboard_and_paste_formatting() { } #[test] -fn type_tool_layers_point_box_and_empty_cleanup() { +fn type_tool_layers_point_box_and_empty_layer_is_kept() { let mut s = Session::default(); s.execute("comp.new", json!({"name": "C", "width": 640, "height": 360, "duration": 4})).unwrap(); let t = s.execute("layer.newText", json!({"text": "", "position": [100, 200], "justify": "left", "edit": true})).unwrap()["layer"].as_u64().unwrap(); assert_eq!(s.state.text_edit.as_ref().map(|e| e.layer.0), Some(t)); - // Leaving an untouched Type-tool layer deletes it. + // Leaving an untouched Type-tool layer keeps it, as After Effects does. s.execute("text.endEdit", json!({})).unwrap(); - assert!(s.active_comp().unwrap().layers.is_empty()); + assert_eq!(s.active_comp().unwrap().layers.len(), 1, "an empty Type-tool layer is kept, as in AE"); let b = s.execute("layer.newText", json!({"text": "", "box": [40, 50, 300, 120], "edit": true})).unwrap()["layer"].as_u64().unwrap(); s.execute("text.insert", json!({"text": "Boxed"})).unwrap(); let d = doc(&s, b); assert_eq!(d.box_size, Some([300.0, 120.0])); assert_eq!(d.box_pos, [-150.0, -60.0]); - let l = s.active_comp().unwrap().layers[0].clone(); + let l = s.active_comp().unwrap().layers.iter().find(|l| l.id.0 == b).expect("the boxed layer").clone(); assert_eq!(l.name, "Boxed", "named after its text"); let pos = l.props.prop("transform/position").unwrap().value.as_vec3(); assert_eq!([pos[0], pos[1]], [190.0, 110.0]); s.execute("text.endEdit", json!({})).unwrap(); - assert_eq!(s.active_comp().unwrap().layers.len(), 1); + assert_eq!(s.active_comp().unwrap().layers.len(), 2, "both text layers stay, the empty one included"); // Resizing the box (layer space) is an attribute too. s.execute("layer.setText", json!({"layer": b, "box": [-100, -60, 200, 120]})).unwrap(); assert_eq!(doc(&s, b).box_size, Some([200.0, 120.0])); @@ -928,11 +942,19 @@ fn fonts_lists_bundled_and_installed_families() { let r = s.execute("text.fonts", json!({"query": "noto ser", "rescan": true})).unwrap(); assert_eq!(r["families"][0]["family"], "Noto Serif"); // The bundled face is Regular; the OS can also provide Bold, Italic, or other faces. - // Compare against the independent directory inventory rather than assuming an empty OS. - let mut expected_styles = std::collections::BTreeSet::from(["Regular".to_string()]); - expected_styles.extend(installed.iter().filter(|face| face.family.eq_ignore_ascii_case("Noto Serif")).map(|face| face.style.clone())); + // Model `add_source()` on the independent directory inventory: a system face whose family + // and primary style duplicate an earlier face is skipped, with its named instances. + let mut expected_styles = std::collections::BTreeSet::from(["regular".to_string()]); + let mut retained = std::collections::BTreeSet::from([("noto serif".to_string(), "regular".to_string())]); + for face in installed.iter().filter(|face| face.family.eq_ignore_ascii_case("Noto Serif")) { + if retained.insert((face.family.to_ascii_lowercase(), face.style.to_ascii_lowercase())) { + expected_styles.insert(face.style.to_ascii_lowercase()); + expected_styles.extend(face.instances.iter().map(|i| i.style.to_ascii_lowercase())); + } + } let styles: Vec = r["families"][0]["styles"].as_array().unwrap().iter().map(|style| style.as_str().unwrap().to_string()).collect(); - assert_eq!(styles.iter().cloned().collect::>(), expected_styles); + // `families()` keeps the first spelling of a style that differs only in case. + assert_eq!(styles.iter().map(|style| style.to_ascii_lowercase()).collect::>(), expected_styles); assert_eq!(styles.len(), expected_styles.len(), "style menu has no duplicates"); assert!(r["families"].as_array().unwrap().iter().all(|f| f["family"].as_str().unwrap().to_lowercase().contains("noto ser"))); // The style menus offer a family's own styles. @@ -1202,7 +1224,7 @@ fn deselect_all_ends_the_open_edit() { } #[test] -fn deselect_all_discards_an_empty_type_tool_layer() { +fn deselect_all_keeps_an_empty_type_tool_layer() { let (mut s, _keep) = session("Hello"); let new = s.execute("layer.newText", json!({"text": "", "position": [80.0, 80.0], "edit": true})).unwrap()["layer"].as_u64().unwrap(); assert!( @@ -1214,7 +1236,5 @@ fn deselect_all_discards_an_empty_type_tool_layer() { s.execute("edit.deselectAll", json!({})).unwrap(); assert!(s.state.text_edit.is_none()); assert!(s.state.selected_layers.is_empty()); - assert_eq!(count(&s), before - 1, "a Type-tool layer left empty is removed, as text.endEdit does"); - s.execute("edit.undo", json!({})).unwrap(); - assert_eq!(count(&s), before, "that removal is one undo step of its own, as on text.endEdit"); + assert_eq!(count(&s), before, "an empty Type-tool layer is kept, as in After Effects"); } diff --git a/crates/engine/src/tests/three_d.rs b/crates/engine/src/tests/three_d.rs index 07a48af1c..748f8a32f 100644 --- a/crates/engine/src/tests/three_d.rs +++ b/crates/engine/src/tests/three_d.rs @@ -1,6 +1,6 @@ //! 3D commands: cameras, lights, views, camera tools (with undo/redo), and the 3D demo comp. -use effectcraft_project::{AutoOrient, LayerSource, LightKind}; +use effectcraft_project::{AutoOrient, LayerSource, LightKind, Renderer}; use effectcraft_render::three_d::View3D; use serde_json::json; @@ -148,6 +148,85 @@ fn deleting_a_source_layer_clears_the_reference() { assert!(s.active_comp().unwrap().layer(effectcraft_project::LayerId(sky)).is_some()); } +/// #2436: only the Advanced 3D renderer lights with Environment lights, and new comps are +/// Classic 3D, so one had no visible effect. Adding one (or changing a light into one) switches +/// the comp to Advanced 3D (in the same undo step, with a toast), as After Effects does. +#[test] +fn environment_lights_switch_classic_comps_to_advanced_3d() { + let toasts = |s: &mut Session| -> Vec { + s.drain_events().into_iter().filter_map(|e| if let crate::Event::Toast { message, .. } = e { Some(message) } else { None }).collect() + }; + let mut s = session(); + let text = s.execute("layer.newText", json!({"text": "Hi"})).unwrap()["layer"].as_u64().unwrap(); + assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D); + toasts(&mut s); + let r = s.execute("layer.newLight", json!({"kind": "Environment"})).unwrap(); + assert_eq!(r["advanced3d"], true); + assert_eq!(s.active_comp().unwrap().renderer, Renderer::Advanced3D); + // As with Composition Settings: the text layer gets Geometry Options. + assert!(s.active_comp().unwrap().layer(effectcraft_project::LayerId(text)).unwrap().props.sub("geometryOptions").is_some()); + assert!(toasts(&mut s).iter().any(|m| m.contains("Advanced 3D"))); + // A second Environment light leaves the comp (and the user) alone. + s.execute("layer.newLight", json!({"kind": "Environment"})).unwrap(); + assert!(toasts(&mut s).is_empty()); + // Undoing the creations takes the lights and the switch back together. + s.undo(); + s.undo(); + assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D); + assert_eq!(layer_count(&s), 1); + // Changing a light to Environment switches too, and undoes with the type. + let spot = s.execute("layer.newLight", json!({"kind": "Spot"})).unwrap()["layer"].as_u64().unwrap(); + assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D); + s.execute("layer.lightSettings", json!({"layer": spot, "kind": "Environment"})).unwrap(); + assert_eq!(s.active_comp().unwrap().renderer, Renderer::Advanced3D); + s.undo(); + assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D); + assert_eq!(s.active_comp().unwrap().layer(effectcraft_project::LayerId(spot)).unwrap().source, LayerSource::Light { kind: LightKind::Spot }); + // Other light types never switch it. + s.execute("layer.newLight", json!({"kind": "Point"})).unwrap(); + assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D); + // Switching back to Classic 3D with an Environment light in the comp explains itself. + let env = s.execute("layer.newLight", json!({"kind": "Environment"})).unwrap()["layer"].as_u64().unwrap(); + toasts(&mut s); + s.execute("comp.renderer", json!({"renderer": "classic3d"})).unwrap(); + assert!(toasts(&mut s).iter().any(|m| m.contains("Environment light")), "the Classic 3D limit is explained"); + // Editing that Environment light (no type change) leaves the renderer the user chose. + s.execute("layer.lightSettings", json!({"layer": env, "intensity": 80})).unwrap(); + assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D, "an ordinary edit doesn't re-switch"); + assert!(toasts(&mut s).is_empty(), "and doesn't toast either"); +} + +/// An Environment light takes Casts Shadows (with Shadow Darkness and Diffusion) like every +/// other light — the Light Settings dialog offers them for all types but Ambient — and keeps no +/// Falloff, which an environment light shining from every direction has no use for (#2436). +#[test] +fn environment_light_takes_shadow_options_but_no_falloff() { + let mut s = session(); + let env = s.execute("layer.newLight", json!({"kind": "Environment", "castsShadows": true, "shadowDarkness": 60, "shadowDiffusion": 12})).unwrap()["layer"] + .as_u64() + .unwrap(); + assert!(prop(&s, env, "lightOptions/castsShadows").as_bool()); + assert_eq!(prop(&s, env, "lightOptions/shadowDarkness").as_f64(), 60.0); + assert_eq!(prop(&s, env, "lightOptions/shadowDiffusion").as_f64(), 12.0); + assert!( + s.active_comp().unwrap().layer(effectcraft_project::LayerId(env)).unwrap().props.prop("lightOptions/falloff").is_none(), + "an environment light has no Falloff" + ); + // The shadow values carry over when the type changes, both ways. + s.execute("layer.lightSettings", json!({"layer": env, "kind": "Spot", "coneAngle": 70})).unwrap(); + assert!(prop(&s, env, "lightOptions/castsShadows").as_bool()); + assert_eq!(prop(&s, env, "lightOptions/shadowDarkness").as_f64(), 60.0); + assert_eq!(prop(&s, env, "lightOptions/coneAngle").as_f64(), 70.0); + s.execute("layer.lightSettings", json!({"layer": env, "kind": "Environment"})).unwrap(); + assert!(prop(&s, env, "lightOptions/castsShadows").as_bool()); + assert_eq!(prop(&s, env, "lightOptions/source").as_layer(), None, "back to Environment: it has a Source again"); + s.undo(); + assert!( + s.active_comp().unwrap().layer(effectcraft_project::LayerId(env)).unwrap().props.prop("lightOptions/coneAngle").is_some(), + "back to Spot: it has a Cone Angle again" + ); +} + #[test] fn three_d_switch_undo_redo() { let mut s = session(); diff --git a/crates/engine/src/tests/vector_import.rs b/crates/engine/src/tests/vector_import.rs index 9cfb94ecc..10c07c2ad 100644 --- a/crates/engine/src/tests/vector_import.rs +++ b/crates/engine/src/tests/vector_import.rs @@ -160,6 +160,37 @@ fn psd_imports_as_composition() { assert!(s.project.comp(cid).is_none()); } +/// #2573: New Composition From Footage on a Photoshop file imported as a composition opens that +/// composition; it isn't wrapped in another. +#[test] +fn psd_imported_as_composition_with_new_comp_is_the_new_comp() { + let path = tmp("newcomp.psd"); + std::fs::write(&path, sample_psd()).unwrap(); + let mut s = Session::default(); + let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition", "newComp": true})).unwrap(); + assert_eq!(r["errors"], json!([]), "{r}"); + let cid = effectcraft_project::ItemId(r["comps"][0].as_u64().unwrap()); + assert_eq!(s.state.active_comp, Some(cid)); + let roots = s.project.items.values().filter(|i| matches!(i.kind, ItemKind::Comp(_)) && i.name.starts_with("newcomp")).count(); + assert_eq!(roots, 1, "no wrapper comp"); +} + +/// A Photoshop file imported as a composition and dropped on the Timeline (`addToComp`) adds +/// its composition once, not its folder too. +#[test] +fn psd_imported_as_composition_into_a_comp_adds_one_layer() { + let path = tmp("dropped.psd"); + std::fs::write(&path, sample_psd()).unwrap(); + let mut s = Session::default(); + let main = s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap()["comp"].as_u64().unwrap(); + let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition", "addToComp": true})).unwrap(); + assert_eq!(r["errors"], json!([]), "{r}"); + let psd = r["comps"][0].as_u64().unwrap(); + let layers = &s.project.comp(effectcraft_project::ItemId(main)).unwrap().layers; + assert_eq!(layers.len(), 1, "{layers:?}"); + assert!(matches!(layers[0].source, LayerSource::Comp { item } if item.0 == psd)); +} + #[test] fn psd_retain_layer_sizes() { let path = tmp("retain.psd"); diff --git a/crates/engine/src/tests/viewer.rs b/crates/engine/src/tests/viewer.rs index 5ed0848b9..1ff12237e 100644 --- a/crates/engine/src/tests/viewer.rs +++ b/crates/engine/src/tests/viewer.rs @@ -419,7 +419,7 @@ fn shape_tool_options_paint_new_shapes() { fn masks_of_every_shape_tool_kind() { let mut s = comp(); let sol = s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap(); - for (kind, n) in [("rect", 4), ("ellipse", 4), ("rounded", 8), ("polygon", 6), ("star", 10)] { + for (kind, n) in [("rect", 4), ("ellipse", 4), ("rounded", 8), ("polygon", 5), ("star", 10)] { let m = s.execute("layer.addMask", json!({"layer": sol, "shape": kind, "rect": [100, 100, 80, 80]})).unwrap()["mask"].as_u64().unwrap(); let path = layer(&s, sol).props.find_group(m).unwrap().get("path").unwrap().value.as_path().unwrap().clone(); assert!(path.closed, "{kind}"); diff --git a/crates/engine/tests/it/parenting_3d_position.rs b/crates/engine/tests/it/parenting_3d_position.rs index f9dd9ae3f..61440e273 100644 --- a/crates/engine/tests/it/parenting_3d_position.rs +++ b/crates/engine/tests/it/parenting_3d_position.rs @@ -410,12 +410,12 @@ fn parenting_static_orientation_is_exact_just_off_gimbal_lock() { fn parenting_animated_orientation_and_nonuniform_parent_keep_authored_pose_boundary() { for animated in [true, false] { let (mut s, p, c) = fixture(true, true); + // A non-uniform parent has no uniform pose to re-express, animated or not. + set(&mut s, p, "transform/scale", json!([120.0, 100.0, 80.0])); if animated { for (time, value) in [(0.0, json!([-8.0, 12.0, -13.0])), (1.0, json!([5.0, 18.0, 14.0]))] { command(&mut s, "prop.addKey", json!({"layer":c,"path":"transform/orientation","time":time,"value":value})); } - } else { - set(&mut s, p, "transform/scale", json!([120.0, 100.0, 80.0])); } let layer = s.active_comp().unwrap().layer(LayerId(c)).unwrap(); let orientation = layer.props.prop("transform/orientation").unwrap().clone(); @@ -430,6 +430,27 @@ fn parenting_animated_orientation_and_nonuniform_parent_keep_authored_pose_bound } } +/// Keyed Orientation under a uniform parent is re-expressed at every key, so the child's world pose +/// and pixels stay put at every time (the parent is Z-only, so the paths between keys stay exact too). +#[test] +fn parenting_uniform_parent_re_expresses_keyed_orientation() { + for factor in [120.0, -120.0] { + let (mut s, p, c) = fixture(true, false); + set(&mut s, p, "transform/rotation", json!(42.0)); + set(&mut s, p, "transform/scale", json!([factor, factor, factor])); + for (time, value) in [(0.0, json!([-8.0, 12.0, -13.0])), (1.0, json!([5.0, 18.0, 14.0]))] { + command(&mut s, "prop.addKey", json!({"layer":c,"path":"transform/orientation","time":time,"value":value})); + } + let times = [0.0, 0.25, 0.5, 0.75, 1.0].map(Tick::from_seconds_f64); + let before = times.map(|t| world(&s, c, t)); + let images = times.map(|t| cpu(&s, t)); + parent(&mut s, c, Some(p), true); + let error = times.into_iter().zip(before).map(|(t, b)| displacement(b, world(&s, c, t))).fold(0.0, f64::max); + let pixels = times.into_iter().zip(&images).map(|(t, i)| diff(i, &cpu(&s, t))).fold(0.0, f32::max); + assert!(error < 1e-8 && pixels < 2e-5, "keyed Orientation under a uniform parent (factor {factor}): corners {error}, pixels {pixels}"); + } +} + /// Position's temporal speed is measured along its spatial path, in parent-space units. #[test] fn parenting_uniform_parent_preserves_eased_position_at_quarter_times_and_undo() { diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index fd1ace60b..1b69a87bd 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -376,7 +376,7 @@ async fn gif_export(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut State<'_ let end = (i + batch).min(st.total); let frames: Vec> = job .frames(comp, (i..end).collect(), |_, img| { - let mut px = job.pixels(&img, comp, job.output.channels, w, h); + let mut px = job.pixels_with(&img, comp, job.output.channels, w, h, false); gif::Frame::from_rgba_speed(w as u16, h as u16, &mut px, 10) }) .await; diff --git a/crates/export/src/pipeline.rs b/crates/export/src/pipeline.rs index b185c3345..c639ebad1 100644 --- a/crates/export/src/pipeline.rs +++ b/crates/export/src/pipeline.rs @@ -4,10 +4,8 @@ use std::sync::Mutex; -use effectcraft_project::render_queue::{ - AlphaMode, Channels, ColorDepth, CurrentOrOff, EffectsMode, FrameSample, RenderLog, ResizeQuality, SwitchOverride, log_path, -}; -use effectcraft_project::{Comp, Project}; +use effectcraft_project::render_queue::{AlphaMode, Channels, CurrentOrOff, EffectsMode, FrameSample, RenderLog, ResizeQuality, SwitchOverride, log_path}; +use effectcraft_project::{BitDepth, Comp, Project}; use effectcraft_raster::Image; use effectcraft_render::{Backend, LayerCache, RenderOpts, Renderer}; use effectcraft_time::Tick; @@ -220,6 +218,9 @@ impl<'a> Cx<'a> { pub fn new(job: &'a Job<'a>) -> Cx<'a> { let mut project = job.project.clone(); apply_overrides(&mut project, job); + // Render Settings ▸ Color Depth overrides the project's depth for the whole render (every + // layer and effect quantises at it), not just the finished frame (#2022). + project.settings.bit_depth = job.settings.color_depth.resolve(project.settings.bit_depth); let frame_times = (job.options.log == RenderLog::PlusPerFrameInfo).then(|| Mutex::new(Vec::new())); let cache = LayerCache::default(); // With deferred readbacks, buffers computed from a missed pass's placeholders are not kept. @@ -240,7 +241,7 @@ impl<'a> Cx<'a> { // compositors differ slightly, so a per-frame choice made renders differ run to run. let backend = if self.project.settings.gpu_acceleration { Backend::Gpu } else { Backend::Cpu }; let opts = RenderOpts { - scale: s.resolution.clamp(0.01, 4.0), + scale: self.comp().map(|c| s.effective_scale(c)).unwrap_or_else(|| s.resolution.clamp(0.01, 4.0)), motion_blur: s.motion_blur_override() != SwitchOverride::OffForAll, guides: s.guide_layers == CurrentOrOff::Current, draft: s.quality == RenderQuality::Draft, @@ -360,14 +361,6 @@ impl<'a> Cx<'a> { if second == first { a } else { interleave(&a, &self.render_at(second).await, upper_first) } } }; - let mut img = img; - // Color Depth: renders run at the project's depth; another depth quantises the frame. - let depth = self.job.settings.color_depth; - if depth != ColorDepth::Current - && let Some(levels) = depth.resolve(self.project.settings.bit_depth).levels() - { - img.quantize(levels); - } let img = self.crop_resize(img); if let Some(ft) = &self.frame_times { ft.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push((i, t0.elapsed().as_secs_f64())); @@ -384,12 +377,14 @@ impl<'a> Cx<'a> { return None; } let FrameSample::Progressive(t) = self.job.settings.sample(comp, i) else { return None }; - let depth = self.job.settings.color_depth; - if depth != ColorDepth::Current && depth.resolve(self.project.settings.bit_depth).levels().is_some() { + // Above 8 bpc the colour is dithered on its way to 8 bits (`Cx::pixels`), which the + // accelerator's conversion doesn't do: those frames render as usual so H.264 doesn't + // band (#2022). The project's depth already includes Render Settings ▸ Color Depth. + if self.project.settings.bit_depth != BitDepth::Bpc8 { return None; } let om = self.job.output; - let scale = self.job.settings.resolution.clamp(0.01, 4.0); + let scale = self.job.settings.effective_scale(comp); let size = |n: u32| ((n as f64 * scale).round() as u32).max(1); if om.resize.enabled || (size(comp.width), size(comp.height)) != (w, h) || om.crop.edges(w, h, scale) != (0, 0, 0, 0) { return None; @@ -435,7 +430,7 @@ impl<'a> Cx<'a> { /// Output Module ▸ Crop, then Resize. fn crop_resize(&self, img: Image) -> Image { let om = self.job.output; - let scale = self.job.settings.resolution.clamp(0.01, 4.0); + let scale = self.comp().map(|c| self.job.settings.effective_scale(c)).unwrap_or_else(|| self.job.settings.resolution.clamp(0.01, 4.0)); let (t, l, b, r) = om.crop.edges(img.width, img.height, scale); let img = if (t, l, b, r) == (0, 0, 0, 0) { img } else { crop(&img, t, l, b, r) }; if !om.resize.enabled { @@ -453,12 +448,24 @@ impl<'a> Cx<'a> { /// 8-bit RGBA of the requested channels cropped/padded to `w`×`h` (codec rounding): RGB over /// the comp background (opaque), RGB + Alpha straight or premultiplied, or the alpha matte. + /// When the render runs above 8 bpc, the colour is dithered on its way to 8 bits, as After + /// Effects dithers colours converted down to 8 bpc for rendering and export, so gradients + /// rendered at 16 or 32 bpc don't band (#2022). An 8 bpc render has no finer values to + /// dither, so its output stays byte for byte what it was. pub fn pixels(&self, img: &Image, comp: &Comp, channels: Channels, w: u32, h: u32) -> Vec { + let dither = self.project.settings.bit_depth != BitDepth::Bpc8; + self.pixels_with(img, comp, channels, w, h, dither) + } + + /// [`Cx::pixels`], dithering the colour or not (a GIF palette gains nothing from dither noise). + pub fn pixels_with(&self, img: &Image, comp: &Comp, channels: Channels, w: u32, h: u32, dither: bool) -> Vec { let px = match channels { + Channels::Rgb if dither => img.to_rgba8_over_dithered(comp.background), Channels::Rgb => img.to_rgba8_over(comp.background), - Channels::Rgba => match self.job.output.alpha_mode { - AlphaMode::Straight => img.to_rgba8(), - AlphaMode::Premultiplied => premultiplied8(img), + Channels::Rgba => match (self.job.output.alpha_mode, dither) { + (AlphaMode::Straight, true) => img.to_rgba8_dithered(), + (AlphaMode::Straight, false) => img.to_rgba8(), + (AlphaMode::Premultiplied, _) => premultiplied8(img, dither), }, Channels::Alpha => img .data @@ -619,15 +626,22 @@ fn bicubic(src: &Image, w: u32, h: u32) -> Image { out } -/// Premultiplied 8-bit RGBA (colour matted with black). -fn premultiplied8(img: &Image) -> Vec { - img.data - .iter() - .flat_map(|p| { - let a = p[3].clamp(0.0, 1.0); - [0, 1, 2].map(|c| (p[c].clamp(0.0, a) * 255.0 + 0.5) as u8).into_iter().chain([(a * 255.0 + 0.5) as u8]) - }) - .collect() +/// Premultiplied 8-bit RGBA (colour matted with black), the colour dithered when `dither`. +fn premultiplied8(img: &Image, dither: bool) -> Vec { + let w = img.width.max(1) as usize; + let mut out = Vec::with_capacity(img.data.len() * 4); + for (i, p) in img.data.iter().enumerate() { + let a = p[3].clamp(0.0, 1.0); + let a8 = (a * 255.0 + 0.5) as u8; + let (x, y) = ((i % w) as u32, (i / w) as u32); + for c in 0..3 { + let v = p[c].clamp(0.0, a); + // Dither may round up past the rounded alpha; premultiplied colour never exceeds it. + out.push(if dither { effectcraft_raster::dither_u8(v, x, y).min(a8) } else { (v * 255.0 + 0.5) as u8 }); + } + out.push(a8); + } + out } #[cfg(test)] @@ -636,6 +650,19 @@ mod tests { use rayon::prelude::*; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + /// Dither may round a colour up while alpha rounds down; premultiplied colour stays at or + /// below the written alpha at every pixel. + #[test] + fn dithered_premultiplied_colour_never_exceeds_alpha() { + let v = 100.25 / 255.0; + let img = Image::filled(64, 64, [v, v, v, v]); + let px = premultiplied8(&img, true); + assert!(px.as_chunks::<4>().0.iter().all(|p| p[..3].iter().all(|c| *c <= p[3])), "a colour byte is above its alpha"); + assert!(px.as_chunks::<4>().0.iter().all(|p| p[3] == 100)); + let plain = premultiplied8(&img, false); + assert!(plain.as_chunks::<4>().0.iter().all(|p| *p == [100, 100, 100, 100])); + } + /// Frames `0..n` through [`map_frames`] with `cap`, recording the most running at once. fn run_capped(n: u64, cap: Option) -> (Vec, usize) { use std::sync::atomic::{AtomicUsize, Ordering}; diff --git a/crates/export/tests/it/banding.rs b/crates/export/tests/it/banding.rs new file mode 100644 index 000000000..3f99f1eb6 --- /dev/null +++ b/crates/export/tests/it/banding.rs @@ -0,0 +1,156 @@ +//! Gradients exported at 16 bpc (or with a 16 bpc Color Depth override) don't band (#2022): the +//! float frame is dithered on its way to 8 bits, and the Render Settings' Color Depth reaches the +//! render itself. + +use std::sync::{Arc, Mutex}; + +use effectcraft_color::Label; +use effectcraft_export::render_queue::*; +use effectcraft_export::{Job, JobOptions, Sink, export}; +use effectcraft_keyframe::Value; +use effectcraft_project::build::{self, Ids}; +use effectcraft_project::{BitDepth, Comp, ItemId, ItemKind, LayerSource, Project, Solid}; +use effectcraft_render::NoFootage; +use effectcraft_time::{FrameRate, Tick}; + +const W: u32 = 1024; +const H: u32 = 16; +/// The ramp runs from this grey to `HI` across the comp: about 25 eight-bit codes in 1024 px, +/// so plain rounding makes bands about 40 px wide. +const LO: f64 = 0.20; +const HI: f64 = 0.30; +/// Width of the column blocks whose mean is compared with the ideal ramp. +const WINDOW: u32 = 8; + +/// A grey horizontal ramp over a comp of the given project depth. +fn ramp_project(depth: BitDepth) -> (Project, ItemId) { + let mut p = Project::default(); + p.settings.bit_depth = depth; + let mut comp = Comp::new(W, H, FrameRate::new(25, 1), Tick::from_seconds_f64(1.0)); + comp.background = [0.0, 0.0, 0.0]; + let sid = p.add_item("Ramp", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 1.0, 1.0], width: W, height: H, pixel_aspect: 1.0 })); + let mut layer = build::layer(&mut p, &comp, "Ramp", LayerSource::Solid { item: sid }, (W, H), None); + let spec = effectcraft_effects::find("ec.generate.gradientramp").expect("gradient ramp effect"); + let mut next = p.next_id; + let mut group = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [W as f64, H as f64]); + p.next_id = next; + for (k, v) in [ + ("start", Value::Vec2([0.0, 0.0])), + ("end", Value::Vec2([W as f64, 0.0])), + ("startColor", Value::Color([LO, LO, LO, 1.0])), + ("endColor", Value::Color([HI, HI, HI, 1.0])), + ] { + group.get_mut(k).expect("ramp parameter").value = v; + } + layer.props.sub_mut("effects").expect("effects group").children.push(group.into()); + comp.layers.push(layer); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.into())); + (p, cid) +} + +/// Export frame 0 as PNG with the Render Settings' Color Depth `depth` and return the grey of +/// every row. +fn export_rows(p: &Project, cid: ItemId, depth: ColorDepth) -> Vec> { + let files: Arc)>>> = Default::default(); + let f = files.clone(); + let sink: Box = Box::new(move |path: &str, data: Vec| f.lock().unwrap().push((path.to_string(), data))); + let settings = + RenderSettings { time_span: TimeSpan::Custom { start: Tick::ZERO, end: Tick::from_seconds_f64(0.04) }, color_depth: depth, ..Default::default() }; + let om = OutputModule::for_format(OutputFormat::PngSequence); + let job = Job { + project: p, + footage: &NoFootage, + expr: None, + accel: None, + comp: cid, + settings: &settings, + output: &om, + path: "/banding/f_[#####].png", + sink: Some(&*sink), + options: JobOptions::default(), + nested_switches: true, + }; + export(&job, &mut |_| true).expect("export"); + let files = files.lock().unwrap(); + let (_, png) = files.iter().find(|(path, _)| path.ends_with(".png")).expect("a PNG"); + let img = image::load_from_memory(png).expect("png").to_rgba8(); + (0..H).map(|y| (0..W).map(|x| img.get_pixel(x, y).0[1]).collect()).collect() +} + +/// The ideal ramp at column `x`, in 8-bit codes. +fn ideal(x: f64) -> f64 { + 255.0 * (LO + (HI - LO) * (x + 0.5) / W as f64) +} + +/// Longest run of equal neighbours in the row: a band's width. +fn longest_run(row: &[u8]) -> usize { + let (mut best, mut run) = (1, 1); + for w in row.windows(2) { + run = if w[0] == w[1] { run + 1 } else { 1 }; + best = best.max(run); + } + best +} + +/// Horizontal neighbours with different values, over all rows: ~25 per row when every band is a +/// flat step, hundreds when the steps are dithered. +fn transitions(rows: &[Vec]) -> usize { + rows.iter().map(|r| r.windows(2).filter(|w| w[0] != w[1]).count()).sum() +} + +/// Largest difference, in 8-bit codes, between the mean of a `WINDOW`-column block over all rows +/// and the ideal ramp's mean over it. Plain rounding leaves up to ~0.4 codes of it beside a step +/// edge; dither averages it away (its noise on a mean of `WINDOW * H` samples is under 0.05). +fn worst_window_error(rows: &[Vec]) -> f64 { + (0..W / WINDOW) + .map(|i| { + let (x0, x1) = ((i * WINDOW) as usize, ((i + 1) * WINDOW) as usize); + let sum: f64 = rows.iter().flat_map(|r| &r[x0..x1]).map(|&v| v as f64).sum(); + let mean = sum / (rows.len() * (x1 - x0)) as f64; + (mean - ideal((x0 + x1) as f64 / 2.0 - 1.0)).abs() + }) + .fold(0.0, f64::max) +} + +#[test] +fn a_16_bpc_gradient_export_is_dithered() { + let (p, cid) = ramp_project(BitDepth::Bpc16); + let rows = export_rows(&p, cid, ColorDepth::Current); + let (steps, err) = (transitions(&rows), worst_window_error(&rows)); + eprintln!("16 bpc project: {steps} transitions, worst {WINDOW}-px mean error {err:.3} codes"); + // Undithered there are ~25 flat steps per row (400 transitions) and the block means are off + // by up to ~0.4 codes. + assert!(steps > 2000, "the steps survive: {steps} transitions"); + assert!(err < 0.25, "the local mean is off by {err} codes"); + // Dither never moves a value a whole code from the ideal ramp. + for row in &rows { + for (x, &v) in row.iter().enumerate() { + let want = ideal(x as f64); + assert!((v as f64 - want).abs() < 1.05, "x {x}: {v} vs {want}"); + } + } +} + +#[test] +fn the_export_is_the_same_every_time() { + let (p, cid) = ramp_project(BitDepth::Bpc16); + assert_eq!(export_rows(&p, cid, ColorDepth::Current), export_rows(&p, cid, ColorDepth::Current)); +} + +#[test] +fn the_render_color_depth_reaches_the_render() { + // An 8 bpc project renders every layer at 8 bits: flat steps, nothing for the output to dither. + let (p, cid) = ramp_project(BitDepth::Bpc8); + let banded = export_rows(&p, cid, ColorDepth::Current); + assert!(longest_run(&banded[0]) >= 30, "an 8 bpc render is quantised before export"); + assert!(transitions(&banded) < 16 * 30); + // The Render Settings' 16 bpc depth overrides the project's: the render keeps its precision + // until the final conversion, which dithers. + let deep = export_rows(&p, cid, ColorDepth::Bpc16); + let (steps, err) = (transitions(&deep), worst_window_error(&deep)); + eprintln!("8 bpc project, 16 bpc render: {steps} transitions, worst {WINDOW}-px mean error {err:.3} codes"); + assert!(steps > 2000 && err < 0.25, "a 16 bpc render still bands: {steps} transitions, error {err}"); + // And 8 bpc on a 16 bpc project renders at 8 bits. + let (p16, cid16) = ramp_project(BitDepth::Bpc16); + assert_eq!(export_rows(&p16, cid16, ColorDepth::Bpc8), banded); +} diff --git a/crates/export/tests/it/h264_gpu_yuv.rs b/crates/export/tests/it/h264_gpu_yuv.rs index 59e670779..0f99a7b54 100644 --- a/crates/export/tests/it/h264_gpu_yuv.rs +++ b/crates/export/tests/it/h264_gpu_yuv.rs @@ -6,7 +6,7 @@ use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicUsize, Ordering}; use effectcraft_color::Label; -use effectcraft_export::render_queue::{OutputFormat, OutputModule, RenderSettings, TimeSpan}; +use effectcraft_export::render_queue::{ColorDepth, OutputFormat, OutputModule, RenderSettings, TimeSpan}; use effectcraft_export::{Job, export}; use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, Project, Solid, build}; use effectcraft_raster::Image; @@ -147,6 +147,20 @@ fn frames_the_accelerator_cannot_take_fall_back_to_rgba() { assert!(a == c); } +/// Above 8 bpc the colour is dithered on its way to 8 bits, which the accelerator's planes +/// don't do: those frames take the RGBA path, so an H.264 export doesn't band (#2022). +#[test] +fn renders_above_8_bpc_skip_the_planes() { + let d = out_dir("h264-gpu-yuv-16bpc"); + let (p, cid) = project(); + let s = RenderSettings { time_span: TimeSpan::LengthOfComp, color_depth: ColorDepth::Bpc16, ..Default::default() }; + let yuv = Fake::new(true, false, usize::MAX); + let a = run(&p, cid, Some(&yuv), &s, &d.join("yuv.mp4")); + let c = run(&p, cid, None, &s, &d.join("cpu.mp4")); + assert_eq!(yuv.yuv_frames.load(Ordering::SeqCst), 0, "no planes asked for above 8 bpc"); + assert!(a == c); +} + /// With deferred readbacks the export keeps rendering frames in passes (`Cx::frames`), never /// through the blocking per-frame path the planes use. #[test] diff --git a/crates/export/tests/it/main.rs b/crates/export/tests/it/main.rs index 0064ba8b7..88398a0e8 100644 --- a/crates/export/tests/it/main.rs +++ b/crates/export/tests/it/main.rs @@ -1,6 +1,7 @@ //! The crate's integration tests, one binary (each module is a former `tests/.rs`). mod audio_sample_positions; +mod banding; mod h264_gpu_yuv; mod hevc_av1_roundtrip; #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/export/tests/it/movie_bytes.rs b/crates/export/tests/it/movie_bytes.rs index 1f48efd1e..a58507161 100644 --- a/crates/export/tests/it/movie_bytes.rs +++ b/crates/export/tests/it/movie_bytes.rs @@ -61,7 +61,7 @@ fn project() -> (Project, ItemId) { } box_.blend_mode = BlendMode::Screen; - comp.layers = vec![box_, ring, solid]; + comp.layers = vec![box_, ring, solid].into(); let cid = p.add_item("Comp 1", Label::Sandstone, None, ItemKind::Comp(comp.into())); (p, cid) } diff --git a/crates/expr/src/host.rs b/crates/expr/src/host.rs index a25fbc4f0..fa2993f97 100644 --- a/crates/expr/src/host.rs +++ b/crates/expr/src/host.rs @@ -10,7 +10,7 @@ use effectcraft_geom::{Mat3, Mat4}; use effectcraft_keyframe::Value; use effectcraft_project::{Comp, ItemId, ItemKind, Layer, LayerId, LayerSource, Node, PropGroup, Property}; -use effectcraft_render::{EvalCtx, source_size}; +use effectcraft_render::EvalCtx; use effectcraft_time::Tick; /// A name-or-index argument (`layer("A")`, `layer(2)`, `effect(1)`). @@ -299,7 +299,17 @@ impl<'a> Resolver<'a> { } fn ctx_at(&self, c: u64, t: f64) -> Option> { let comp = self.comp(c)?; - Some(EvalCtx { project: self.ctx.project, comp_id: ItemId(c), comp, time: Tick::from_seconds_f64(t), expr: self.ctx.expr, footage: self.ctx.footage }) + let same_comp = c == self.ctx.comp_id.0; + Some(EvalCtx { + project: self.ctx.project, + comp_id: ItemId(c), + comp, + time: Tick::from_seconds_f64(t), + expr: self.ctx.expr, + footage: self.ctx.footage, + property_overrides: if same_comp { self.ctx.property_overrides } else { &[] }, + media_overrides: if same_comp { self.ctx.media_overrides } else { &[] }, + }) } fn prop(&self, c: u64, l: u64, path: &str) -> Option<(&'a Layer, &'a Property)> { let layer = self.layer(c, l)?; @@ -372,7 +382,7 @@ impl<'a> Resolver<'a> { Req::LayerInfo { comp, layer } => { let c = self.comp(*comp)?; let l = self.layer(*comp, *layer)?; - let (mut w, mut h) = source_size(project, l); + let (mut w, mut h) = self.ctx_at(*comp, self.ctx.time.seconds())?.source_size(l); if w == 0 || h == 0 { (w, h) = (c.width, c.height); } @@ -467,7 +477,7 @@ impl<'a> Resolver<'a> { } } _ => { - let (w, h) = source_size(project, l); + let (w, h) = ctx.source_size(l); [0.0, 0.0, w as f64, h as f64] } }; @@ -501,7 +511,12 @@ impl<'a> Resolver<'a> { Req::SourceInfo { comp, layer } => { let c = self.comp(*comp)?; let l = self.layer(*comp, *layer)?; - let it = project.item(l.source.item()?)?; + let ctx = self.ctx_at(*comp, self.ctx.time.seconds())?; + let item = match &l.source { + LayerSource::Footage { item } => ctx.media_item(l, *item), + _ => l.source.item()?, + }; + let it = project.item(item)?; let (kind, aspect, frame, duration) = match &it.kind { ItemKind::Comp(nc) => ("comp", nc.pixel_aspect, nc.frame_duration().seconds(), nc.duration.seconds()), ItemKind::Footage(f) => ("footage", f.pixel_aspect, f.frame_rate.frame_duration().seconds(), f.duration.seconds()), diff --git a/crates/expr/src/lib.rs b/crates/expr/src/lib.rs index c77af5b37..9e78d250f 100644 --- a/crates/expr/src/lib.rs +++ b/crates/expr/src/lib.rs @@ -17,7 +17,8 @@ //! `sourceRectAtTime`), property methods (`valueAtTime`, `velocityAtTime`, `speedAtTime`, `key`, //! `nearestKey`, `numKeys`, `wiggle`, `temporalWiggle`, `smooth`, `loopIn/Out[Duration]`) and the //! global helpers (`linear`/`ease`/`easeIn`/`easeOut`, `random`/`gaussRandom`/`seedRandom`/ -//! `noise`, vector maths, `clamp`, `lookAt`, time and colour conversions, `posterizeTime`). +//! `noise`, vector maths, `clamp`, `lookAt`, time and colour conversions, `posterizeTime`), +//! plus `bezier`, a CSS-style cubic-Bezier easing helper that After Effects does not have. //! * **Determinism**: `wiggle` and `random` are seeded by layer index, property uid (and time / //! `seedRandom`), so frames render identically on every thread and platform. //! * **Runtime**: one boa `Context` per thread with compiled scripts cached by text. Scripts read @@ -162,7 +163,16 @@ fn evaluate_out( 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 ctx = EvalCtx { + project: ctx.project, + comp_id: ctx.comp_id, + comp: ctx.comp, + time: ctx.time, + expr: Some(host), + footage: ctx.footage, + property_overrides: ctx.property_overrides, + media_overrides: ctx.media_overrides, + }; let comp = ctx.comp_id.0; let path = layer.props.path_of(prop.uid).unwrap_or_else(|| format!("@{}", prop.uid)); let resolver = Resolver { ctx, own: Own { comp, layer, prop, path: path.clone() } }; diff --git a/crates/expr/src/prelude.js b/crates/expr/src/prelude.js index c2ca16736..3196ce5a3 100644 --- a/crates/expr/src/prelude.js +++ b/crates/expr/src/prelude.js @@ -31,6 +31,7 @@ function __begin(t, v, c, l, path, uid, idx, fd, ti, tt, sv) { __seed = 0; __timeless = false; __projectInfo = null; + __layersTrim(); } function __finish(r) { @@ -44,7 +45,7 @@ function __finish(r) { function __result(r) { r = __v(r); - if (r !== null && typeof r === 'object' && r.__isLayer) return r.index; + if (typeof r === 'function' && r.__isLayer) return r.index; if (r !== null && typeof r === 'object' && r.__isTextStyle) return r.__out(); // `[temp, temp]` where temp is a property (pick-whip 1D → 2D): read the values here, while // host requests can still be answered and the script re-run. @@ -207,6 +208,60 @@ function ease() { return __interp(__ease, arguments); } function easeIn() { return __interp(__easeIn, arguments); } function easeOut() { return __interp(__easeOut, arguments); } +// Cubic-Bezier easing (the CSS cubic-bezier() shape): [x1, y1, x2, y2], with x1 / x2 in 0..1 +// and y1 / y2 free (overshoot included). Solves x(u) = x by Newton–Raphson, then by bisection +// when Newton leaves 0..1 or the derivative vanishes, and returns y(u). A curve that is not four +// finite numbers is linear, never an error. +function __cubicBezier(curve) { + var x1, y1, x2, y2; + if (Array.isArray(curve) && curve.length === 4) { + x1 = curve[0]; y1 = curve[1]; x2 = curve[2]; y2 = curve[3]; + } + if (typeof x1 !== 'number' || typeof y1 !== 'number' || typeof x2 !== 'number' || typeof y2 !== 'number' || + !isFinite(x1) || !isFinite(y1) || !isFinite(x2) || !isFinite(y2)) { + return __lin; + } + x1 = Math.max(0, Math.min(1, x1)); + x2 = Math.max(0, Math.min(1, x2)); + function bx(u) { var v = 1 - u; return 3 * v * v * u * x1 + 3 * v * u * u * x2 + u * u * u; } + function dbx(u) { var v = 1 - u; return 3 * v * v * x1 + 6 * v * u * (x2 - x1) + 3 * u * u * (1 - x2); } + function by(u) { var v = 1 - u; return 3 * v * v * u * y1 + 3 * v * u * u * y2 + u * u * u; } + function dby(u) { var v = 1 - u; return 3 * v * v * y1 + 6 * v * u * (y2 - y1) + 3 * u * u * (1 - y2); } + return function (x) { + if (x <= 0) return 0; + if (x >= 1) return 1; + var u = x; + for (var i = 0; i < 8; i++) { + var f = bx(u) - x; + var d = dbx(u); + if (Math.abs(d) < 1e-12) break; + // Stop when the *value* is accurate: near the endpoints dy/dx is steep, so a residual in x + // alone can still mean a visible error in y (f * dy/dx estimates it). + if (Math.abs(f * dby(u) / d) < 1e-9) return by(u); + var n = u - f / d; + if (!(n >= 0 && n <= 1)) break; + u = n; + } + var lo = 0, hi = 1; + for (var j = 0; j < 60; j++) { + var mid = (lo + hi) / 2; + if (bx(mid) < x) lo = mid; else hi = mid; + } + return by((lo + hi) / 2); + }; +} + +// bezier(t, tMin, tMax, value1, value2, curve), or bezier(t, value1, value2, curve): the curve is +// the sixth / fourth argument. Read it by position, not from the end: when it is missing or +// unusable, the interpolation falls back to linear without consuming the last value. +function bezier() { + var a = arguments, n = a.length; + if (n >= 6) return __interp(__cubicBezier(a[5]), [a[0], a[1], a[2], a[3], a[4]]); + if (n === 5) return __interp(__lin, [a[0], a[1], a[2], a[3], a[4]]); + if (n === 4) return __interp(__cubicBezier(a[3]), [a[0], a[1], a[2]]); + return __interp(__lin, [a[0], a[1], a[2]]); +} + // --------------------------------------------------------------------------------------------- // Random numbers and noise. @@ -434,7 +489,7 @@ function __ancestor(c, l, path, countUp) { if (typeof n !== 'number' || !(n >= 1) || !isFinite(n)) __err('propertyGroup(' + countUp + '): the count must be a number of 1 or more'); n = Math.floor(n); var info = __h(__ANCESTOR, c, l, path, n); - if (info === 'layer') return new Layer(c, l); + if (info === 'layer') return __layer(c, l); if (info === null) __err('propertyGroup(' + n + ') goes above the layer'); return __mkGroup(c, l, info); } @@ -464,10 +519,10 @@ Comp.prototype = { return __err('Composition "' + this.name + '" has no active camera'); }, layer: function (a, rel) { - if (a !== null && typeof a === 'object' && a.__isLayer) return this.layer(a.index + (rel || 0)); + if (typeof a === 'function' && a.__isLayer) return this.layer(a.index + (rel || 0)); var id = __h(__LAYER, this.__c, a); if (id === null) __err(typeof a === 'string' ? 'Layer named "' + a + '" does not exist' : 'Layer index ' + a + ' out of range'); - return new Layer(this.__c, id); + return __layer(this.__c, id); }, toString: function () { return '[object Comp]'; }, }; @@ -542,7 +597,55 @@ var thisProject = { // Layer ----------------------------------------------------------------------------------------- -function Layer(c, l) { this.__c = c; this.__l = l; this.__i = null; } +// A layer is a property group too, as in After Effects: `thisLayer("transform")`, another layer's +// `("effects")("Slider Control")`, `thisLayer(1)` (what the pick whip writes with Compact English +// off). Callable like a group (`__mkGroup`), with the Layer methods. +function Layer(c, l) { + var f = function (k) { return f.__group(k); }; + delete f.name; + delete f.length; + Object.setPrototypeOf(f, Layer.prototype); + // Kept across evaluations (`__layer`), so an expression can't change which layer it is, delete + // a field or leave its own on it. Its info is private to `__info`, which reads it again in an + // evaluation whose `__S` isn't the one it was read in (every evaluation has a new `__S`), so + // freezing the layer can't keep its info either. + var info = null, read = null; + Object.defineProperty(f, '__c', { value: c }); + Object.defineProperty(f, '__l', { value: l }); + Object.defineProperty(f, '__info', { + get: function () { + if (read !== __S) { info = __h(__LINFO, c, l); read = __S; } + return info; + }, + }); + Object.preventExtensions(f); + return f; +} + +// Layer `l` of comp `c`, made once per runtime: a callable Layer is dearer to make than a plain +// object, so it is kept, and its info (`__info`) is read again in each evaluation. +// The kept layers are out of an expression's reach (their maps have no prototype, so a change to +// `Object.prototype` doesn't reach them either), and bounded like the runtime's scripts: once +// `max` are kept, the layers after them aren't, and the next evaluation starts over +// (`__layersTrim`, so a layer stays the same object for a whole evaluation). +var __layer, __layersTrim; +(function () { + var layers = Object.create(null), kept = 0, max = 4096; + __layer = function (c, l) { + // By comp, then layer: a `c + ':' + l` key costs more to build than the lookup saves. + var m = layers[c], f = m && m[l]; + if (f) return f; + f = new Layer(c, l); + if (kept < max) { + (m || (layers[c] = Object.create(null)))[l] = f; + kept++; + } + return f; + }; + __layersTrim = function () { + if (kept >= max) { layers = Object.create(null); kept = 0; } + }; +})(); function __m3(m, p) { var x = p[0] || 0, y = p[1] || 0; @@ -566,7 +669,6 @@ function __m4(m, p, vec) { Layer.prototype = { __isLayer: true, - get __info() { return this.__i || (this.__i = __h(__LINFO, this.__c, this.__l)); }, get name() { return this.__info[0]; }, get index() { return this.__info[1]; }, get inPoint() { return this.__info[2]; }, @@ -578,7 +680,7 @@ Layer.prototype = { get parent() { var p = this.__info[7]; if (p === null) __err('Layer "' + this.name + '" has no parent'); - return new Layer(this.__c, p); + return __layer(this.__c, p); }, get hasVideo() { return this.__info[8]; }, get enabled() { return this.__info[11]; }, @@ -750,7 +852,7 @@ Prop.prototype = { propertyGroup: function (n) { return __ancestor(this.__c, this.__l, this.__p, n); }, __wrap: function (v) { var kind = this.__info[2]; - if (kind === 'layer') return v === null ? null : new Layer(this.__c, v); + if (kind === 'layer') return v === null ? null : __layer(this.__c, v); if (kind === 'path' && v !== null) return new __Path(v[0], v[1], v[2], v[3]); return v; }, @@ -780,7 +882,7 @@ Prop.prototype = { for (var i = 1; i < times.length; i++) if (Math.abs(times[i] - t) < Math.abs(times[best] - t)) best = i; return this.key(best + 1); }, - __seedIndex: function () { return this.__self ? __S.idx : new Layer(this.__c, this.__l).index; }, + __seedIndex: function () { return this.__self ? __S.idx : __layer(this.__c, this.__l).index; }, wiggle: function (freq, amp, octaves, ampMult, t) { if (octaves === undefined) octaves = 1; if (ampMult === undefined) ampMult = 0.5; @@ -849,8 +951,9 @@ var __propHandler = { var v = t.value; if (v === null || v === undefined) return undefined; var x = v[k]; - // Property groups are callable proxies: hand them over as they are, not bound like methods (#809). - if (x?.__isGroup) return x; + // Property groups and layers are callable: hand them over as they are, not bound like methods + // (#809). + if (typeof x === 'function' && (x.__isGroup || x.__isLayer)) return x; return typeof x === 'function' ? x.bind(v) : x; }, }; @@ -1028,18 +1131,18 @@ Markers.prototype = { // --------------------------------------------------------------------------------------------- // Unqualified names: thisLayer / thisProperty attributes and the `this*` objects. They're // accessors on the global object; assigning one (`var width = 10`) shadows it for the current -// evaluation only. +// evaluation only. They can't be deleted or redefined, so an expression can't take one away from +// the evaluations after it. function __global(name, get) { Object.defineProperty(globalThis, name, { - configurable: true, get: function () { return name in __S.vars ? __S.vars[name] : get(); }, set: function (v) { __S.vars[name] = v; }, }); } __global('thisComp', function () { return __S.comp || (__S.comp = new Comp(__S.c)); }); -__global('thisLayer', function () { return __S.layer || (__S.layer = new Layer(__S.c, __S.l)); }); +__global('thisLayer', function () { return __S.layer || (__S.layer = __layer(__S.c, __S.l)); }); __global('thisProperty', function () { return __S.prop || (__S.prop = __mkProp(__S.c, __S.l, __S.path)); }); ['name', 'index', 'inPoint', 'outPoint', 'startTime', 'width', 'height', 'hasParent', 'parent', 'hasVideo', 'enabled', 'active', 'transform', 'position', 'scale', 'rotation', 'opacity', 'anchorPoint', 'text', 'marker'].forEach(function (k) { @@ -1172,3 +1275,23 @@ function nearestKey(t) { return thisProperty.nearestKey(t); } delete globalThis.Proxy; delete globalThis.Reflect; })(); + +// --------------------------------------------------------------------------------------------- +// The runtime runs this file once and keeps it for the thread's later evaluations, so an +// expression can't change the object model or the internals: the prototypes are frozen, and the +// `__` globals, the constructors and `thisProject` can't be reassigned. The `__` state below is +// set again by `__begin` in each evaluation. + +[Comp, Footage, Layer, Prop, Markers, __Path, __TextStyle].forEach(function (f) { + Object.freeze(f.prototype); + Object.freeze(f); +}); +[__GroupProto, __groupHandler, __propHandler, thisProject].forEach(Object.freeze); +(function () { + var perEvaluation = { __S: true, __rc: true, __seed: true, __timeless: true, __projectInfo: true }; + var model = { Comp: true, Footage: true, Layer: true, Prop: true, Markers: true, thisProject: true }; + Object.getOwnPropertyNames(globalThis).forEach(function (k) { + if (!(k.slice(0, 2) === '__' && !perEvaluation[k]) && !model[k]) return; + if ('value' in Object.getOwnPropertyDescriptor(globalThis, k)) Object.defineProperty(globalThis, k, { writable: false, configurable: false }); + }); +})(); diff --git a/crates/expr/src/runtime.rs b/crates/expr/src/runtime.rs index 29e5e4eda..84bfcdf3c 100644 --- a/crates/expr/src/runtime.rs +++ b/crates/expr/src/runtime.rs @@ -677,6 +677,7 @@ pub fn styled_doc(v: &serde_json::Value, own: &effectcraft_keyframe::TextDoc) -> let p0 = src.para(0); d.set_paras(vec![p0; d.para_count()]); d.stroke_over_fill = src.stroke_over_fill; + d.line_join = src.line_join; } } for op in v.get("ops").and_then(|o| o.as_array()).into_iter().flatten() { diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs index cf13f02f8..57d491de5 100644 --- a/crates/expr/src/tests.rs +++ b/crates/expr/src/tests.rs @@ -77,7 +77,7 @@ fn fx() -> Fx { let ids = (a.id, b.id, title.id, shape.id); let c = p.comp_mut(cid).unwrap(); - c.layers = vec![a, b, title, shape]; + c.layers = vec![a, b, title, shape].into(); c.markers.push(Marker { time: secs(1.5), comment: "comp marker".into(), ..Default::default() }); let other = Comp::new(64, 32, FrameRate::FPS_24, Tick::from_seconds_f64(3.0)); @@ -314,8 +314,78 @@ fn interpolation_with_arrays() { assert_eq!(f.pos("ease(time, 0, 1, [0, 0], [100, 50])", 1.0), [100.0, 50.0, 0.0]); } -// ---- object model ----------------------------------------------------------------------------- +#[test] +fn cubic_bezier_curves() { + let f = fx(); + // ease(t, …) is cubic-bezier(1/3, 0, 2/3, 1): x(u) = u and y(u) = 3u² - 2u³. + assert_close!(f.num("bezier(0.25, 0, 1, 0, 100, [0.3333333333333333, 0, 0.6666666666666666, 1])", 0.0), f.num("ease(0.25, 0, 1, 0, 100)", 0.0)); + assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, [0.3333333333333333, 0, 0.6666666666666666, 1])", 0.0), 50.0); + // Symmetric ease-in-out: slower at both ends, faster in the middle. + let early = f.num("bezier(0.25, 0, 1, 0, 100, [0.42, 0, 0.58, 1])", 0.0); + let late = f.num("bezier(0.75, 0, 1, 0, 100, [0.42, 0, 0.58, 1])", 0.0); + assert!(early < 25.0 && late > 75.0, "ease-in-out: {early}, {late}"); + // Control points on the diagonal are linear. + assert_close!(f.num("bezier(0.3, 0, 1, 0, 100, [0, 0, 1, 1])", 0.0), 30.0); + // t is clamped to tMin..tMax; tMin == tMax behaves like the other helpers. + assert_close!(f.num("bezier(-1, 0, 1, 0, 100, [0.42, 0, 0.58, 1])", 0.0), 0.0); + assert_close!(f.num("bezier(2, 0, 1, 0, 100, [0.42, 0, 0.58, 1])", 0.0), 100.0); + assert_close!(f.num("bezier(5, 2, 2, 0, 100, [0.42, 0, 0.58, 1])", 0.0), 100.0); +} +#[test] +fn cubic_bezier_short_form_arrays_and_fallbacks() { + let f = fx(); + // Short form: no tMin / tMax. + assert_close!(f.num("bezier(0.25, 10, 20, [0, 0, 1, 1])", 0.0), 12.5); + assert_eq!(f.pos("bezier(time, 0, 1, [0, 0], [100, 50], [0.42, 0, 0.58, 1])", 1.0), [100.0, 50.0, 0.0]); + // y outside 0..1 overshoots, as CSS allows. + let over = f.num("bezier(0.5, 0, 1, 0, 100, [0.5, 2, 0.5, 2])", 0.0); + assert!(over > 100.0, "overshoot: {over}"); + // A curve that is not four finite numbers falls back to linear instead of throwing. + assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, [1, 0])", 0.0), 50.0); + assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, 'ease')", 0.0), 50.0); + // A missing or unusable curve must not consume the last value: read the curve by position. + assert_close!(f.num("bezier(0.5, 0, 1, 0, 100)", 0.0), 50.0); + assert_close!(f.num("bezier(0.5, 0, 100)", 0.0), 50.0); + assert_close!(f.num("bezier(0.5, 0, 100, [1, 0])", 0.0), 50.0); + let v = f.ev(f.shape, "contents/#1/contents/fill/color", "bezier(0.5, 0, 1, [0, 0, 0, 1], [1, 1, 1, 1])", 0.0).unwrap(); + assert_eq!(v, Value::Color([0.5, 0.5, 0.5, 1.0])); + // Exactly four values are a curve; extra ones mean the curve is malformed, so linear. + assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, [0.42, 0, 0.58, 1, 0])", 0.0), 50.0); + assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, [0.42, 0, 0.58, 1, 2, 3])", 0.0), 50.0); + // x control points outside 0..1 are clamped, as CSS requires. + let v = f.num("bezier(0.5, 0, 1, 0, 100, [-1, 0, 2, 1])", 0.0); + assert!(v.is_finite(), "clamped curve: {v}"); +} + +#[test] +fn cubic_bezier_is_accurate_near_the_endpoints() { + /// The value the curve reaches at `x`, by bisection on its monotone x(u). + fn reference(x1: f64, y1: f64, x2: f64, y2: f64, x: f64) -> f64 { + let bx = |u: f64| 3.0 * (1.0 - u) * (1.0 - u) * u * x1 + 3.0 * (1.0 - u) * u * u * x2 + u * u * u; + let by = |u: f64| 3.0 * (1.0 - u) * (1.0 - u) * u * y1 + 3.0 * (1.0 - u) * u * u * y2 + u * u * u; + let (mut lo, mut hi) = (0.0f64, 1.0f64); + for _ in 0..100 { + let mid = 0.5 * (lo + hi); + if bx(mid) < x { + lo = mid; + } else { + hi = mid; + } + } + by(0.5 * (lo + hi)) + } + // Steep curves: a residual in x is not a residual in y near the endpoints (dy/dx is in the + // thousands), so the solver stops on the estimated y error instead. + let f = fx(); + for (c, x) in [([0.0, 1000.0, 1.0, 1.0], 1e-6), ([0.0, 100.0, 1.0, 0.0], 1e-9), ([0.8, -500.0, 1.0, 1.0], 0.999999)] { + let got = f.num(&format!("bezier({x}, 0, 1, [{}, {}, {}, {}])", c[0], c[1], c[2], c[3]), 0.0); + let want = reference(c[0], c[1], c[2], c[3], x); + assert!((got - want).abs() < 1e-6, "curve {c:?} at {x}: {got} vs {want}"); + } +} + +// ---- object model ----------------------------------------------------------------------------- #[test] fn comp_attributes() { let f = fx(); @@ -908,6 +978,49 @@ fn source_rect_at_time() { assert!((s[0] + 31.0).abs() < 2.0 && (s[1] + 16.0).abs() < 2.0 && (s[2] - 62.0).abs() < 3.0, "{s:?}"); } +#[test] +fn source_dimensions_follow_media_replacement() { + let mut p = Project::default(); + let comp = Comp::new(200, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let cid = p.add_item("Main", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + let footage = |p: &mut Project, name: &str, width, height| { + p.add_item( + name, + Label::SeaFoam, + None, + ItemKind::Footage(effectcraft_project::Footage { + path: name.into(), + kind: effectcraft_project::FootageKind::Still, + width, + height, + has_video: true, + ..Default::default() + }), + ) + }; + let original = footage(&mut p, "original.png", 100, 80); + let replacement = footage(&mut p, "replacement.png", 30, 40); + let mut l = build::layer(&mut p, &comp, "Clip", LayerSource::Footage { item: original }, (100, 80), None); + l.props.prop_mut(ROT).unwrap().expr = + Some(Expression { text: "thisLayer.width + thisLayer.height + thisLayer.source.width + sourceRectAtTime().width".into(), enabled: true }); + let lid = l.id; + p.comp_mut(cid).unwrap().layers.push(l); + let c = p.comp(cid).unwrap(); + let l = c.layer(lid).unwrap(); + let media = effectcraft_render::MediaOverride::new(lid, replacement); + let ctx = EvalCtx { + project: &p, + comp_id: cid, + comp: c, + time: Tick::ZERO, + expr: Some(&Expressions), + footage: None, + property_overrides: &[], + media_overrides: &[media], + }; + assert_eq!(ctx.value(l, l.props.prop(ROT).unwrap()), Value::Scalar(130.0)); +} + // ---- colour ----------------------------------------------------------------------------------- #[test] @@ -986,6 +1099,8 @@ fn shadowing_layer_attribute_names_is_local() { let f = fx(); assert_close!(f.num("var width = 3;\nwidth * 2", 0.0), 6.0); assert_close!(f.num("width", 0.0), 20.0, 1e-9); + assert_close!(f.num("function width() { return 4; }\nwidth() * 2", 0.0), 8.0); + assert_close!(f.num("width", 0.0), 20.0, 1e-9); } #[test] @@ -1115,7 +1230,16 @@ fn text_expression_selector_values() { let sel_of = |p: &Project| { let c = p.comp(cid).unwrap(); let l = c.layer(lid).unwrap(); - let ctx = EvalCtx { project: p, comp_id: cid, comp: c, time: Tick::ZERO, expr: Some(&Expressions), footage: None }; + let ctx = EvalCtx { + project: p, + comp_id: cid, + comp: c, + time: Tick::ZERO, + expr: Some(&Expressions), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let doc = effectcraft_render::text::source_text(&ctx, l).unwrap(); let lay = effectcraft_text::layout_doc(&doc); let anim = l.props.group("text/animators/#1").unwrap(); @@ -1371,4 +1495,97 @@ fn time_to_frames_preserves_coercion_and_small_durations() { } } +/// A layer is a property group too, as in After Effects: `thisLayer("transform")`, another +/// layer's `("effects")("Slider Control")` or `(1)`, as the pick whip writes them with Compact +/// English off (they failed: "not a callable function"). It is still a layer. +#[test] +fn layers_are_callable_property_groups() { + let f = fx(); + assert_close!(f.num("thisLayer(\"transform\")(\"opacity\")", 0.0), 100.0); + assert_close!(f.num("thisComp.layer(\"A\")(\"effects\")(\"ec.control.slider\")(\"slider\")", 0.0), 42.0); + assert_close!(f.num("thisComp.layer(\"A\")(\"effects\")(\"Slider Control\")(1)", 0.0), 42.0); + assert_close!(f.num("thisComp.layer(1)(\"effects\")(1)(1)", 0.0), 42.0); + // Its own name, index and methods, and a layer as the result still reads as its index. + assert_close!(f.num("thisLayer.name == \"A\" ? thisComp.layer(\"B\").index : 0", 0.0), 2.0); + assert_close!(f.num("thisComp.layer(\"B\")", 0.0), 2.0); + assert_close!(f.num("thisComp.layer(thisComp.layer(\"B\"), -1).index", 0.0), 1.0); + assert!(f.err("thisLayer(\"nope\")").contains("no property"), "{}", f.err("thisLayer(\"nope\")")); +} + +/// A layer is kept from one evaluation to the next (it is dear to make) but reads the project +/// afresh in each, and an expression's own fields on it don't reach the next one. +#[test] +fn a_kept_layer_reads_the_project_again() { + let mut f = fx(); + let first = "thisComp.layer(\"B\").index * 10 + thisComp.layer(2).name.length"; + assert_close!(f.num(first, 0.0), 21.0); + f.layer_mut(f.b).name = "Bee".into(); + f.p.comp_mut(f.cid).unwrap().layers.swap(0, 1); + assert_close!(f.num("thisComp.layer(\"Bee\").index * 10 + thisComp.layer(2).name.length", 0.0), 11.0); + assert_close!(f.num("thisLayer.index", 0.0), 2.0); + let _ = f.ev(f.a, ROT, "thisLayer.mine = 5; 0", 0.0); + assert_close!(f.num("thisLayer.mine === undefined ? 1 : 0", 0.0), 1.0); + let _ = f.ev(f.a, ROT, "var L = thisLayer; L.__c = 'x'; L.__l = 99; L.__i = ['Z', 7]; L.__s = __S; 0", 0.0); + assert_close!(f.num("thisLayer.index * 10 + thisLayer.name.length", 0.0), 21.0); + let _ = f.ev(f.a, ROT, "var L = thisLayer; delete L.__c; delete L.__l; delete L.__i; delete L.__s; 0", 0.0); + assert_close!(f.num("thisLayer.index * 10 + thisLayer.name.length", 0.0), 21.0); + let _ = f.ev(f.a, ROT, "Object.freeze(thisLayer); 0", 0.0); + f.layer_mut(f.a).name = "Ay".into(); + f.p.comp_mut(f.cid).unwrap().layers.swap(0, 1); + assert_close!(f.num("thisLayer.index * 10 + thisLayer.name.length", 0.0), 12.0); +} + +/// The kept layers are bounded: past the bound a layer isn't kept, an evaluation that makes more +/// layers than are kept still sees one object for each layer it had kept, and the evaluations +/// after it, which start over, read the right layers. +#[test] +fn the_kept_layers_are_bounded() { + let f = fx(); + let same = "(thisLayer === thisComp.layer(thisLayer.index) ? 1 : 0)"; + let many = + format!("var c = thisLayer.__c;\nfor (var i = 0; i < 5000; i++) __layer(c, 100000 + i);\n{same} * 10 + (__layer(c, 1e6) === __layer(c, 1e6) ? 0 : 1)"); + assert_close!(f.num(&many, 0.0), 11.0); + assert_close!(f.num(&format!("{same} * 100 + thisLayer.index * 10 + thisLayer.name.length"), 0.0), 111.0); + assert_close!(f.num(&format!("{same} * 100 + thisLayer.index * 10 + thisLayer.name.length"), 0.0), 111.0); +} + +/// A change to `Object.prototype` doesn't reach the kept layers: a frozen object put there under +/// the comp's id, before any of its layers is kept, doesn't stop them being kept. +#[test] +fn the_kept_layers_dont_go_through_object_prototype() { + let f = fx(); + let _ = f.ev(f.a, ROT, "try { Object.prototype[thisComp.__c] = Object.freeze({}); } catch (e) {} 0", 0.0); + assert_close!(f.num("thisLayer === thisComp.layer(thisLayer.index) ? 1 : 0", 0.0), 1.0); +} + +/// The prelude is kept from one evaluation to the next, so an expression can't change its object +/// model or internals for the ones after it. +#[test] +fn an_expression_cant_change_the_prelude_for_the_next() { + let f = fx(); + let read = "[thisComp.layer(1).index, thisLayer.index, thisLayer('transform').property('rotation').value, thisLayer.transform.opacity.value, thisProject.bitsPerChannel, comp(thisComp.name).numLayers, width]"; + let before = f.ev(f.a, ROT, read, 0.5).unwrap(); + let attacks = [ + "Comp.prototype.layer = function () { return 0; }", + "Object.getPrototypeOf(thisLayer).index = 0", + "Object.getPrototypeOf(thisLayer('transform')).property = function () { return 0; }", + "Object.getPrototypeOf(thisLayer.transform.opacity).value = 0", + "Comp = function () {}", + "Prop = function () {}", + "thisProject = null", + "__h = function () { return null; }", + "__layer = function () { return null; }", + "__child = function () {}", + "delete __mkProp", + "delete thisLayer", + "delete thisComp", + "delete width", + "Object.defineProperty(globalThis, 'thisComp', { get: function () { return 0; } })", + ]; + for a in attacks { + let _ = f.ev(f.a, ROT, &format!("try {{ {a} }} catch (e) {{}} 0"), 0.0); + assert_eq!(f.ev(f.a, ROT, read, 0.5).unwrap(), before, "after `{a}`"); + } +} + mod property_group; diff --git a/crates/expr/src/tests/fuzz.rs b/crates/expr/src/tests/fuzz.rs index 1bfaf8c16..37b28c415 100644 --- a/crates/expr/src/tests/fuzz.rs +++ b/crates/expr/src/tests/fuzz.rs @@ -93,6 +93,8 @@ const SEEDS: &[&str] = &[ "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)", + "bezier(time, 0, 1, 0, 100, [0.42, 0, 0.58, 1])", + "bezier(time, [0, 0], [100, 50], [0.25, 0.1, 0.25, 1])", "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)", diff --git a/crates/expr/src/tests/property_group.rs b/crates/expr/src/tests/property_group.rs index debe5e5ac..c96045ec2 100644 --- a/crates/expr/src/tests/property_group.rs +++ b/crates/expr/src/tests/property_group.rs @@ -72,6 +72,9 @@ fn property_group_reaches_the_layer() { assert_close!(f.num("thisProperty.propertyGroup(2).name == 'A' ? 1 : 0", 0.0), 1.0); assert_close!(f.num("thisProperty.propertyGroup(2).index", 0.0), 1.0); assert_close!(f.num("thisProperty.propertyGroup(1).propertyGroup(1).name == 'A' ? 1 : 0", 0.0), 1.0); + // The layer it reaches is the same object as `thisLayer`. + assert_close!(f.num("thisLayer('transform').propertyGroup(1) === thisLayer ? 1 : 0", 0.0), 1.0); + assert_close!(f.num("thisProperty.propertyGroup(2) === thisLayer ? 1 : 0", 0.0), 1.0); let s = staggered(); assert_close!(s.shape_num(SCALE_2, "thisProperty.propertyGroup(4).name == 'Shape' ? 1 : 0"), 1.0); } diff --git a/crates/gpu/src/adv3d.rs b/crates/gpu/src/adv3d.rs index 24c5a3b35..c96e57d2d 100644 --- a/crates/gpu/src/adv3d.rs +++ b/crates/gpu/src/adv3d.rs @@ -77,7 +77,7 @@ fn buffer_bytes(count: u64, stride: u64, limit: u64) -> Option { /// Checking these before serialization bounds host packing as well as the GPU uploads. fn packed_bytes(counts: [u64; 6], uniform_limit: u64, storage_limit: u64, buffer_limit: u64) -> Option<[u64; 7]> { let mut sizes = [0; 7]; - sizes[0] = buffer_bytes(40, 4, uniform_limit.min(buffer_limit))?; + sizes[0] = buffer_bytes(44, 4, uniform_limit.min(buffer_limit))?; for (index, (count, stride)) in counts.into_iter().zip([96, 16, 16, 80, 80, 4]).enumerate() { let size = count.checked_mul(stride)?.max(16); sizes[index + 1] = buffer_bytes(size, 1, storage_limit.min(buffer_limit))?; @@ -164,11 +164,11 @@ mod capability_tests { #[test] fn packed_arrays_preflight_exact_strides_and_empty_padding() { - assert_eq!(packed_bytes([0; 6], 160, 16, 160), Some([160, 16, 16, 16, 16, 16, 16])); - assert_eq!(packed_bytes([2; 6], 160, 192, 192), Some([160, 192, 32, 32, 160, 160, 16])); - assert!(packed_bytes([0; 6], 159, 16, 160).is_none()); - assert!(packed_bytes([0; 6], 160, 15, 160).is_none()); - assert!(packed_bytes([2; 6], 160, 192, 191).is_none()); + assert_eq!(packed_bytes([0; 6], 176, 16, 176), Some([176, 16, 16, 16, 16, 16, 16])); + assert_eq!(packed_bytes([2; 6], 176, 192, 192), Some([176, 192, 32, 32, 160, 160, 16])); + assert!(packed_bytes([0; 6], 175, 16, 176).is_none()); + assert!(packed_bytes([0; 6], 176, 15, 176).is_none()); + assert!(packed_bytes([2; 6], 176, 192, 191).is_none()); for index in 0..6 { let mut counts = [0; 6]; counts[index] = u64::MAX; @@ -203,7 +203,7 @@ mod capability_tests { }; for (scene, counts) in [(Scene::default(), [0; 6]), (scene, [1; 6])] { let actual = pack(&scene).map(|bytes| bytes.len() as u64); - assert_eq!(Some(actual), packed_bytes(counts, 160, 160, 160)); + assert_eq!(Some(actual), packed_bytes(counts, 176, 176, 176)); } } } @@ -315,10 +315,25 @@ fn pack(s: &Scene) -> [Vec; 7] { globals.extend([s.eye[0], s.eye[1], s.eye[2], s.ortho as u32 as f32]); globals.extend([s.cam_fwd[0], s.cam_fwd[1], s.cam_fwd[2], s.lights.len() as f32]); globals.extend([s.ambient[0], s.ambient[1], s.ambient[2], s.env.is_some() as u32 as f32]); - let e = s.env.unwrap_or(effectcraft_render::three_d::adv::EnvInfo { radiance: 0, mips: 1, irradiance: 0, intensity: 0.0, rotation: 0.0 }); + let e = s.env.unwrap_or(effectcraft_render::three_d::adv::EnvInfo { + radiance: 0, + mips: 1, + irradiance: 0, + intensity: 0.0, + rotation: 0.0, + sun: [0.0; 3], + key: 0.0, + shadow: -1, + shadow_darkness: 0.0, + shadow_diffusion: 0.0, + }); globals.extend([e.radiance as f32, e.mips as f32, e.irradiance as f32, e.intensity]); let lit = !(s.lights.is_empty() && s.env.is_none() && s.ambient == [0.0; 3]); - globals.extend([e.rotation, lit as u32 as f32, 0.0, 0.0]); + // env2: rotation, any lighting (0/1), shadow map index (-1 none), shadow darkness. + globals.extend([e.rotation, lit as u32 as f32, e.shadow as f32, e.shadow_darkness]); + // The environment's dominant direction (its shadow's source), for the shadow bias slope, + // and its key fraction — how much of its light the shadow can remove (shade::env_shadow). + globals.extend([e.sun[0], e.sun[1], e.sun[2], e.key]); let mats = s.materials.iter().flat_map(|m| { let flags = (m.double_sided as u32) | (m.unlit as u32) << 1 diff --git a/crates/gpu/src/bokeh.rs b/crates/gpu/src/bokeh.rs index 39dc0bdea..a3f05bf12 100644 --- a/crates/gpu/src/bokeh.rs +++ b/crates/gpu/src/bokeh.rs @@ -10,8 +10,9 @@ use crate::context::{Enc, GpuImage, Params}; /// Blur `src` (an unpadded plane buffer) as `d` says. Returns the padded, blurred image. pub(crate) fn apply(e: &mut Enc, src: &GpuImage, d: &PlaneDof) -> Option { - let (w, h) = (src.width + 2 * d.pad, src.height + 2 * d.pad); - if !e.g.fits(w + 1, h) || d.radius.len() != w as usize * h as usize || d.levels.is_empty() { + // Saturating, so an oversized padding is refused by `fits` below instead of wrapping around. + let (w, h) = (src.width.saturating_add(d.pad.saturating_mul(2)), src.height.saturating_add(d.pad.saturating_mul(2))); + if !e.g.fits(w.saturating_add(1), h) || d.radius.len() != w as usize * h as usize || d.levels.is_empty() { return None; } let padded = e.image(w, h); diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index 1e97b7eab..5a2b80f1c 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -22,6 +22,20 @@ pub(crate) struct GBuf { } impl GBuf { + /// [`effectcraft_effects::Buf::cover`] on the device; `None` when it can't hold it. + pub fn cover(&mut self, e: &mut Enc, size: [f64; 2]) -> Option<()> { + let Some((dx, dy, w, h)) = effectcraft_effects::cover_plan(self.offset, self.scale, size, self.img.width, self.img.height) else { return Some(()) }; + if !e.g.fits(w, h) { + return None; + } + let out = e.image(w, h); + e.copy_into(&self.img, &out, dx, dy); + self.img = out; + self.offset[0] += dx as f64; + self.offset[1] += dy as f64; + Some(()) + } + pub fn to_px(&self, p: [f64; 2]) -> (f64, f64) { (p[0] * self.scale + self.offset[0], p[1] * self.scale + self.offset[1]) } @@ -46,6 +60,7 @@ impl GBuf { /// Buf::pad: grow by `pad` transparent pixels on every side. pub fn pad(&mut self, e: &mut Enc, pad: u32) -> Option<()> { + let pad = effectcraft_effects::pad_within_budget(self.img.width, self.img.height, pad); if pad == 0 { return Some(()); } @@ -166,12 +181,23 @@ impl effectcraft_render::FxTarget for GpuFx<'_, '_> { /// GBuf::cover, like `pad`: a bigger texture with the old one copied into place, no /// readback. A region the device can't allocate leaves the buffer as it is. fn cover(&mut self, size: [f64; 2]) { + let Some(mut b) = self.b.take() else { return }; + let keep = (b.img.clone(), b.offset); + if b.cover(self.e, size).is_none() { + (b.img, b.offset) = keep; + } + self.b = Some(b); + } + + /// [`effectcraft_effects::Buf::align`] on the device: a texture grown left/top, the old one + /// copied in. A size the device can't allocate leaves the buffer as it is. + fn align(&mut self, size: [f64; 2]) { let Some(b) = self.b.take() else { return }; - let out = match effectcraft_effects::cover_plan(b.offset, b.scale, size, b.img.width, b.img.height) { - Some((dx, dy, w, h)) if self.e.g.fits(w, h) => { - let img = self.e.image(w, h); - self.e.copy_into(&b.img, &img, dx, dy); - GBuf { img, offset: [b.offset[0] + dx as f64, b.offset[1] + dy as f64], scale: b.scale } + let out = match effectcraft_effects::align_plan(b.offset, b.scale, size, b.img.width, b.img.height) { + Some((l, t)) if self.e.g.fits(b.img.width + l, b.img.height + t) => { + let img = self.e.image(b.img.width + l, b.img.height + t); + self.e.copy_into(&b.img, &img, l, t); + GBuf { img, offset: [b.offset[0] + l as f64, b.offset[1] + t as f64], scale: b.scale } } _ => b, }; @@ -291,6 +317,9 @@ fn gaussian(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { let repeat = ctx.params.b("repeatEdge"); if !repeat && !ctx.adjustment { b.pad(e, (s * 3.0).ceil() as u32)?; + } else if repeat && !ctx.adjustment { + // The edge it repeats is the layer's, not the painted content's (#1205). + b.cover(e, ctx.layer_size)?; } b.img = gaussian_blur(e, &b.img, s * kx, s * ky, repeat || ctx.adjustment); Some(b) @@ -311,6 +340,9 @@ fn box_blur(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { let repeat = ctx.params.b("repeatEdge"); if !repeat && !ctx.adjustment { b.pad(e, (r * it) as u32 + 1)?; + } else if repeat && !ctx.adjustment { + // The edge it repeats is the layer's, not the painted content's (#1205). + b.cover(e, ctx.layer_size)?; } b.img = box_passes(e, &b.img, &vec![r * kx as usize; it], &vec![r * ky as usize; it], repeat || ctx.adjustment); Some(b) diff --git a/crates/gpu/src/fx_distort.rs b/crates/gpu/src/fx_distort.rs index ce7133822..510f91e8c 100644 --- a/crates/gpu/src/fx_distort.rs +++ b/crates/gpu/src/fx_distort.rs @@ -180,6 +180,9 @@ fn turbulent(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { let size = (ctx.params.f("size") * b.scale).max(1.0); if ctx.params.b("resizeLayer") && !ctx.adjustment { b.pad(e, amount.abs().ceil() as u32 + 1)?; + } else { + // Room for pixels displaced past the content, up to the layer's edge (#1205). + b.pad(e, effectcraft_effects::cover_pad(ctx, [b.img.width, b.img.height], b.offset, b.scale, amount.abs().ceil() as u32 + 1))?; } let off = b.to_px(ctx.params.v2("offset")); let oct = ctx.params.f("complexity").clamp(1.0, 10.0); diff --git a/crates/gpu/src/shaders/advanced3d.wgsl b/crates/gpu/src/shaders/advanced3d.wgsl index 182868423..8ac143ad0 100644 --- a/crates/gpu/src/shaders/advanced3d.wgsl +++ b/crates/gpu/src/shaders/advanced3d.wgsl @@ -14,8 +14,10 @@ struct Globals { ambient: vec4, // radiance first texture, mip count, irradiance texture, intensity env: vec4, - // rotation, any lighting (0/1), -, - + // rotation, any lighting (0/1), shadow map index (-1 none), shadow darkness env2: vec4, + // the environment's dominant direction (its shadow's source) + env_sun: vec4, }; @group(0) @binding(0) var g: Globals; @@ -339,6 +341,18 @@ fn light_shadow(li: u32, kind: u32, lpos: vec3, shadow: i32, darkness: f32, return 1.0 - darkness * (1.0 - f); } +// The Environment light's shadow from its dominant direction (shade::env_shadow). Only the +// image's key fraction (g.env_sun.w) of the light is occluded — the fill keeps shining — so +// the shadow stays partial the way After Effects renders an area-light shadow. +fn env_shadow(p: vec3, ng: vec3, n: vec3) -> f32 { + if (g.env2.z < 0.0) { + return 1.0; + } + let n_l = clamp(dot(n, g.env_sun.xyz), 0.0, 1.0); + let f = shadow_factor(u32(g.env2.z + 0.5) * SHADOW_STRIDE, p, ng, n_l); + return 1.0 - g.env2.w * g.env_sun.w * (1.0 - f); +} + fn attenuation(kind: u32, falloff: u32, radius: f32, falloff_distance: f32, cos_inner: f32, cos_outer: f32, dir: vec3, to_l: vec3, dist: f32) -> f32 { var k = 1.0; if (falloff == 1u && kind != 0u) { @@ -425,6 +439,10 @@ fn shadow_only_amount(unlit: bool, normal: vec3, front: bool, pos: vec3 0.5) { + amount = max(amount, 1.0 - env_shadow(pos, n, n)); + } return amount; } @@ -564,7 +582,11 @@ fn fs(i: VOut, @builtin(front_facing) front: bool) -> FOut { let f0 = mix(vec3(0.04 * spec_k), rgb, metallic); let ab = env_brdf(n_v, rough); let fs = f0 * ab.x + vec3(ab.y); - acc = acc + (irr * rgb * (vec3(1.0) - fs) * (1.0 - metallic) * diff_k + spec * fs) * ao; + var esh = 1.0; + if (receives_shadows) { + esh = env_shadow(i.pos, ng, n); + } + acc = acc + (irr * rgb * (vec3(1.0) - fs) * (1.0 - metallic) * diff_k + spec * fs) * ao * esh; } let c = acc + emissive; out.color = vec4(c * alpha, alpha); diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index 0aaf38af0..c57c1ef84 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -498,7 +498,7 @@ fn layer_styles_collapsed_precomps_and_cpu_fallbacks() { b.blend_mode = BlendMode::Difference; (a, b) }; - s.p.comp_mut(inner_id).unwrap().layers = vec![b, a]; + s.p.comp_mut(inner_id).unwrap().layers = vec![b, a].into(); let bg = s.footage(97, 61); s.push(bg); let mut pre = build::layer(&mut s.p, &s.comp, "Inner", LayerSource::Comp { item: inner_id }, (60, 40), None); diff --git a/crates/gpu/src/tests/adjust.rs b/crates/gpu/src/tests/adjust.rs index 5635e08e1..39438d43e 100644 --- a/crates/gpu/src/tests/adjust.rs +++ b/crates/gpu/src/tests/adjust.rs @@ -83,7 +83,7 @@ fn track_mattes_limit_gpu_adjustments_like_the_cpu() { set(&mut matte, "transform/rotation", Value::Scalar(10.0)); let mid = s.push(matte); let c = s.p.comp_mut(s.cid).unwrap(); - let adj = c.layers.iter_mut().find(|l| l.switches.adjustment).unwrap(); + let adj = c.layers.find_mut(|l| l.switches.adjustment).unwrap(); adj.track_matte = Some(TrackMatte { layer: mid, kind }); check(&format!("matted adjustment {kind:?} {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.0); } diff --git a/crates/gpu/src/tests/adv3d.rs b/crates/gpu/src/tests/adv3d.rs index 21422aa6b..00b1fee14 100644 --- a/crates/gpu/src/tests/adv3d.rs +++ b/crates/gpu/src/tests/adv3d.rs @@ -5,7 +5,7 @@ //! edges may be covered by the other neighbour. Interior pixels agree within 2/255; at most //! 1.5 % of pixels (silhouettes) may differ more. Skipped without an adapter. -use effectcraft_keyframe::Value; +use effectcraft_keyframe::{Gradient, Value}; use effectcraft_project::build; use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, LightKind, PrimitiveKind, Project, Renderer as R3, Solid}; use effectcraft_render::three_d::adv::{self, Scene}; @@ -38,7 +38,8 @@ fn set(l: &mut effectcraft_project::Layer, path: &str, v: Value) { } /// Primitives with metal/dielectric materials, a shadow-casting spot light and a point light, -/// an environment light, a semi-transparent textured card. +/// a shadow-casting Environment light over a sky-gradient environment layer, a semi-transparent +/// textured card. pub(crate) fn scene_project() -> (Project, ItemId) { let mut p = Project::default(); let mut c = Comp::new(240, 160, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); @@ -73,12 +74,29 @@ pub(crate) fn scene_project() -> (Project, ItemId) { set(l, "transform/position", Value::Vec3([120.0, 80.0, -60.0])); set(l, "transform/rotationY", Value::Scalar(30.0)); }); - let env = p.add_item("E", Default::default(), None, ItemKind::Solid(Solid { color: [0.6, 0.7, 1.0], width: 32, height: 16, pixel_aspect: 1.0 })); - add(&mut p, cid, LayerSource::Solid { item: env }, |l| { - l.environment = true; + // The Environment Layer: a sky-to-ground gradient (its dominant region, the bright sky, + // shadows the scene from above). + add(&mut p, cid, LayerSource::Shape, |l| { l.switches.three_d = true; + let mut next = 10_000u64; + let ids = &mut build::Ids(&mut next); + let rect = build::shape_rect(ids, [240.0, 160.0], [0.0, 0.0], 0.0); + let gfill = build::shape_gradient_fill( + ids, + false, + [0.0, -80.0], + [0.0, 80.0], + Gradient { colors: vec![(0.0, [1.0, 1.0, 1.0, 1.0]), (1.0, [0.04, 0.05, 0.08, 1.0])], opacities: vec![(0.0, 1.0), (1.0, 1.0)] }, + ); + let g = build::shape_group(ids, "Sky", vec![rect, gfill]); + l.props.sub_mut("contents").unwrap().children.push(g.into()); + l.environment = true; + }); + add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| { + set(l, "lightOptions/intensity", Value::Scalar(50.0)); + set(l, "lightOptions/castsShadows", Value::Bool(true)); + set(l, "lightOptions/shadowDiffusion", Value::Scalar(6.0)); }); - add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| set(l, "lightOptions/intensity", Value::Scalar(50.0))); add(&mut p, cid, LayerSource::Light { kind: LightKind::Spot }, |l| { set(l, "transform/position", Value::Vec3([40.0, -120.0, -200.0])); set(l, "transform/poi", Value::Vec3([120.0, 100.0, 0.0])); @@ -96,7 +114,16 @@ pub(crate) fn scene_project() -> (Project, ItemId) { pub(crate) fn scene(p: &Project, cid: ItemId) -> Scene { let r = Renderer::new(p, &NoFootage, RenderOpts::default()); - let ctx = EvalCtx { project: p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None }; + let ctx = EvalCtx { + project: p, + comp_id: cid, + comp: p.comp(cid).unwrap(), + time: Tick::ZERO, + expr: None, + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let run: Vec<&effectcraft_project::Layer> = ctx.comp.layers.iter().rev().filter(|l| l.is_3d() && l.has_video()).collect(); adv::scene_of(&r, &ctx, &run, (ctx.comp.width, ctx.comp.height)) } @@ -135,6 +162,8 @@ fn gpu_matches_cpu_lit_scene() { let (p, cid) = scene_project(); let s = scene(&p, cid); assert!(!s.shadows.is_empty() && s.env.is_some() && s.opaque_count < s.indices.len() as u32); + // The Environment light casts its shadow (the sky gradient's dominant direction). + assert!(s.env.is_some_and(|e| e.shadow >= 0 && e.sun != [0.0; 3]), "the environment shadow map is built"); compare(&s, &g); } @@ -181,7 +210,7 @@ fn gpu_matches_cpu_wide_penumbrae() { set(&mut comp.layers[0], "lightOptions/shadowDiffusion", Value::Scalar(30.0)); let s = scene(&p, cid); let (cpu, gt) = (adv::raster::render(&s), g.raster_3d(&s).expect("gpu raster")); - for l in &mut p.comp_mut(cid).unwrap().layers[..2] { + for l in p.comp_mut(cid).unwrap().layers.iter_mut().take(2) { set(l, "lightOptions/castsShadows", Value::Bool(false)); } let unshadowed = adv::raster::render(&scene(&p, cid)); @@ -251,7 +280,7 @@ pub(crate) fn dof_mb_project() -> (Project, ItemId) { c.enable_motion_blur = true; c.motion_blur_samples = 6; // The sphere moves 40 px per frame. - let sphere = c.layers.iter_mut().find(|l| matches!(l.source, LayerSource::Primitive { kind: PrimitiveKind::Sphere })).unwrap(); + let sphere = c.layers.find_mut(|l| matches!(l.source, LayerSource::Primitive { kind: PrimitiveKind::Sphere })).unwrap(); sphere.switches.motion_blur = true; sphere.props.prop_mut("transform/position").unwrap().keys = vec![ effectcraft_keyframe::Keyframe::new(Tick::ZERO, Value::Vec3([80.0, 95.0, 0.0])), @@ -277,7 +306,16 @@ pub(crate) fn off_share(a: &[[f32; 4]], b: &[[f32; 4]]) -> (f64, f32) { } pub(crate) fn run_of(p: &Project, cid: ItemId) -> (EvalCtx<'_>, Vec<&effectcraft_project::Layer>) { - let ctx = EvalCtx { project: p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None }; + let ctx = EvalCtx { + project: p, + comp_id: cid, + comp: p.comp(cid).unwrap(), + time: Tick::ZERO, + expr: None, + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let run: Vec<&effectcraft_project::Layer> = ctx.comp.layers.iter().rev().filter(|l| l.is_3d() && l.has_video()).collect(); (ctx, run) } @@ -411,7 +449,7 @@ fn gpu_compositor_draws_advanced_3d_runs_on_the_device() { eprintln!("walk: {:.3} % beyond 1/255 (max {max:.4})", share * 100.0); assert!(share <= 0.01, "{:.3} % of pixels differ", share * 100.0); // A blend mode in the run takes the 2D compositing path (`SplitRun`, also on the GPU). - p.comp_mut(cid).unwrap().layers.iter_mut().find(|l| l.switches.three_d && !l.is_light() && l.source.is_av()).unwrap().blend_mode = + p.comp_mut(cid).unwrap().layers.find_mut(|l| l.switches.three_d && !l.is_light() && l.source.is_av()).unwrap().blend_mode = effectcraft_color::BlendMode::Screen; let (ctx, run) = run_of(&p, cid); assert!(Renderer::new(&p, &NoFootage, RenderOpts::default()).prepare_adv_run(&ctx, &run, (240, 160)).is_none()); @@ -532,12 +570,12 @@ fn gpu_compositor_draws_split_advanced_3d_runs_and_skies() { }); } let c = p.comp_mut(cid).unwrap(); - let env = c.layers.iter_mut().find(|l| l.environment).unwrap(); + let env = c.layers.find_mut(|l| l.environment).unwrap(); env.props.sub_mut("effects").unwrap().children.push(ramp.into()); let ids: Vec<_> = c.layers.iter().filter(|l| l.switches.three_d && !l.is_light() && !l.environment && l.source.is_av()).map(|l| l.id).collect(); let top = c.layers[0].id; fn by_id(c: &mut Comp, id: effectcraft_project::LayerId) -> &mut effectcraft_project::Layer { - c.layers.iter_mut().find(|l| l.id == id).unwrap() + c.layers.find_mut(|l| l.id == id).unwrap() } match case { 0 => by_id(c, ids[1]).blend_mode = effectcraft_color::BlendMode::Screen, @@ -555,7 +593,7 @@ fn gpu_compositor_draws_split_advanced_3d_runs_and_skies() { } _ => { // An environment background behind the run, and the 2D track matte. - c.layers.iter_mut().find(|l| l.environment).unwrap().environment_background = true; + c.layers.find_mut(|l| l.environment).unwrap().environment_background = true; by_id(c, ids[0]).track_matte = Some(effectcraft_project::TrackMatte { layer: top, kind: effectcraft_project::MatteKind::Alpha }); } } diff --git a/crates/gpu/src/tests/fx_noise.rs b/crates/gpu/src/tests/fx_noise.rs index 38a30aca5..7d13004ee 100644 --- a/crates/gpu/src/tests/fx_noise.rs +++ b/crates/gpu/src/tests/fx_noise.rs @@ -208,7 +208,7 @@ fn echo_and_posterize_time() { pos.keys = vec![Keyframe::new(Tick::ZERO, crate::tests::v3(10.0, 12.0)), Keyframe::new(Tick::from_seconds_f64(1.0), crate::tests::v3(50.0, 30.0))]; let rot = a.props.prop_mut("transform/rotation").unwrap(); rot.keys = vec![Keyframe::new(Tick::ZERO, Value::Scalar(0.0)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(90.0))]; - s.p.comp_mut(inner_id).unwrap().layers = vec![a]; + s.p.comp_mut(inner_id).unwrap().layers = vec![a].into(); let mut l = build::layer(&mut s.p, &s.comp, "Inner", LayerSource::Comp { item: inner_id }, (60, 40), None); s.effect(&mut l, id, &vals); set(&mut l, "transform/rotation", Value::Scalar(8.0)); diff --git a/crates/gpu/src/tests/fx_time.rs b/crates/gpu/src/tests/fx_time.rs index 0c94bb413..c71355736 100644 --- a/crates/gpu/src/tests/fx_time.rs +++ b/crates/gpu/src/tests/fx_time.rs @@ -40,7 +40,7 @@ fn moving(id: &str, vals: &[(&str, Value)], map: Option<&str>) { pos.keys = vec![Keyframe::new(Tick::ZERO, v3(10.0, 12.0)), Keyframe::new(Tick::from_seconds_f64(1.0), v3(50.0, 30.0))]; let rot = a.props.prop_mut("transform/rotation").unwrap(); rot.keys = vec![Keyframe::new(Tick::ZERO, Value::Scalar(0.0)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(90.0))]; - s.p.comp_mut(inner_id).unwrap().layers = vec![a]; + s.p.comp_mut(inner_id).unwrap().layers = vec![a].into(); let mut l = build::layer(&mut s.p, &s.comp, "Inner", LayerSource::Comp { item: inner_id }, (60, 40), None); s.effect(&mut l, id, &vals); set(&mut l, "transform/rotation", Value::Scalar(8.0)); diff --git a/crates/gpu/src/tests/fx_tone.rs b/crates/gpu/src/tests/fx_tone.rs index 08212a12a..40bc9483f 100644 --- a/crates/gpu/src/tests/fx_tone.rs +++ b/crates/gpu/src/tests/fx_tone.rs @@ -164,7 +164,7 @@ fn color_stabilizer() { pos.keys = vec![Keyframe::new(Tick::ZERO, crate::tests::v3(22.0, 18.0)), Keyframe::new(Tick::from_seconds_f64(1.0), crate::tests::v3(38.0, 24.0))]; let op = a.props.prop_mut("transform/opacity").unwrap(); op.keys = vec![Keyframe::new(Tick::ZERO, Value::Scalar(100.0)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(40.0))]; - s.p.comp_mut(inner_id).unwrap().layers = vec![a]; + s.p.comp_mut(inner_id).unwrap().layers = vec![a].into(); let mut l = build::layer(&mut s.p, &s.comp, "Inner", LayerSource::Comp { item: inner_id }, (60, 40), None); let vals = [ ("stabilize", e(mode)), diff --git a/crates/gpu/src/tests/three_d.rs b/crates/gpu/src/tests/three_d.rs index a9c2e85e2..acdbea7b1 100644 --- a/crates/gpu/src/tests/three_d.rs +++ b/crates/gpu/src/tests/three_d.rs @@ -177,7 +177,7 @@ fn mattes_preserve_transparency_precomps_and_adjustment_splits() { let mid = s.push(m); let layers = &mut s.p.comp_mut(s.cid).unwrap().layers; layers[1].track_matte = Some(TrackMatte { layer: mid, kind: MatteKind::Luma }); - if let Some(far) = layers.iter_mut().find(|l| l.switches.three_d && l.name == "Pattern") { + if let Some(far) = layers.find_mut(|l| l.switches.three_d && l.name == "Pattern") { far.preserve_transparency = true; } // Move the matte below the bottom so the 3D run stays one run. @@ -203,7 +203,7 @@ fn mattes_preserve_transparency_precomps_and_adjustment_splits() { a.switches.three_d = true; set(&mut a, "transform/rotationX", Value::Scalar(40.0)); let b = s.solid([0.2, 0.8, 0.4], 30, 20); - s.p.comp_mut(inner_id).unwrap().layers = vec![b, a]; + s.p.comp_mut(inner_id).unwrap().layers = vec![b, a].into(); let mut pre = build::layer(&mut s.p, &s.comp, "Inner", LayerSource::Comp { item: inner_id }, (80, 60), None); pre.switches.collapse = true; pre.switches.three_d = true; @@ -299,7 +299,7 @@ fn two_d_layer_with_a_three_d_track_matte() { let layers = &mut s.p.comp_mut(s.cid).unwrap().layers; let fi = layers.iter().position(|l| l.id == fid).unwrap(); layers[fi].track_matte = Some(TrackMatte { layer: mid, kind }); - if let Some(ml) = layers.iter_mut().find(|l| l.id == mid) { + if let Some(ml) = layers.find_mut(|l| l.id == mid) { ml.switches.video = false; } check(&format!("3d matte {kind:?} {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.005); diff --git a/crates/gpu/src/walk.rs b/crates/gpu/src/walk.rs index 78a6857fb..d4d7a62a1 100644 --- a/crates/gpu/src/walk.rs +++ b/crates/gpu/src/walk.rs @@ -89,7 +89,7 @@ fn draw_comp<'a>(e: &mut Enc, r: &Renderer<'a>, ctx: &EvalCtx<'a>, canvas: &mut i = j; } else { match r.collapsed(ctx, visible[i]) { - Some(item) => draw_collapsed(e, r, ctx, visible[i], item, canvas)?, + Some(collapsed) => draw_collapsed(e, r, ctx, visible[i], &collapsed, canvas)?, None => draw_layer(e, r, ctx, visible[i], canvas)?, } i += 1; @@ -187,19 +187,26 @@ fn draw_split(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, split: &effectcraft_rend Some(cv) } -fn draw_collapsed<'a>(e: &mut Enc, r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, item: ItemId, canvas: &mut GpuImage) -> Option<()> { +fn draw_collapsed<'a>( + e: &mut Enc, + r: &Renderer<'a>, + ctx: &EvalCtx<'a>, + layer: &'a Layer, + collapsed: &effectcraft_render::Collapsed, + canvas: &mut GpuImage, +) -> Option<()> { let opacity = r.layer_opacity(ctx, layer); if opacity <= 0.0 { return Some(()); } let matte = r.track_matte(ctx, layer).is_some(); if layer.blend_mode == BlendMode::Normal && !matte && !layer.preserve_transparency { - if let Some((sub, nctx)) = r.collapse_into(ctx, layer, item, opacity) { + if let Some((sub, nctx)) = r.collapse_into(ctx, layer, collapsed, opacity) { draw_comp(e, &sub, &nctx, canvas)?; } return Some(()); } - let Some((sub, nctx)) = r.collapse_into(ctx, layer, item, 1.0) else { return Some(()) }; + let Some((sub, nctx)) = r.collapse_into(ctx, layer, collapsed, 1.0) else { return Some(()) }; let mut iso = e.zeros(canvas.width, canvas.height); draw_comp(e, &sub, &nctx, &mut iso)?; composite_iso(e, r, ctx, layer, iso, canvas, opacity) diff --git a/crates/host/src/tests.rs b/crates/host/src/tests.rs index a73f15937..3114c2ddb 100644 --- a/crates/host/src/tests.rs +++ b/crates/host/src/tests.rs @@ -269,7 +269,16 @@ fn value(s: &effectcraft_engine::Session, l: u64, path: &str) -> effectcraft_eng let cid = s.active_comp_id().unwrap(); let comp = s.project.comp(cid).unwrap(); let layer = comp.layer(effectcraft_engine::project::LayerId(l)).unwrap(); - let ctx = effectcraft_engine::render::EvalCtx { project: &s.project, comp_id: cid, comp, time: Default::default(), expr: s.expr.as_deref(), footage: None }; + let ctx = effectcraft_engine::render::EvalCtx { + project: &s.project, + comp_id: cid, + comp, + time: Default::default(), + expr: s.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; ctx.value(layer, layer.props.prop(path).unwrap()) } @@ -523,8 +532,16 @@ fn stereo_rig_expressions_follow_the_master_camera() { let v3 = |s: &effectcraft_engine::Session, path: &str| { let comp = s.project.comp(left).unwrap(); let layer = &comp.layers[0]; - let ctx = - effectcraft_engine::render::EvalCtx { project: &s.project, comp_id: left, comp, time: Default::default(), expr: s.expr.as_deref(), footage: None }; + let ctx = effectcraft_engine::render::EvalCtx { + project: &s.project, + comp_id: left, + comp, + time: Default::default(), + expr: s.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; ctx.value(layer, layer.props.prop(path).unwrap()).as_vec3() }; let close = |a: [f64; 3], b: [f64; 3]| (0..3).all(|i| (a[i] - b[i]).abs() < 1e-6); diff --git a/crates/host/tests/it/main.rs b/crates/host/tests/it/main.rs index 1e60e5ab7..826363edb 100644 --- a/crates/host/tests/it/main.rs +++ b/crates/host/tests/it/main.rs @@ -12,4 +12,5 @@ mod relink; mod tga_import; mod timeline_interop; mod vector_import; +mod wav_markers; mod webm_metadata; diff --git a/crates/host/tests/it/vector_import.rs b/crates/host/tests/it/vector_import.rs index 36d83dd1a..badd60b66 100644 --- a/crates/host/tests/it/vector_import.rs +++ b/crates/host/tests/it/vector_import.rs @@ -509,7 +509,7 @@ fn shapes_from_vector_layer_keeps_images_as_footage_layers() { assert_eq!(names, ["images.pdf Outlines", "Image", "Image", "images.pdf Outlines 1", "images.pdf"]); assert_eq!(c.layers[0].id.0, sid); assert_eq!(r["layers"].as_array().unwrap().len(), 2); - for l in &c.layers[1..3] { + for l in c.layers.iter().skip(1).take(2) { assert_eq!(l.parent.map(|p| p.0), Some(sid), "{}", l.name); assert!(matches!(l.source, effectcraft_project::LayerSource::Footage { .. })); } diff --git a/crates/host/tests/it/wav_markers.rs b/crates/host/tests/it/wav_markers.rs new file mode 100644 index 000000000..98d2685dc --- /dev/null +++ b/crates/host/tests/it/wav_markers.rs @@ -0,0 +1,126 @@ +//! Markers a WAV file carries, through the fully wired session (media import included): the +//! layer made from the file starts with them, and Update Markers From Source copies them again, +//! through its bound `layers` parameter as well as the selection, as one undo step. + +use effectcraft_engine::Session; +use effectcraft_project::LayerSource; +use serde_json::json; + +/// The comments of a layer's markers, in time order. +fn names(s: &mut Session, layer: u64) -> Vec { + let list = s.execute_checked("markers.list", json!({"layer": layer})).unwrap(); + list.as_array().unwrap().iter().map(|m| m["comment"].as_str().unwrap().to_string()).collect() +} + +/// A one-second mono 8 kHz WAV with the named cue points `(id, sample, name)` (a `cue ` chunk and a +/// `LIST` `adtl`). +fn marker_wav(points: &[(u32, u32, &str)]) -> Vec { + fn chunk(id: &[u8; 4], body: &[u8]) -> Vec { + let mut v = id.to_vec(); + v.extend((body.len() as u32).to_le_bytes()); + v.extend(body); + if body.len() % 2 == 1 { + v.push(0); + } + v + } + let mut fmt = Vec::new(); + fmt.extend(1u16.to_le_bytes()); + fmt.extend(1u16.to_le_bytes()); + fmt.extend(8000u32.to_le_bytes()); + fmt.extend(16000u32.to_le_bytes()); + fmt.extend(2u16.to_le_bytes()); + fmt.extend(16u16.to_le_bytes()); + let mut cue = (points.len() as u32).to_le_bytes().to_vec(); + let mut adtl = b"adtl".to_vec(); + for &(id, at, name) in points { + for v in [id, 0] { + cue.extend(v.to_le_bytes()); + } + cue.extend(b"data"); + for v in [0, 0, at] { + cue.extend(u32::to_le_bytes(v)); + } + let mut labl = id.to_le_bytes().to_vec(); + labl.extend(name.as_bytes()); + labl.push(0); + adtl.extend(chunk(b"labl", &labl)); + } + let mut body = b"WAVE".to_vec(); + for c in [chunk(b"fmt ", &fmt), chunk(b"data", &[0; 16000]), chunk(b"cue ", &cue), chunk(b"LIST", &adtl)] { + body.extend(c); + } + let mut file = b"RIFF".to_vec(); + file.extend((body.len() as u32).to_le_bytes()); + file.extend(body); + file +} + +/// A WAV's cue points become markers on the layer made from it, named as in the file, and +/// Update Markers From Source brings back one that was deleted (through the fully wired session, +/// media import included). +#[test] +fn a_wav_layer_starts_with_the_markers_in_its_file() { + let mut s = effectcraft_host::session(); + s.execute_checked("comp.new", json!({"name": "C", "width": 64, "height": 48, "frameRate": 30, "duration": 2})).unwrap(); + let dir = std::env::temp_dir().join(format!("effectcraft-host-wav-markers-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("cues.wav"); + std::fs::write(&path, marker_wav(&[(1, 0, "Intro"), (2, 4000, "Middle")])).unwrap(); + let r = s.execute_checked("file.import", json!({"paths": [path.to_string_lossy()], "addToComp": true})).unwrap(); + assert_eq!(r["errors"], json!([]), "{r}"); + let layer = s.active_comp().unwrap().layers.iter().find(|l| matches!(l.source, LayerSource::Footage { .. })).unwrap().id.0; + let list = s.execute_checked("markers.list", json!({"layer": layer})).unwrap(); + let marks: Vec<(String, f64)> = + list.as_array().unwrap().iter().map(|m| (m["comment"].as_str().unwrap().to_string(), m["time"].as_f64().unwrap())).collect(); + assert_eq!(marks, [("Intro".to_string(), 0.0), ("Middle".to_string(), 0.5)]); + s.execute_checked("markers.delete", json!({"layer": layer, "index": 0})).unwrap(); + assert_eq!(names(&mut s, layer), ["Middle"]); + s.execute_checked("layer.select", json!({"layers": [layer]})).unwrap(); + // The bound `layers` parameter (what a script or the menu's own binding passes) brings the marker back... + let r = s.execute_checked("layer.updateMarkersFromSource", json!({"layers": [layer]})).unwrap(); + assert_eq!(r["added"], 1); + assert_eq!(names(&mut s, layer), ["Intro", "Middle"]); + // ...as one undo step: undo removes it again, redo restores it. + assert!(s.undo()); + assert_eq!(names(&mut s, layer), ["Middle"]); + assert!(s.redo()); + assert_eq!(names(&mut s, layer), ["Intro", "Middle"]); + // The selection route (no parameter) finds nothing more to add. + assert_eq!(s.execute_checked("layer.updateMarkersFromSource", json!({})).unwrap()["added"], 0); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Reload Footage re-reads the markers the file carries: a cue point added to the file after the +/// import is available to Update Markers From Source (and a layer already made keeps its own). +#[test] +fn reloading_a_wav_picks_up_cue_points_added_to_the_file() { + let mut s = effectcraft_host::session(); + s.execute_checked("comp.new", json!({"name": "C", "width": 64, "height": 48, "frameRate": 30, "duration": 2})).unwrap(); + let dir = std::env::temp_dir().join(format!("effectcraft-host-wav-reload-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("cues.wav"); + std::fs::write(&path, marker_wav(&[(1, 0, "Intro")])).unwrap(); + let r = s.execute_checked("file.import", json!({"paths": [path.to_string_lossy()], "addToComp": true})).unwrap(); + assert_eq!(r["errors"], json!([]), "{r}"); + let (layer, item) = s + .active_comp() + .unwrap() + .layers + .iter() + .find_map(|l| match l.source { + LayerSource::Footage { item } => Some((l.id.0, item.0)), + _ => None, + }) + .unwrap(); + assert_eq!(names(&mut s, layer), ["Intro"]); + // The file gains a cue point; the layer does not change until asked. + std::fs::write(&path, marker_wav(&[(1, 0, "Intro"), (2, 4000, "Added later")])).unwrap(); + let r = s.execute_checked("file.reloadFootage", json!({"items": [item]})).unwrap(); + assert_eq!(r["reloaded"], 1, "{r}"); + assert_eq!(names(&mut s, layer), ["Intro"]); + let r = s.execute_checked("layer.updateMarkersFromSource", json!({"layers": [layer]})).unwrap(); + assert_eq!(r["added"], 1); + assert_eq!(names(&mut s, layer), ["Intro", "Added later"]); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/interchange/src/export.rs b/crates/interchange/src/export.rs index 7da18913a..cc44475f3 100644 --- a/crates/interchange/src/export.rs +++ b/crates/interchange/src/export.rs @@ -155,12 +155,7 @@ fn unsupported(project: &Project, comp: &Comp, l: &Layer, format: TimelineFormat return Some(format!("blend mode {}", l.blend_mode.label())); } // Premiere's Basic Motion in the interchange formats has one (uniform) scale. - if let Some(s) = l.transform().and_then(|t| t.get("scale")) - && std::iter::once(&s.value).chain(s.keys.iter().map(|k| &k.value)).any(|v| { - let a = v.as_vec3(); - (a[0] - a[1]).abs() > 1e-9 - }) - { + if l.transform().and_then(|t| t.get("scale")).is_some_and(non_uniform_scale) { return Some("non-uniform scale".into()); } if l.preserve_transparency { @@ -581,10 +576,7 @@ impl Exporter<'_> { } } if let Some(p) = tr.get("scale") { - let uniform = std::iter::once(&p.value).chain(p.keys.iter().map(|k| &k.value)).all(|v| { - let a = v.as_vec3(); - (a[0] - a[1]).abs() < 1e-9 - }); + let uniform = !non_uniform_scale(p); m.params.insert("scale".into(), param_out(p, &to_clip, |v| fp::ParamValue::Float(v.as_vec3()[1]))); if !uniform { m.params.insert("scale_width".into(), param_out(p, &to_clip, |v| fp::ParamValue::Float(v.as_vec3()[0]))); @@ -636,6 +628,16 @@ fn differs(v: &Value, def: [f64; 2]) -> bool { (a[0] - def[0]).abs() > 1e-6 || (a[1] - def[1]).abs() > 1e-6 } +/// Whether a scale property is non-uniform at some point: active key values, or the static +/// value when every key is muted (muted keys are not exported, #1894). +fn non_uniform_scale(p: &Property) -> bool { + let non_uniform = |v: &Value| { + let a = v.as_vec3(); + (a[0] - a[1]).abs() > 1e-9 + }; + if p.keys.iter().any(|k| !k.muted) { p.keys.iter().filter(|k| !k.muted).any(|k| non_uniform(&k.value)) } else { non_uniform(&p.value) } +} + /// The clip's `id` effect, or a new instance of it (None if the registry has no such effect). fn effect_or_new(ti: &fp::TrackItem, id: &str) -> Option { ti.effect(id).cloned().or_else(|| fp::find_effect(id).map(|e| e.instance())) @@ -651,7 +653,9 @@ fn set_effect(ti: &mut fp::TrackItem, e: fp::EffectInstance) { /// A property → a FilmCraft parameter (layer-time keys → clip-time keys). fn param_out(p: &Property, to_clip: &impl Fn(Tick) -> filmcraft_time::Tick, conv: impl Fn(&Value) -> fp::ParamValue) -> fp::Param { let mut out = fp::Param::new(conv(&p.value)); - for k in &p.keys { + // XML timelines have no mute state: leave muted keys out, as the Lottie exporter does, so + // the exported animation matches what the renderer shows (#1894). + for k in p.keys.iter().filter(|k| !k.muted) { let mut fk = fp::Keyframe::new(to_clip(k.time), conv(&k.value)); fk.interp = match k.out_interp { Interp::Linear => fp::Interpolation::Linear, diff --git a/crates/interchange/src/tests.rs b/crates/interchange/src/tests.rs index 1e0c7d417..c7ccdef33 100644 --- a/crates/interchange/src/tests.rs +++ b/crates/interchange/src/tests.rs @@ -411,7 +411,7 @@ fn ae_project() -> (Project, ItemId) { let mut ls = effectcraft_project::build::layer(&mut p, &c, "tone", LayerSource::Footage { item: s }, (0, 0), None); ls.out_point = t(3.0); ls.props.prop_mut("audio/levels").unwrap().value = Value::Vec2([-3.0, -3.0]); - c.layers = vec![lt, lp, la, ls]; + c.layers = vec![lt, lp, la, ls].into(); let cid = comp_item(&mut p, "Main", c); (p, cid) } @@ -480,6 +480,59 @@ fn tr_orig(l: &Layer) -> &effectcraft_project::Property { l.transform().unwrap().get("position").unwrap() } +/// #1894: XML timelines have no mute state, so a muted key must not be written as a keyframe +/// (the export would otherwise animate through it). +#[test] +fn export_skips_muted_keyframes() { + let mut p = Project::default(); + let a = footage_item(&mut p, "/media/a.mov"); + let mut c = Comp::new(W, H, FrameRate::new(25, 1), t(4.0)); + let mut l = effectcraft_project::build::layer(&mut p, &c, "clip", LayerSource::Footage { item: a }, (W, H), None); + { + let pos = l.transform_mut().unwrap().get_mut("position").unwrap(); + pos.keys = vec![ + Keyframe::new(t(0.0), Value::Vec3([100.0, 120.0, 0.0])), + Keyframe::new(t(1.0), Value::Vec3([500.0, 240.0, 0.0])), + Keyframe::new(t(2.0), Value::Vec3([300.0, 60.0, 0.0])), + ]; + pos.keys[1].muted = true; + } + c.layers.push(l); + let cid = comp_item(&mut p, "Main", c); + let out = export(&p, cid, &ExportOptions { format: TimelineFormat::Fcp7Xml, ..Default::default() }, &HashMap::new()).unwrap(); + let (q, r) = import_doc(&out.bytes, "Main.xml", &mut probe); + let main = r.comps.iter().copied().find(|c| q.item(*c).unwrap().name == "Main").expect("Main"); + let pos = layer(q.comp(main).unwrap(), "clip").transform().unwrap().get("position").unwrap(); + assert_eq!(pos.keys.len(), 2, "the muted key was exported"); + assert_eq!(pos.keys[0].value.as_vec3()[0], 100.0); + assert_eq!(pos.keys[1].value.as_vec3()[0], 300.0); +} + +/// #1894: a muted non-uniform scale key must not force a uniform layer to pre-render; only the +/// active keys matter, and the static value when every key is muted. +#[test] +fn muted_non_uniform_scale_does_not_force_prerendering() { + let mut p = Project::default(); + let a = footage_item(&mut p, "/media/a.mov"); + let mut c = Comp::new(W, H, FrameRate::new(25, 1), t(2.0)); + let mut l = effectcraft_project::build::layer(&mut p, &c, "clip", LayerSource::Footage { item: a }, (W, H), None); + { + let scale = l.transform_mut().unwrap().get_mut("scale").unwrap(); + scale.keys = vec![Keyframe::new(t(0.0), Value::Vec3([100.0, 100.0, 100.0])), Keyframe::new(t(1.0), Value::Vec3([50.0, 200.0, 100.0]))]; + scale.keys[1].muted = true; + } + let lid = l.id; + c.layers.push(l); + let cid = comp_item(&mut p, "Main", c); + assert_eq!(prerender_reason(&p, cid, lid, &ExportOptions::default()), None); + // With every key muted the static value drives evaluation, so a non-uniform static scale + // still takes the flattened path. + let scale = p.comp_mut(cid).unwrap().layer_mut(lid).unwrap().transform_mut().unwrap().get_mut("scale").unwrap(); + scale.keys[0].muted = true; + scale.value = Value::Vec3([50.0, 200.0, 100.0]); + assert_eq!(prerender_reason(&p, cid, lid, &ExportOptions::default()).as_deref(), Some("non-uniform scale")); +} + #[test] fn export_without_prerender_leaves_text_out() { let (p, cid) = ae_project(); diff --git a/crates/keyframe/src/lib.rs b/crates/keyframe/src/lib.rs index f2d94f177..abf929fe9 100644 --- a/crates/keyframe/src/lib.rs +++ b/crates/keyframe/src/lib.rs @@ -15,7 +15,8 @@ pub mod value; use effectcraft_time::{TICKS_PER_SECOND, Tick}; use serde::{Deserialize, Serialize}; -pub use text_doc::{BaselineOption, CharStyle, Composer, Direction, FigureStyle, FigureWidth, Kerning, OpenType, ParaStyle, StyleRun}; +use std::borrow::Borrow; +pub use text_doc::{BaselineOption, CharStyle, Composer, Direction, FigureStyle, FigureWidth, Kerning, LineJoin, OpenType, ParaStyle, StyleRun}; pub use value::{FeatherPoint, Gradient, Justify, ShapePath, TextDoc, Value}; /// Temporal interpolation on one side of a keyframe. @@ -89,12 +90,19 @@ pub struct Keyframe { /// Keyframe color label: index into the label list (0 = None). #[serde(default, skip_serializing_if = "is_zero")] pub label: u8, + /// Muted keys stay in the list (and the UI) but evaluation skips them. + #[serde(default, skip_serializing_if = "is_false")] + pub muted: bool, } fn is_zero(v: &u8) -> bool { *v == 0 } +fn is_false(v: &bool) -> bool { + !*v +} + fn yes() -> bool { true } @@ -116,6 +124,7 @@ impl Keyframe { spatial_continuous: false, roving: false, label: 0, + muted: false, } } pub fn hold(mut self) -> Keyframe { @@ -320,16 +329,16 @@ fn v3(v: &Value) -> [f64; 3] { /// tangent is a sixth of the way from the previous key to the next, the in tangent its mirror, and /// key times play no part. An end key uses its one neighbour, so the path leaves the first key /// and enters the last one toward the neighbouring key instead of with no tangent. -fn auto_spatial(keys: &[Keyframe], i: usize) -> ([f64; 3], [f64; 3]) { +fn auto_spatial>(keys: &[K], i: usize) -> ([f64; 3], [f64; 3]) { if i >= keys.len() || keys.len() < 2 { return ([0.0; 3], [0.0; 3]); } - let a = v3(&keys[i.saturating_sub(1)].value); - let c = v3(&keys[(i + 1).min(keys.len() - 1)].value); + let a = v3(&keys[i.saturating_sub(1)].borrow().value); + let c = v3(&keys[(i + 1).min(keys.len() - 1)].borrow().value); let tout = [(c[0] - a[0]) / 6.0, (c[1] - a[1]) / 6.0, (c[2] - a[2]) / 6.0]; // A side facing a key at the same position gets no tangent, so the segment between two // identical keys has zero length and the layer holds still (#1675). - let p = v3(&keys[i].value); + let p = v3(&keys[i].borrow().value); let tin = if i > 0 && a == p { [0.0; 3] } else { [-tout[0], -tout[1], -tout[2]] }; let tout = if i + 1 < keys.len() && c == p { [0.0; 3] } else { tout }; (tin, tout) @@ -337,7 +346,12 @@ fn auto_spatial(keys: &[Keyframe], i: usize) -> ([f64; 3], [f64; 3]) { /// Effective spatial tangents of key `i` (auto or stored). pub fn spatial_tangents(keys: &[Keyframe], i: usize) -> ([f64; 3], [f64; 3]) { - let Some(key) = keys.get(i) else { return ([0.0; 3], [0.0; 3]) }; + tangents_of(keys, i) +} + +/// [`spatial_tangents`] over stored keys or a filtered list of them (see [`evaluate`]). +fn tangents_of>(keys: &[K], i: usize) -> ([f64; 3], [f64; 3]) { + let Some(key) = keys.get(i).map(Borrow::borrow) else { return ([0.0; 3], [0.0; 3]) }; if key.spatial_auto { auto_spatial(keys, i) } else { (key.spatial_in, key.spatial_out) } } @@ -373,18 +387,18 @@ pub fn spatial_handles(keys: &[Keyframe], i: usize) -> ([f64; 3], [f64; 3]) { } /// Effective temporal ease of key `i` for dimension `d` (auto-Bezier keys get the neighbour slope). -fn effective_ease(keys: &[Keyframe], i: usize, d: usize, spatial: bool, out: bool) -> Ease { - let k = &keys[i]; +fn effective_ease>(keys: &[K], i: usize, d: usize, spatial: bool, out: bool) -> Ease { + let k = keys[i].borrow(); let list = if out { &k.out_ease } else { &k.in_ease }; if k.auto_bezier && i > 0 && i + 1 < keys.len() { - let dt = secs(keys[i + 1].time) - secs(keys[i - 1].time); + let dt = secs(keys[i + 1].borrow().time) - secs(keys[i - 1].borrow().time); let slope = if spatial { - let a = v3(&keys[i - 1].value); - let c = v3(&keys[i + 1].value); + let a = v3(&keys[i - 1].borrow().value); + let c = v3(&keys[i + 1].borrow().value); ((c[0] - a[0]).powi(2) + (c[1] - a[1]).powi(2) + (c[2] - a[2]).powi(2)).sqrt() / dt.max(1e-12) } else { - let a = keys[i - 1].value.components(); - let c = keys[i + 1].value.components(); + let a = keys[i - 1].borrow().value.components(); + let c = keys[i + 1].borrow().value.components(); (c.get(d).unwrap_or(&0.0) - a.get(d).unwrap_or(&0.0)) / dt.max(1e-12) }; return Ease { speed: slope, influence: 1.0 / 6.0 }; @@ -508,23 +522,39 @@ pub fn time_reverse(keys: &mut [Keyframe]) { } /// Evaluate a keyframed property at `t`. `spatial` selects motion-path semantics for 2D/3D values. +/// Muted keys are skipped, so the active neighbours interpolate as if they were not there; `None` +/// means every key is muted (the property's static value applies). pub fn evaluate(keys: &[Keyframe], t: Tick, spatial: bool) -> Option { - let first = keys.first()?; + if !keys.iter().any(|k| k.muted) { + return evaluate_active(keys, t, spatial); + } + // Muting is rare: skip the muted keys by reference rather than copying every key and value. + let active: Vec<&Keyframe> = keys.iter().filter(|k| !k.muted).collect(); + evaluate_active(&active, t, spatial) +} + +fn evaluate_active>(keys: &[K], t: Tick, spatial: bool) -> Option { + let first = keys.first()?.borrow(); if keys.len() == 1 || t <= first.time { return Some(first.value.clone()); } - let last = keys.last()?; + let last = keys.last()?.borrow(); if t >= last.time { return Some(last.value.clone()); } - let i = keys.partition_point(|k| k.time <= t) - 1; - Some(segment_value(keys, i, t, spatial)) + let i = keys.partition_point(|k| k.borrow().time <= t) - 1; + Some(segment_value_of(keys, i, t, spatial)) } /// Value inside segment `i` → `i+1` at time `t`. pub fn segment_value(keys: &[Keyframe], i: usize, t: Tick, spatial: bool) -> Value { - let a = &keys[i]; - let b = &keys[i + 1]; + segment_value_of(keys, i, t, spatial) +} + +/// [`segment_value`] over stored keys or a filtered list of them (see [`evaluate`]). +fn segment_value_of>(keys: &[K], i: usize, t: Tick, spatial: bool) -> Value { + let a = keys[i].borrow(); + let b = keys[i + 1].borrow(); if a.out_interp == Interp::Hold || !a.value.interpolates() { return a.value.clone(); } @@ -539,8 +569,8 @@ pub fn segment_value(keys: &[Keyframe], i: usize, t: Tick, spatial: bool) -> Val if is_spatial { let p0 = v3(&a.value); let p3 = v3(&b.value); - let (_, out_t) = spatial_tangents(keys, i); - let (in_t, _) = spatial_tangents(keys, i + 1); + let (_, out_t) = tangents_of(keys, i); + let (in_t, _) = tangents_of(keys, i + 1); let seg = SpatialSeg::new([p0, [p0[0] + out_t[0], p0[1] + out_t[1], p0[2] + out_t[2]], [p3[0] + in_t[0], p3[1] + in_t[1], p3[2] + in_t[2]], p3]); let len = seg.length(); let f = if lin_out && lin_in || len <= 1e-12 { @@ -617,6 +647,7 @@ pub fn set_key(keys: &mut Vec, key: Keyframe) -> usize { k.spatial_continuous = old.spatial_continuous; k.roving = old.roving; k.label = old.label; + k.muted = old.muted; keys[i] = k; i } diff --git a/crates/keyframe/src/tests.rs b/crates/keyframe/src/tests.rs index 0a31cfc19..312703239 100644 --- a/crates/keyframe/src/tests.rs +++ b/crates/keyframe/src/tests.rs @@ -395,3 +395,59 @@ proptest::proptest! { proptest::prop_assert!((-1e-9..=10.0 + 1e-9).contains(&v)); } } + +#[test] +fn muted_keys_are_skipped_by_evaluation() { + let mut keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)), Keyframe::new(s(1.0), Value::Scalar(100.0)), Keyframe::new(s(2.0), Value::Scalar(300.0))]; + let at = |keys: &[Keyframe], t: f64| evaluate(keys, s(t), false).unwrap().as_f64(); + assert!((at(&keys, 0.5) - 50.0).abs() < 1e-9); + assert!((at(&keys, 1.5) - 200.0).abs() < 1e-9); + keys[1].muted = true; + // The 0 → 300 segment now spans 0 → 2 s. + assert!((at(&keys, 0.5) - 75.0).abs() < 1e-9); + assert!((at(&keys, 1.0) - 150.0).abs() < 1e-9); + assert!((at(&keys, 1.5) - 225.0).abs() < 1e-9); + for k in &mut keys { + k.muted = true; + } + assert!(evaluate(&keys, s(0.5), false).is_none()); +} + +#[test] +fn muted_key_automation_uses_the_active_neighbours() { + let mut keys = vec![ + Keyframe::new(s(0.0), Value::Vec2([0.0, 0.0])), + Keyframe::new(s(1.0), Value::Vec2([50.0, 100.0])), + Keyframe::new(s(2.0), Value::Vec2([100.0, 0.0])), + ]; + for k in &mut keys { + k.auto_bezier = true; + k.in_interp = Interp::Bezier; + k.out_interp = Interp::Bezier; + } + let all = keys.clone(); + let mut active = keys.clone(); + active.remove(1); + keys[1].muted = true; + for t in [0.0, 0.25, 0.5, 1.0, 1.5, 1.75, 2.0] { + assert_eq!(evaluate(&keys, s(t), true), evaluate(&active, s(t), true), "{t}"); + } + // The muted auto-Bezier key no longer bulges the motion path. + assert_ne!(evaluate(&keys, s(0.5), true), evaluate(&all, s(0.5), true)); +} + +#[test] +fn mute_round_trips_and_survives_value_replacement() { + let mut k = Keyframe::new(s(0.0), Value::Scalar(1.0)); + let json = serde_json::to_value(&k).unwrap(); + assert!(json.get("muted").is_none(), "false is the default and is skipped"); + k.muted = true; + let json = serde_json::to_value(&k).unwrap(); + assert_eq!(json["muted"], serde_json::json!(true)); + let back: Keyframe = serde_json::from_value(json).unwrap(); + assert!(back.muted); + let mut keys = vec![k]; + set_key(&mut keys, Keyframe::new(s(0.0), Value::Scalar(2.0))); + assert!(keys[0].muted); + assert_eq!(keys[0].value, Value::Scalar(2.0)); +} diff --git a/crates/keyframe/src/text_doc.rs b/crates/keyframe/src/text_doc.rs index 6c1e5b843..96a8b6be3 100644 --- a/crates/keyframe/src/text_doc.rs +++ b/crates/keyframe/src/text_doc.rs @@ -179,6 +179,31 @@ pub enum Composer { fn single_line() -> Composer { Composer::SingleLine } + +/// How the corners of a text stroke are drawn (Character panel Line Join). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum LineJoin { + /// Sharp corners (AE's default for new text). + #[default] + Miter, + Round, + Bevel, +} + +impl LineJoin { + pub fn parse(s: &str) -> Option { + Some(match s.to_ascii_lowercase().as_str() { + "miter" => LineJoin::Miter, + "round" => LineJoin::Round, + "bevel" => LineJoin::Bevel, + _ => return None, + }) + } +} + +fn round_join() -> LineJoin { + LineJoin::Round +} fn yes() -> bool { true } @@ -402,6 +427,9 @@ pub struct TextDoc { pub apply_fill: bool, pub apply_stroke: bool, pub stroke_over_fill: bool, + /// Documents saved before line joins existed drew round joins. + #[serde(default = "round_join")] + pub line_join: LineJoin, pub tracking: f64, /// None = auto (120% of size). pub leading: Option, @@ -463,6 +491,7 @@ impl Default for TextDoc { apply_fill: true, apply_stroke: false, stroke_over_fill: true, + line_join: LineJoin::Miter, tracking: 0.0, leading: None, justify: Justify::Left, @@ -909,6 +938,10 @@ impl TextDoc { self.stroke_over_fill = v.as_bool().ok_or("strokeOverFill: expected a boolean")?; return Ok(true); } + "lineJoin" => { + self.line_join = v.as_str().and_then(LineJoin::parse).ok_or("lineJoin: expected miter|round|bevel")?; + return Ok(true); + } "vertical" => { self.vertical = v.as_bool().ok_or("vertical: expected a boolean")?; return Ok(true); @@ -1423,6 +1456,8 @@ mod tests { assert!(d.is_uniform()); assert_eq!(d.style_at(2).size, 50.0); assert_eq!(d.composer, Composer::SingleLine, "old box text keeps greedy breaking"); + assert_eq!(d.line_join, LineJoin::Round, "old strokes keep their round corners"); + assert_eq!(TextDoc::default().line_join, LineJoin::Miter); assert!(d.ligatures); assert_eq!(d.kerning, Kerning::Metrics); // Round trip with runs. diff --git a/crates/lottie/src/anim.rs b/crates/lottie/src/anim.rs index e3264e3b7..4925460a9 100644 --- a/crates/lottie/src/anim.rs +++ b/crates/lottie/src/anim.rs @@ -73,9 +73,22 @@ fn is_spatial(p: &Property) -> bool { } /// Export a property as a Lottie animatable property (`{a, k}` plus `x` for expressions). -/// `label` names the property in warnings. +/// `label` names the property in warnings. Muted keys are omitted: Lottie has no mute state, and +/// the remaining keys carry the animation the renderer shows. pub(crate) fn export_prop(ex: &mut Ex, p: &Property, conv: &dyn Fn(&Value) -> Json, label: &str) -> Json { - let mut out = if p.keys.is_empty() { json!({"a": 0, "k": conv(&p.value)}) } else { json!({"a": 1, "k": export_keys(ex, p, conv)}) }; + let muted = p.keys.iter().any(|k| k.muted); + if muted { + ex.warn(format!("{label}: muted keyframes are not exported")); + } + let active: Vec; + let keys: &[Keyframe] = if muted { + active = p.keys.iter().filter(|k| !k.muted).cloned().collect(); + &active + } else { + &p.keys + }; + let mut out = + if keys.is_empty() { json!({"a": 0, "k": conv(&p.value)}) } else { json!({"a": 1, "k": export_keys(ex, keys, is_spatial(p), p.hold_only, conv)}) }; if let Some(e) = p.expr.as_ref().filter(|e| e.enabled && !e.text.trim().is_empty()) { if ex.include_expressions { out["x"] = json!(e.text); @@ -86,20 +99,18 @@ pub(crate) fn export_prop(ex: &mut Ex, p: &Property, conv: &dyn Fn(&Value) -> Js out } -fn export_keys(ex: &Ex, p: &Property, conv: &dyn Fn(&Value) -> Json) -> Vec { - let keys = &p.keys; - let spatial = is_spatial(p); +fn export_keys(ex: &Ex, keys: &[Keyframe], spatial: bool, hold_only: bool, conv: &dyn Fn(&Value) -> Json) -> Vec { let mut out = Vec::with_capacity(keys.len()); for (i, k) in keys.iter().enumerate() { let mut o = json!({"t": r6(ex.tb.frame(k.time)), "s": wrap(conv(&k.value))}); if i + 1 == keys.len() { - if k.out_interp == Interp::Hold || p.hold_only { + if k.out_interp == Interp::Hold || hold_only { o["h"] = json!(1); } out.push(o); continue; } - if k.out_interp == Interp::Hold || p.hold_only || !k.value.interpolates() { + if k.out_interp == Interp::Hold || hold_only || !k.value.interpolates() { o["h"] = json!(1); out.push(o); continue; diff --git a/crates/lottie/src/tests.rs b/crates/lottie/src/tests.rs index 31f3a70bb..203f04c04 100644 --- a/crates/lottie/src/tests.rs +++ b/crates/lottie/src/tests.rs @@ -878,3 +878,21 @@ fn external_images_are_local_or_skipped() { assert_eq!(std::path::Path::new(&paths[0]), base.join("images").join("a.png")); assert_eq!(r.warnings.iter().filter(|w| w.contains("is not imported")).count(), assets.len() - 1, "{:?}", r.warnings); } + +#[test] +fn muted_keys_are_omitted_from_the_export() { + let (mut p, cid) = setup(); + let mut l = solid_layer(&mut p, cid, [0.0, 1.0, 0.0], 20, 20); + let mut b = Keyframe::new(s(1.0), Value::Scalar(100.0)); + b.muted = true; + l.props.prop_mut("transform/opacity").unwrap().keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)), b, Keyframe::new(s(2.0), Value::Scalar(300.0))]; + push(&mut p, cid, l); + let (q, nc, warnings, json) = roundtrip(&p, cid, &ExportOptions::default()); + assert!(warnings.iter().any(|w| w.contains("muted keyframes")), "{warnings:?}"); + // Lottie has no mute state: the two active keys carry the 0 → 300 animation. + assert_eq!(json["layers"][0]["ks"]["o"]["k"].as_array().unwrap().len(), 2); + assert_eq!(json["layers"][0]["ks"]["o"]["k"][1]["s"], json!([300.0])); + let opacity_at = |p: &Project, cid: ItemId, t: f64| p.comp(cid).unwrap().layers[0].props.prop("transform/opacity").unwrap().value_at(s(t)).as_f64(); + assert!((opacity_at(&p, cid, 1.0) - 150.0).abs() < 1e-6); + assert!((opacity_at(&q, nc, 1.0) - 150.0).abs() < 1e-6); +} diff --git a/crates/media/Cargo.toml b/crates/media/Cargo.toml index 0ef01d831..52a375fef 100644 --- a/crates/media/Cargo.toml +++ b/crates/media/Cargo.toml @@ -12,6 +12,7 @@ workspace = true [dependencies] craft-docpath = { workspace = true } effectcraft-time = { workspace = true } +effectcraft-color = { workspace = true } effectcraft-project = { workspace = true } effectcraft-raster = { workspace = true } effectcraft-render = { workspace = true } diff --git a/crates/media/src/exr_channels.rs b/crates/media/src/exr_channels.rs index 795da99e5..d372ec849 100644 --- a/crates/media/src/exr_channels.rs +++ b/crates/media/src/exr_channels.rs @@ -13,6 +13,10 @@ use std::sync::{Arc, Mutex, OnceLock}; use effectcraft_raster::AuxChannels; use effectcraft_raster::channels3d::{BACKGROUND_DEPTH, split_channel}; +/// The largest display window decoded (16384²; vector footage and Continuously Rasterize +/// stop at the same size). +const MAX_DISPLAY_PIXELS: usize = 1 << 28; + fn is_depth(name: &str) -> bool { let last = name.rsplit('.').next().unwrap_or(name); last.eq_ignore_ascii_case("z") || name.to_ascii_lowercase().contains("depth") @@ -75,7 +79,9 @@ pub fn read_exr_channels(bytes: &[u8]) -> Option { } }; collect_meta(&img.attributes.other); - let n = w.checked_mul(h)?; + // Each channel is a display-window plane: a 60000² window over a 1×1 data window was a + // multi-gigabyte allocation per channel (#2141). + let n = w.checked_mul(h).filter(|n| *n <= MAX_DISPLAY_PIXELS)?; // The decoded image is consumed channel by channel, so each channel's samples are freed (or // moved) as its plane is made instead of the whole file being held twice (#474). for layer in img.layer_data { @@ -337,6 +343,18 @@ mod tests { assert_eq!(aux.get("GlossDir.R").and_then(|p| p.first()).copied(), Some(0.25)); } + /// #2141: one pixel of data in a 60000² display window is refused, not allocated per channel. + #[test] + fn a_huge_display_window_is_refused_not_allocated() { + use exr::prelude::*; + let channels = AnyChannels::sort(vec![AnyChannel::new("R", FlatSamples::F32(vec![0.5]))].into()); + let mut image = Image::from_layer(Layer::new((1, 1), LayerAttributes::default(), Encoding::FAST_LOSSLESS, channels)); + image.attributes.display_window = IntegerBounds::new((0, 0), (60000, 60000)); + let mut bytes = std::io::Cursor::new(Vec::new()); + image.write().to_buffered(&mut bytes).unwrap(); + assert!(read_exr_channels(&bytes.into_inner()).is_none()); + } + #[test] fn layered_exr_without_an_unnamed_rgb_layer_imports() { use exr::prelude::*; diff --git a/crates/media/src/layered.rs b/crates/media/src/layered.rs index 055c3feb0..b259c8b7f 100644 --- a/crates/media/src/layered.rs +++ b/crates/media/src/layered.rs @@ -113,6 +113,11 @@ pub(crate) fn decode(path: &str, bytes: &[u8], footage: &Footage, op: AlphaOp) - if is_svg(path, bytes) || is_pdf_like(path, bytes) { let doc = vector_doc(path, bytes, footage.layer.as_ref(), footage.page).unwrap_or(Err(String::new())).map_err(MediaError::Decode)?; let (w, h) = doc.pixel_size(); + // A declared size up to 30000² would be a 14 GB allocation that aborts the app (#2136): + // past what Continuously Rasterize can draw, the footage reports an error instead. + if u64::from(w) * u64::from(h) > u64::from(MAX_VECTOR_SIDE) * u64::from(MAX_VECTOR_SIDE) { + return Err(MediaError::Decode(format!("{path}: {w}×{h} pixels is larger than vector footage can be drawn ({MAX_VECTOR_SIDE}×{MAX_VECTOR_SIDE})"))); + } let img = effectcraft_svg::rasterize(&doc, w, h, 1.0); // The rasteriser produces premultiplied pixels already. let _ = op; @@ -121,10 +126,32 @@ pub(crate) fn decode(path: &str, bytes: &[u8], footage: &Footage, op: AlphaOp) - Ok(None) } +/// The largest side vector footage is drawn at, decoded or continuously rasterised. +const MAX_VECTOR_SIDE: u32 = 16384; + /// A vector file rasterised at `scale` × its pixel size (Continuously Rasterize). pub(crate) fn rasterize_vector(path: &str, bytes: &[u8], layer: Option<&effectcraft_project::SourceLayer>, page: u32, scale: f64) -> Option { let doc = vector_doc(path, bytes, layer, page)?.ok()?; let (w, h) = doc.pixel_size(); - let (sw, sh) = (((w as f64 * scale).ceil() as u32).clamp(1, 16384), ((h as f64 * scale).ceil() as u32).clamp(1, 16384)); + let (sw, sh) = (((w as f64 * scale).ceil() as u32).clamp(1, MAX_VECTOR_SIDE), ((h as f64 * scale).ceil() as u32).clamp(1, MAX_VECTOR_SIDE)); Some(effectcraft_svg::rasterize(&doc, sw, sh, scale)) } + +#[cfg(test)] +mod tests { + use super::*; + + /// #2136: an SVG that declares 30000×30000 asked for a 14 GB image and aborted the app; it + /// is now an error, and a drawable size still decodes. + #[test] + fn vector_footage_past_the_drawable_size_is_an_error_not_an_abort() { + let huge = br#""#; + let footage = probe("huge.svg", huge).unwrap().unwrap(); + let e = decode("huge.svg", huge, &footage, AlphaOp::new(footage.alpha, footage.premul_color)).err().map(|e| e.to_string()).unwrap_or_default(); + assert!(e.contains("30000×30000"), "{e}"); + let small = br#""#; + let footage = probe("small.svg", small).unwrap().unwrap(); + let img = decode("small.svg", small, &footage, AlphaOp::new(footage.alpha, footage.premul_color)).unwrap().unwrap(); + assert_eq!((img.width, img.height), (40, 20)); + } +} diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index 9b5b491ab..946e0d12a 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -25,6 +25,7 @@ pub use layered::vector_doc; mod pool; mod probe; mod stream; +mod wav_markers; mod webp_anim; pub use pool::{DEFAULT_BUDGET, MediaPool, PoolStats}; @@ -79,6 +80,22 @@ pub fn is_importable(path: &std::path::Path) -> bool { || dpx::EXTENSIONS.contains(&ext.as_str()) } +/// A whole file's bytes, refusing anything that isn't a regular file: a project can name +/// `/dev/zero` or a FIFO as footage, and reading those never ends (#2134). +pub(crate) fn read_regular_file(path: &std::path::Path) -> std::io::Result> { + use std::io::Read; + let mut file = std::fs::File::open(path)?; + let meta = file.metadata()?; + if !meta.is_file() { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("{} is not a file", path.display()))); + } + let mut bytes = Vec::new(); + // A sparse file can claim far more than memory: an error, not an abort. + bytes.try_reserve_exact(usize::try_from(meta.len()).unwrap_or(usize::MAX)).map_err(|e| std::io::Error::new(std::io::ErrorKind::OutOfMemory, e))?; + file.read_to_end(&mut bytes)?; + Ok(bytes) +} + pub(crate) fn ext_of(path: &std::path::Path) -> String { path.extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase() } @@ -99,3 +116,20 @@ pub(crate) fn still_format(path: &str, bytes: &[u8]) -> Option effectcraft_time::FrameRate { if r.num > 0 && r.den > 0 { effectcraft_time::FrameRate::new(r.num, r.den) } else { effectcraft_time::FrameRate::FPS_30 } } + +#[cfg(test)] +mod read_tests { + /// #2134: a folder (and on Unix a device) named as footage is an error, not an endless read. + #[test] + fn only_regular_files_are_read_whole() { + let dir = std::env::temp_dir(); + assert!(super::read_regular_file(&dir).is_err()); + assert!(crate::probe::probe(&dir).is_err(), "probing a folder fails instead of reading it"); + #[cfg(unix)] + assert!(super::read_regular_file(std::path::Path::new("/dev/zero")).is_err()); + let f = dir.join(format!("ec-read-regular-{}.bin", std::process::id())); + std::fs::write(&f, b"abc").unwrap(); + assert_eq!(super::read_regular_file(&f).unwrap(), b"abc"); + let _ = std::fs::remove_file(f); + } +} diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index 748d33c99..1f1587059 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -67,6 +67,10 @@ const CONFORM_VERSION: u32 = 3; /// conform thread allocate all of it at once (#408). const MAX_CONFORM_SECONDS: i64 = 24 * 3600; +/// The sample rates audio is read at; a header outside them is damaged or hostile (#2141). +const MIN_SAMPLE_RATE: u32 = 1_000; +const MAX_SAMPLE_RATE: u32 = 768_000; + /// The sample frames of `footage`'s audio at `rate` Hz that a conformed file holds, `None` past /// [`MAX_CONFORM_SECONDS`]. #[cfg_attr(target_arch = "wasm32", allow(dead_code))] @@ -513,6 +517,11 @@ impl MediaPool { let src = self.inner.source(&path).ok_or("the file can't be opened")?; let s0 = start.to_units_floor(rate as i64); let native = src.info().audio.as_ref().map_or(rate, |a| a.sample_rate); + // The header's rate scales the samples read: 0xFFFFFFFF Hz made one conform chunk about + // 47 GB (#2141). Real audio is 1–768 kHz. + if native != 0 && !(MIN_SAMPLE_RATE..=MAX_SAMPLE_RATE).contains(&native) { + return Err(format!("a sample rate of {native} Hz is outside {MIN_SAMPLE_RATE}–{MAX_SAMPLE_RATE} Hz")); + } if native == 0 || rate == 0 || native == rate { return read_stereo(&src, s0, frames, rate); } @@ -581,7 +590,7 @@ impl Inner { if let Some(b) = lock(&self.files).get(path) { return Ok(b.clone()); } - std::fs::read(path).map(Into::into).map_err(|e| MediaError::Io(format!("{path}: {e}"))) + crate::read_regular_file(std::path::Path::new(path)).map(Into::into).map_err(|e| MediaError::Io(format!("{path}: {e}"))) } /// The opened movie/audio source for `path`. The first caller opens it, outside the map's diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index 6bba682b3..41b50b0f4 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -23,9 +23,10 @@ pub fn probe(path: impl AsRef) -> Result { let turn = header.as_deref().map_or(Orientation::NoTransforms, video_turn); turn_footage(&mut f, turn); correct_rate(&mut f, header.as_deref().and_then(video_rate)); + trim_to_edits(&mut f, header.as_deref().and_then(video_shown)); return Ok(f); } - let bytes = std::fs::read(path).map_err(|e| MediaError::Io(format!("{}: {e}", path.display())))?; + let bytes = crate::read_regular_file(path).map_err(|e| MediaError::Io(format!("{}: {e}", path.display())))?; if crate::MODEL_EXTENSIONS.contains(&ext_of(path).as_str()) { return probe_model(&path.to_string_lossy(), &bytes, &side_file_reader(path)); } @@ -38,7 +39,7 @@ fn side_file_reader(model: &Path) -> impl Fn(&str) -> Option> + use<> { let model = std::path::absolute(model).unwrap_or_else(|_| model.to_path_buf()); move |uri| { let p = craft_docpath::resolve_absolute(uri, Some(&model)).ok()?; - std::fs::read(p).ok() + crate::read_regular_file(&p).ok() } } @@ -107,13 +108,45 @@ pub fn probe_bytes(path: &str, bytes: Arc<[u8]>) -> Result { 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 rate = video_rate(&bytes); + let shown = video_shown(&bytes); + // A WAV's cue points and XMP markers become the footage's markers. + let markers = crate::wav_markers::read(&bytes); let src = filmcraft_codecs::open_bytes(&name, bytes)?; let mut f = footage_from_info(path, src.info()); + f.markers = markers; turn_footage(&mut f, turn); correct_rate(&mut f, rate); + trim_to_edits(&mut f, shown); Ok(f) } +/// A movie lasts as long as its video edit list shows (#2592): the reader reports the media +/// duration (`mdhd`), which for H.264 with B-frames runs past the last shown frame by the +/// encoder delay the edit list trims (75 frames at 25 fps read as 3.04 s). Never lengthens. +fn trim_to_edits(f: &mut Footage, shown: Option) { + if f.kind == FootageKind::Video + && let Some(shown) = shown.filter(|s| *s > Tick::ZERO && *s < f.duration) + { + f.duration = shown; + } +} + +/// How long the video track's edit list shows its media (`elst` segments, movie timescale): +/// `None` without edits, or with an empty edit (a delayed start keeps the reader's duration). +fn video_shown(bytes: &[u8]) -> Option { + let file = filmcraft_isobmff::open(bytes).ok()?; + let t = file.tracks.iter().find(|t| t.kind == filmcraft_isobmff::TrackKind::Video && !t.samples.is_empty())?; + edits_shown(&t.edits, file.timescale) +} + +fn edits_shown(edits: &[filmcraft_isobmff::Edit], timescale: u32) -> Option { + if edits.is_empty() || timescale == 0 || edits.iter().any(|e| e.media_time < 0) { + return None; + } + let units = edits.iter().try_fold(0u64, |acc, e| acc.checked_add(e.segment_duration))?; + Some(Tick::from_units(i64::try_from(units).ok()?, i64::from(timescale))) +} + /// A movie's frame rate as its frame durations average to ([`video_rate`]), where the reader's /// own estimate misreads it. fn correct_rate(f: &mut Footage, rate: Option) { @@ -319,6 +352,24 @@ fn averaged_rate(timescale: u32, durations: impl Iterator) -> Option mod tests { use super::*; + /// #2592: an encoder-delayed H.264 track's edit list shows 75 frames at 25 fps (3 s at a + /// 1000 timescale), though its media runs a frame longer; empty edits and no edits leave it. + #[test] + fn the_edit_list_trims_a_movie_to_the_frames_it_shows() { + let edit = |segment_duration, media_time| filmcraft_isobmff::Edit { segment_duration, media_time, media_rate: 0x0001_0000 }; + let shown = edits_shown(&[edit(3000, 1024)], 1000); + assert_eq!(shown, Some(Tick::from_units(3, 1))); + let mut f = Footage { kind: FootageKind::Video, duration: Tick::from_units(76, 25), ..Footage::default() }; + trim_to_edits(&mut f, shown); + assert_eq!(f.duration, Tick::from_units(75, 25)); + // Never longer than the reader's duration, and only for video. + trim_to_edits(&mut f, Some(Tick::from_units(4, 1))); + assert_eq!(f.duration, Tick::from_units(75, 25)); + assert_eq!(edits_shown(&[edit(1000, -1), edit(3000, 0)], 1000), None); + assert_eq!(edits_shown(&[], 1000), None); + assert_eq!(edits_shown(&[edit(3000, 0)], 0), None); + } + /// Durations (in 1/`timescale` s) of `n` frames at `rate` whose times are rounded to the timescale. fn rounded(rate: FrameRate, timescale: i64, n: i64) -> Vec { let at = |k: i64| (2 * k * timescale * rate.den + rate.num) / (2 * rate.num); diff --git a/crates/media/src/wav_markers.rs b/crates/media/src/wav_markers.rs new file mode 100644 index 000000000..ca40824b6 --- /dev/null +++ b/crates/media/src/wav_markers.rs @@ -0,0 +1,517 @@ +//! Markers a WAV file carries, read from its RIFF chunks. +//! +//! Two encodings are read and merged: +//! - **Cue points** (the standard): a `cue ` chunk with one point per marker, named by `labl` (or +//! `note`) sub-chunks of a `LIST` `adtl` chunk, and given a length by `ltxt`. +//! - **XMP** (Adobe's tools): an `_PMX` chunk holding an XMP packet whose `xmpDM:markers` list has +//! one entry per marker (`xmpDM:startTime`, `xmpDM:duration`, `xmpDM:name`), counted in frames +//! of `xmpDM:frameRate` (`f44100` for samples at 44.1 kHz). +//! +//! RF64 files (WAVs over 4 GB) carry a `0xFFFFFFFF` size for a chunk too big for 32 bits and give its real size in a +//! `ds64` chunk; those sizes are resolved so the chunks after the audio are still found. +//! +//! The file is untrusted input: every read is bounds-checked, sizes use checked arithmetic, and +//! the number of markers, of `adtl` sub-chunks looked at and the size of the XMP packet are capped. + +use std::collections::{HashMap, HashSet}; + +use effectcraft_color::Label; +use effectcraft_project::Marker; +use effectcraft_time::Tick; + +/// Most markers read from one file. +pub const MAX_MARKERS: usize = 10_000; +/// Largest XMP packet read (bytes). +const MAX_XMP: usize = 4 << 20; +/// Most `adtl` sub-chunks (`labl`, `note`, `ltxt`) looked at in one file: a few per cue point is plenty. +const MAX_ADTL_RECORDS: usize = 4 * MAX_MARKERS; +/// Most entries read from an RF64 file's `ds64` size table. +const MAX_DS64_TABLE: usize = 1_024; + +fn u32_at(b: &[u8], at: usize) -> Option { + let s = b.get(at..at.checked_add(4)?)?; + Some(u32::from_le_bytes(s.try_into().ok()?)) +} + +fn u64_at(b: &[u8], at: usize) -> Option { + let s = b.get(at..at.checked_add(8)?)?; + Some(u64::from_le_bytes(s.try_into().ok()?)) +} + +/// The text of a NUL-terminated label (the rest of the chunk), lossily decoded. +fn label(b: &[u8]) -> String { + let end = b.iter().position(|&c| c == 0).unwrap_or(b.len()); + String::from_utf8_lossy(b.get(..end).unwrap_or_default()).trim().to_string() +} + +fn secs(frames: f64, rate: f64) -> Tick { + if rate > 0.0 && frames.is_finite() { Tick::from_seconds_f64((frames / rate).max(0.0)) } else { Tick::ZERO } +} + +/// Whether `bytes` start a RIFF/WAVE file. +pub fn is_wav(bytes: &[u8]) -> bool { + matches!(bytes.get(0..4), Some(b"RIFF" | b"RF64")) && bytes.get(8..12) == Some(b"WAVE") +} + +/// The sizes an RF64 file's `ds64` chunk gives for chunks too big for the 32-bit size field. +struct Ds64 { + /// The `data` chunk's size. + data: u64, + /// Other chunks: `(id, size)`. + table: Vec<([u8; 4], u64)>, +} + +impl Ds64 { + /// The real size of chunk `id`, when the file says. + fn size_of(&self, id: &[u8; 4]) -> Option { + if id == b"data" { Some(self.data) } else { self.table.iter().find(|(t, _)| t == id).map(|(_, size)| *size) } + } +} + +/// The `ds64` chunk of an RF64 file (`RF64`, size, `WAVE`, then `ds64`); `None` for any other file or one whose +/// `ds64` doesn't fit. +fn ds64(bytes: &[u8]) -> Option { + if bytes.get(0..4) != Some(b"RF64") || bytes.get(12..16) != Some(b"ds64") { + return None; + } + let size = usize::try_from(u32_at(bytes, 16)?).ok()?; + let body = bytes.get(20..20usize.checked_add(size)?)?; + // riff size (8), data size (8), sample count (8), table length (4), then `(id, size)` entries of 12 bytes + let data = u64_at(body, 8)?; + let entries = usize::try_from(u32_at(body, 24)?).ok()?; + let mut table = Vec::new(); + for i in 0..entries.min(MAX_DS64_TABLE) { + let at = 28usize.checked_add(i.checked_mul(12)?)?; + let id = <[u8; 4]>::try_from(body.get(at..at.checked_add(4)?)?).ok()?; + table.push((id, u64_at(body, at + 4)?)); + } + Some(Ds64 { data, table }) +} + +/// The chunks of a WAVE file as `(id, body)`, stopping at the first one that doesn't fit. +fn chunks(bytes: &[u8]) -> Vec<([u8; 4], &[u8])> { + let big = ds64(bytes); + let mut out = Vec::new(); + let mut at = 12usize; + while let Some(head) = bytes.get(at..at.saturating_add(8)) { + let (Ok(id), Some(field)) = (<[u8; 4]>::try_from(&head[..4]), u32_at(head, 4)) else { break }; + // In an RF64 file a size field of 0xFFFFFFFF stands for the size `ds64` gives. + let size = match &big { + Some(d) if field == u32::MAX => d.size_of(&id).unwrap_or(u64::from(field)), + _ => u64::from(field), + }; + let start = at.saturating_add(8); + let Some(end) = usize::try_from(size).ok().and_then(|n| start.checked_add(n)) else { break }; + let Some(body) = bytes.get(start..end) else { break }; + out.push((id, body)); + // Chunks are padded to an even size. + at = end.saturating_add(end & 1); + } + out +} + +/// The markers `bytes` carry, in time order (empty for anything that isn't a WAV with markers). +pub fn read(bytes: &[u8]) -> Vec { + if !is_wav(bytes) { + return Vec::new(); + } + let chunks = chunks(bytes); + let rate = chunks.iter().find(|(id, _)| id == b"fmt ").and_then(|(_, b)| u32_at(b, 4)).map_or(0.0, f64::from); + let mut out = cue_markers(&chunks, rate); + if let Some((_, xmp)) = chunks.iter().find(|(id, _)| id == b"_PMX") { + // Tools that write both encodings repeat each marker. + let mut seen: HashSet<(String, Tick)> = out.iter().map(|o| (o.comment.clone(), o.time)).collect(); + for m in xmp_markers(xmp, rate) { + if seen.insert((m.comment.clone(), m.time)) { + out.push(m); + } + } + } + out.truncate(MAX_MARKERS); + out.sort_by_key(|m| m.time); + out +} + +/// Cue points named and sized by the `adtl` list. +fn cue_markers(chunks: &[([u8; 4], &[u8])], rate: f64) -> Vec { + let Some((_, cue)) = chunks.iter().find(|(id, _)| id == b"cue ") else { return Vec::new() }; + let count = (u32_at(cue, 0).unwrap_or(0) as usize).min(MAX_MARKERS); + // The cue points first: `(id, offset in samples)`. + let mut points = Vec::new(); + for i in 0..count { + let Some(point) = cue.get(4usize.saturating_add(i.saturating_mul(24))..) else { break }; + let (Some(id), Some(offset)) = (u32_at(point, 0), u32_at(point, 20)) else { break }; + points.push((id, offset)); + } + // Only the names and lengths of those ids are kept, so a hostile `adtl` list cannot grow them past the cue count, + // and each record is a lookup, not a scan. + let wanted: HashSet = points.iter().map(|(id, _)| *id).collect(); + let mut names: HashMap = HashMap::new(); + let mut lengths: HashMap = HashMap::new(); + let mut records = 0usize; + 'lists: for (_, list) in chunks.iter().filter(|(id, b)| id == b"LIST" && b.get(0..4) == Some(b"adtl")) { + let mut at = 4usize; + while let Some(head) = list.get(at..at.saturating_add(8)) { + let (Ok(id), Some(size)) = (<[u8; 4]>::try_from(&head[..4]), u32_at(head, 4)) else { break }; + let start = at.saturating_add(8); + let Some(end) = start.checked_add(size as usize) else { break }; + let Some(body) = list.get(start..end) else { break }; + records += 1; + if records > MAX_ADTL_RECORDS { + break 'lists; + } + if let Some(cue_id) = u32_at(body, 0).filter(|c| wanted.contains(c)) { + match &id { + // A `labl` is the name; a `note` only when there is none. + b"labl" => { + names.insert(cue_id, label(body.get(4..).unwrap_or_default())); + } + b"note" => { + names.entry(cue_id).or_insert_with(|| label(body.get(4..).unwrap_or_default())); + } + // The first `ltxt` of a cue point gives its length. + b"ltxt" => { + lengths.entry(cue_id).or_insert_with(|| u32_at(body, 4).unwrap_or(0)); + } + _ => {} + } + } + at = end.saturating_add(end & 1); + } + } + points + .into_iter() + .map(|(id, offset)| { + let comment = names.get(&id).cloned().unwrap_or_default(); + let length = lengths.get(&id).copied().unwrap_or(0); + Marker { time: secs(f64::from(offset), rate), duration: secs(f64::from(length), rate), comment, label: Label::None, ..Default::default() } + }) + .collect() +} + +/// The text of `name` in an XMP entry, as an attribute (`name="v"`) or an element +/// (`v`). +fn xmp_value(entry: &str, name: &str) -> Option { + let attr = format!("{name}=\""); + if let Some(i) = entry.find(&attr) { + let rest = entry.get(i + attr.len()..)?; + return Some(unescape(rest.get(..rest.find('"')?)?)); + } + let open = format!("<{name}>"); + let i = entry.find(&open)?; + let rest = entry.get(i + open.len()..)?; + Some(unescape(rest.get(..rest.find('<')?)?)) +} + +fn unescape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some(i) = rest.find('&') { + out.push_str(rest.get(..i).unwrap_or_default()); + let tail = rest.get(i..).unwrap_or_default(); + let Some(semi) = tail.find(';').filter(|&n| n <= 10) else { + out.push('&'); + rest = tail.get(1..).unwrap_or_default(); + continue; + }; + let ent = tail.get(1..semi).unwrap_or_default(); + match ent { + "amp" => out.push('&'), + "lt" => out.push('<'), + "gt" => out.push('>'), + "quot" => out.push('"'), + "apos" => out.push('\''), + _ => { + let code = ent.strip_prefix("#x").and_then(|h| u32::from_str_radix(h, 16).ok()).or_else(|| ent.strip_prefix('#').and_then(|d| d.parse().ok())); + match code.and_then(char::from_u32) { + Some(c) => out.push(c), + None => out.push_str(tail.get(..=semi).unwrap_or_default()), + } + } + } + rest = tail.get(semi + 1..).unwrap_or_default(); + } + out.push_str(rest); + out.trim().to_string() +} + +/// A frame rate as XMP writes it: `f44100`, or `f30000s1001` (rate over scale). +fn xmp_rate(text: &str) -> Option { + let t = text.trim().strip_prefix('f')?; + let (num, den) = t.split_once('s').unwrap_or((t, "1")); + let (n, d): (f64, f64) = (num.parse().ok()?, den.parse().ok()?); + (n > 0.0 && d > 0.0).then_some(n / d) +} + +/// The markers of an XMP packet's `xmpDM:markers` entries. +fn xmp_markers(packet: &[u8], file_rate: f64) -> Vec { + let text = String::from_utf8_lossy(packet.get(..packet.len().min(MAX_XMP)).unwrap_or_default()); + let mut out = Vec::new(); + // The frame rate belongs to the track an entry follows. + let mut rate = file_rate; + for entry in text.split("().ok()) else { continue }; + let length = xmp_value(entry, "xmpDM:duration").and_then(|v| v.trim().parse::().ok()).unwrap_or(0.0); + let comment = xmp_value(entry, "xmpDM:name").unwrap_or_default(); + out.push(Marker { time: secs(start, rate), duration: secs(length, rate), comment, label: Label::None, ..Default::default() }); + if out.len() >= MAX_MARKERS { + break; + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn chunk(id: &[u8; 4], body: &[u8]) -> Vec { + let mut v = id.to_vec(); + v.extend((body.len() as u32).to_le_bytes()); + v.extend(body); + if body.len() % 2 == 1 { + v.push(0); + } + v + } + + fn fmt(rate: u32) -> Vec { + let mut b = Vec::new(); + b.extend(1u16.to_le_bytes()); // PCM + b.extend(1u16.to_le_bytes()); // mono + b.extend(rate.to_le_bytes()); + b.extend((rate * 2).to_le_bytes()); + b.extend(2u16.to_le_bytes()); + b.extend(16u16.to_le_bytes()); + chunk(b"fmt ", &b) + } + + fn wav(chunks: &[Vec]) -> Vec { + let body: Vec = chunks.iter().flatten().copied().collect(); + let mut v = b"RIFF".to_vec(); + v.extend((body.len() as u32 + 4).to_le_bytes()); + v.extend(b"WAVE"); + v.extend(body); + v + } + + fn cue(points: &[(u32, u32)]) -> Vec { + let mut b = (points.len() as u32).to_le_bytes().to_vec(); + for (id, offset) in points { + b.extend(id.to_le_bytes()); + b.extend(0u32.to_le_bytes()); + b.extend(b"data"); + b.extend(0u32.to_le_bytes()); + b.extend(0u32.to_le_bytes()); + b.extend(offset.to_le_bytes()); + } + chunk(b"cue ", &b) + } + + fn labl(id: &[u8; 4], cue_id: u32, text: &str) -> Vec { + let mut b = cue_id.to_le_bytes().to_vec(); + b.extend(text.as_bytes()); + b.push(0); + chunk(id, &b) + } + + fn adtl(subs: &[Vec]) -> Vec { + let mut b = b"adtl".to_vec(); + b.extend(subs.iter().flatten()); + chunk(b"LIST", &b) + } + + #[test] + fn cue_points_are_named_by_the_adtl_list_and_timed_in_samples() { + let file = wav(&[ + fmt(44100), + chunk(b"data", &[0; 8]), + cue(&[(2, 88200), (1, 22050)]), + adtl(&[labl(b"labl", 1, "SEQ01_Prologue"), labl(b"labl", 2, "SEQ02_Vallee_P1_Agriculture")]), + ]); + let m = read(&file); + assert_eq!(m.len(), 2); + assert_eq!((m[0].comment.as_str(), m[0].time), ("SEQ01_Prologue", Tick::from_seconds_f64(0.5))); + assert_eq!((m[1].comment.as_str(), m[1].time), ("SEQ02_Vallee_P1_Agriculture", Tick::from_seconds_f64(2.0))); + } + + #[test] + fn a_note_names_a_cue_only_when_it_has_no_label_and_ltxt_gives_a_length() { + let mut ltxt = 1u32.to_le_bytes().to_vec(); + ltxt.extend(44100u32.to_le_bytes()); + ltxt.extend([0; 16]); + let file = wav(&[ + fmt(44100), + cue(&[(1, 0), (2, 44100)]), + adtl(&[labl(b"note", 1, "from note"), chunk(b"ltxt", <xt), labl(b"labl", 2, "named"), labl(b"note", 2, "ignored")]), + ]); + let m = read(&file); + assert_eq!((m[0].comment.as_str(), m[0].duration), ("from note", Tick::from_seconds_f64(1.0))); + assert_eq!(m[1].comment, "named"); + } + + const XMP_ELEMENTS: &str = r#"MarkersCuef44100441000One & Two13230022050Three"#; + + #[test] + fn xmp_markers_are_read_as_elements_in_frames_of_the_track_rate() { + let file = wav(&[fmt(48000), chunk(b"_PMX", XMP_ELEMENTS.as_bytes())]); + let m = read(&file); + assert_eq!(m.len(), 2); + // 44100 frames at f44100 is one second, whatever the file's own rate. + assert_eq!((m[0].comment.as_str(), m[0].time), ("One & Two", Tick::from_seconds_f64(1.0))); + assert_eq!((m[1].comment.as_str(), m[1].time, m[1].duration), ("Three", Tick::from_seconds_f64(3.0), Tick::from_seconds_f64(0.5))); + } + + #[test] + fn xmp_markers_are_read_as_attributes_and_use_the_file_rate_without_a_track_rate() { + let xmp = r#""#; + let m = read(&wav(&[fmt(48000), chunk(b"_PMX", xmp.as_bytes())])); + assert_eq!((m[0].comment.as_str(), m[0].time), ("Attr é", Tick::from_seconds_f64(0.5))); + } + + #[test] + fn a_marker_in_both_encodings_is_listed_once() { + let xmp = r#""#; + let file = wav(&[fmt(44100), cue(&[(1, 44100)]), adtl(&[labl(b"labl", 1, "Same")]), chunk(b"_PMX", xmp.as_bytes())]); + let names: Vec = read(&file).into_iter().map(|m| m.comment).collect(); + assert_eq!(names, ["Same", "Only XMP"]); + } + + #[test] + fn importing_a_wav_puts_its_markers_on_the_footage() { + let file = wav(&[ + fmt(44100), + chunk(b"data", &[0; 4 * 44100]), + cue(&[(1, 0), (2, 44100)]), + adtl(&[labl(b"labl", 1, "SEQ01_Prologue"), labl(b"labl", 2, "SEQ02_Vallee_P1_Agriculture")]), + ]); + let f = crate::probe::probe_bytes("markers.wav", file.into()).expect("probe"); + assert!(f.has_audio); + let names: Vec<&str> = f.markers.iter().map(|m| m.comment.as_str()).collect(); + assert_eq!(names, ["SEQ01_Prologue", "SEQ02_Vallee_P1_Agriculture"]); + assert_eq!(f.markers[1].time, Tick::from_seconds_f64(1.0)); + } + + #[test] + fn damaged_or_hostile_files_read_as_far_as_they_go_without_panicking() { + assert!(read(b"").is_empty()); + assert!(read(b"RIFF").is_empty()); + assert!(read(b"not a wav file at all").is_empty()); + let good = wav(&[fmt(44100), cue(&[(1, 44100)]), adtl(&[labl(b"labl", 1, "Kept")])]); + // Every truncation is safe. + for n in 0..good.len() { + let _ = read(&good[..n]); + } + // A cue count far beyond the chunk, and chunk sizes past the end of the file. + let mut huge = 0xFFFF_FFFFu32.to_le_bytes().to_vec(); + huge.extend([0; 24]); + let m = read(&wav(&[fmt(44100), chunk(b"cue ", &huge)])); + assert!(m.len() <= 1); + let mut lying = good.clone(); + lying.extend(b"LIST\xff\xff\xff\xffadtl"); + assert_eq!(read(&lying).len(), 1); + // No sample rate: times are zero, not a division by zero. + let m = read(&wav(&[cue(&[(1, 44100)])])); + assert_eq!(m[0].time, Tick::ZERO); + } + + /// A chunk whose size field says `field` (an RF64 file's `0xFFFFFFFF`) while its body is `body`. + fn chunk_with_size_field(id: &[u8; 4], field: u32, body: &[u8]) -> Vec { + let mut v = id.to_vec(); + v.extend(field.to_le_bytes()); + v.extend(body); + if body.len() % 2 == 1 { + v.push(0); + } + v + } + + /// An RF64 `ds64` chunk: the `data` chunk's size and a table of other chunks' sizes. + fn ds64_chunk(data_size: u64, table: &[([u8; 4], u64)]) -> Vec { + let mut b = 0u64.to_le_bytes().to_vec(); // riff size (not used) + b.extend(data_size.to_le_bytes()); + b.extend(0u64.to_le_bytes()); // sample count + b.extend((table.len() as u32).to_le_bytes()); + for (id, size) in table { + b.extend(id); + b.extend(size.to_le_bytes()); + } + chunk(b"ds64", &b) + } + + fn rf64(chunks: &[Vec]) -> Vec { + let body: Vec = chunks.iter().flatten().copied().collect(); + let mut v = b"RF64".to_vec(); + v.extend(u32::MAX.to_le_bytes()); + v.extend(b"WAVE"); + v.extend(body); + v + } + + #[test] + fn rf64_markers_after_a_data_chunk_of_sentinel_size_are_read() { + let audio = [0u8; 8]; + let file = rf64(&[ + ds64_chunk(audio.len() as u64, &[]), + fmt(44100), + chunk_with_size_field(b"data", u32::MAX, &audio), + cue(&[(1, 44100)]), + adtl(&[labl(b"labl", 1, "After the audio")]), + ]); + let m = read(&file); + assert_eq!(m.len(), 1); + assert_eq!((m[0].comment.as_str(), m[0].time), ("After the audio", Tick::from_seconds_f64(1.0))); + } + + #[test] + fn rf64_sizes_of_other_chunks_come_from_the_ds64_table() { + let junk = [7u8; 6]; + let tail = || [cue(&[(1, 8000)]), adtl(&[labl(b"labl", 1, "Found")])].concat(); + let with_entry = rf64(&[ds64_chunk(0, &[(*b"junk", junk.len() as u64)]), fmt(8000), chunk_with_size_field(b"junk", u32::MAX, &junk), tail()]); + assert_eq!(read(&with_entry)[0].comment, "Found"); + // Without the table entry the sentinel is all there is, and what follows is not reached. + let without = rf64(&[ds64_chunk(0, &[]), fmt(8000), chunk_with_size_field(b"junk", u32::MAX, &junk), tail()]); + assert!(read(&without).is_empty()); + } + + #[test] + fn hostile_rf64_size_tables_read_as_far_as_they_go_without_panicking() { + // sizes that overflow when added to an offset + for data in [u64::MAX, u64::MAX - 7, 1 << 63] { + let file = rf64(&[ds64_chunk(data, &[(*b"junk", u64::MAX)]), fmt(8000), chunk_with_size_field(b"data", u32::MAX, &[0; 4]), cue(&[(1, 0)])]); + assert!(read(&file).is_empty()); + } + // a table that claims far more entries than its chunk holds + let mut liar = ds64_chunk(0, &[]); + liar[8 + 24..8 + 28].copy_from_slice(&u32::MAX.to_le_bytes()); + let _ = read(&rf64(&[liar, fmt(8000), cue(&[(1, 0)])])); + // every truncation of a good RF64 file is safe + let good = + rf64(&[ds64_chunk(4, &[]), fmt(44100), chunk_with_size_field(b"data", u32::MAX, &[0; 4]), cue(&[(1, 44100)]), adtl(&[labl(b"labl", 1, "Kept")])]); + assert_eq!(read(&good).len(), 1); + for n in 0..good.len() { + let _ = read(&good[..n]); + } + } + + #[test] + fn a_huge_adtl_list_is_read_in_linear_time_and_only_for_the_cue_points_that_exist() { + // Records for ids that have no cue point are skipped, not stored, and the number looked at is capped. + let junk = |n: u32| (1000..1000 + n).map(|id| labl(b"labl", id, "n")).collect::>(); + let started = std::time::Instant::now(); + // the real label comes first: read + let mut subs = vec![labl(b"labl", 1, "Real")]; + subs.extend(junk(100_000)); + let m = read(&wav(&[fmt(44100), cue(&[(1, 44100)]), adtl(&subs)])); + assert_eq!(m[0].comment, "Real"); + // it comes after more records than are looked at: not read, and the marker is still there + let mut subs = junk(MAX_ADTL_RECORDS as u32 + 10); + subs.push(labl(b"labl", 1, "Too late")); + let m = read(&wav(&[fmt(44100), cue(&[(1, 44100)]), adtl(&subs)])); + assert_eq!((m.len(), m[0].comment.as_str()), (1, "")); + assert!(started.elapsed() < std::time::Duration::from_secs(5), "took {:?}", started.elapsed()); + } +} diff --git a/crates/project/src/build.rs b/crates/project/src/build.rs index b78e3d8c1..3ff61ba7d 100644 --- a/crates/project/src/build.rs +++ b/crates/project/src/build.rs @@ -596,6 +596,22 @@ pub fn shape_simple_op(ids: &mut Ids, kind: &str) -> Option { .with(ids.prop("phase", "Temporal Phase", Value::Scalar(0.0)).with_ui(ParamUi::Angle)) .with(wiggle_spatial_phase(ids)) .with(ids.prop("seed", "Random Seed", Value::Scalar(0.0))), + "wiggler" | "wiggleTransform" => { + // The Transform group holds how far each transformation wiggles. + let tr = ids + .group("transform", "Transform") + .with(ids.prop("anchor", "Anchor Point", Value::Vec2([0.0, 0.0])).with_ui(ParamUi::Point)) + .with(ids.prop("position", "Position", Value::Vec2([0.0, 0.0])).with_ui(ParamUi::Point)) + .with(ids.prop("scale", "Scale", Value::Vec2([0.0, 0.0])).with_ui(ParamUi::Percent)) + .with(ids.prop("rotation", "Rotation", Value::Scalar(0.0)).with_ui(ParamUi::Angle)); + indexed(ids.group("wiggler", "Wiggle Transform 1")) + .with(ids.prop("speed", "Wiggles/Second", Value::Scalar(2.0))) + .with(ids.prop("correlation", "Correlation", Value::Scalar(50.0)).with_ui(ParamUi::Percent)) + .with(ids.prop("phase", "Temporal Phase", Value::Scalar(0.0)).with_ui(ParamUi::Angle)) + .with(ids.prop("spatialPhase", "Spatial Phase", Value::Scalar(0.0)).with_ui(ParamUi::Angle)) + .with(ids.prop("seed", "Random Seed", Value::Scalar(0.0))) + .with(tr) + } "merge" => indexed(ids.group("merge", "Merge Paths 1")).with(ids.prop("mode", "Mode", Value::Enum(0)).with_ui(popup(&[ "Merge", "Add", @@ -681,10 +697,16 @@ pub fn light_options(ids: &mut Ids, kind: LightKind) -> PropGroup { } if kind == LightKind::Environment { // The environment image: a layer (footage, comp…) mapped as an equirectangular panorama; - // none = the comp's Environment Layer. + // none = the comp's Environment Layer, or a flat default environment of the light's + // colour × intensity when there is none. No Falloff: an environment light shines from + // every direction at once. Environment lights cast shadows too (After Effects' Light + // Settings shows Cast Shadows for them; not for Ambient) (#2436). return g .with(ids.prop("source", "Source", Value::Layer(None)).with_ui(ParamUi::Layer)) - .with(ids.prop("rotation", "Environment Rotation", Value::Scalar(0.0)).with_ui(ParamUi::Angle)); + .with(ids.prop("rotation", "Environment Rotation", Value::Scalar(0.0)).with_ui(ParamUi::Angle)) + .with(ids.prop("castsShadows", "Casts Shadows", Value::Bool(false)).with_ui(ParamUi::Checkbox)) + .with(ids.prop("shadowDarkness", "Shadow Darkness", Value::Scalar(100.0)).with_ui(ParamUi::Percent)) + .with(ids.prop("shadowDiffusion", "Shadow Diffusion", Value::Scalar(0.0)).with_ui(ParamUi::Pixels)); } if kind != LightKind::Ambient { g = g @@ -881,6 +903,14 @@ pub fn layer(project: &mut Project, comp: &Comp, name: &str, source: LayerSource _ => {} } } + // A footage layer starts with the markers its file carries (layer time is footage time). + let footage_markers = match &source { + LayerSource::Footage { item } => match project.item(*item).map(|i| &i.kind) { + Some(crate::ItemKind::Footage(f)) => f.markers.clone(), + _ => vec![], + }, + _ => vec![], + }; let has_audio = matches!(&source, LayerSource::Footage { item } if matches!(project.item(*item).map(|i| &i.kind), Some(crate::ItemKind::Footage(f)) if f.has_audio)) || matches!(source, LayerSource::Comp { .. }); if has_audio { @@ -910,7 +940,7 @@ pub fn layer(project: &mut Project, comp: &Comp, name: &str, source: LayerSource preserve_transparency: false, track_matte: None, parent: None, - markers: vec![], + markers: footage_markers, markers_locked: false, environment: false, environment_background: false, diff --git a/crates/project/src/comp.rs b/crates/project/src/comp.rs index 36069e637..aa4ff7201 100644 --- a/crates/project/src/comp.rs +++ b/crates/project/src/comp.rs @@ -87,8 +87,8 @@ pub struct Comp { pub display_start: Tick, pub background: [f32; 3], pub work_area: (Tick, Tick), - /// Layer #1 first (top of the stack). - pub layers: Vec, + /// Layer #1 first (top of the stack). Each layer is shared between undo states until edited. + pub layers: super::Layers, #[serde(default)] pub markers: Vec, pub shutter_angle: f64, @@ -188,7 +188,7 @@ impl Comp { display_start: Tick::ZERO, background: [0.0, 0.0, 0.0], work_area: (Tick::ZERO, duration), - layers: vec![], + layers: super::Layers::new(), markers: vec![], shutter_angle: 180.0, shutter_phase: -90.0, @@ -239,7 +239,7 @@ impl Comp { } pub fn layer_mut(&mut self, id: LayerId) -> Option<&mut Layer> { - self.layers.iter_mut().find(|l| l.id == id) + self.layers.find_mut(|l| l.id == id) } /// 1-based layer number (#) of a layer. diff --git a/crates/project/src/footage.rs b/crates/project/src/footage.rs index 391b1e17c..336272d77 100644 --- a/crates/project/src/footage.rs +++ b/crates/project/src/footage.rs @@ -1,4 +1,4 @@ -use super::settings::yes; +use super::settings::{Marker, yes}; use super::{ColorSpace, Deserialize, FrameRate, Serialize, Tick}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -52,6 +52,142 @@ pub enum AlphaMode { Ignore, } +/// Interpret Footage ▸ Cineon Settings ▸ Units: how the Converted Black and White Points are +/// shown and entered. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum CineonUnits { + /// 10-bit code values (0–1023). + #[default] + TenBit, + /// 8-bit values (0–255). + EightBit, + /// Floating point (0–1). + Float, +} + +impl CineonUnits { + pub const ALL: [CineonUnits; 3] = [CineonUnits::TenBit, CineonUnits::EightBit, CineonUnits::Float]; + + /// The id commands use: `10bit`, `8bit` or `float`. + pub fn id(self) -> &'static str { + match self { + CineonUnits::TenBit => "10bit", + CineonUnits::EightBit => "8bit", + CineonUnits::Float => "float", + } + } + + pub fn label(self) -> &'static str { + match self { + CineonUnits::TenBit => "10-bit", + CineonUnits::EightBit => "8-bit", + CineonUnits::Float => "Float", + } + } + + pub fn parse(s: &str) -> Option { + match s.to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() { + "10bit" | "10" => Some(CineonUnits::TenBit), + "8bit" | "8" => Some(CineonUnits::EightBit), + "float" | "32bit" => Some(CineonUnits::Float), + _ => None, + } + } + + /// A 10-bit value (0–1023) in these units. + pub fn from_ten_bit(self, v: f64) -> f64 { + match self { + CineonUnits::TenBit => v, + CineonUnits::EightBit => v * 255.0 / 1023.0, + CineonUnits::Float => v / 1023.0, + } + } + + /// A value in these units as a 10-bit value (0–1023). + pub fn to_ten_bit(self, v: f64) -> f64 { + match self { + CineonUnits::TenBit => v, + CineonUnits::EightBit => v * 1023.0 / 255.0, + CineonUnits::Float => v * 1023.0, + } + } +} + +/// Interpret Footage ▸ Cineon Settings of DPX and Cineon footage: how the file's logarithmic +/// (printing density) code values become the footage's pixels. Code values are counted in 10-bit +/// units whatever the file's bit depth. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct CineonSettings { + /// Logarithmic Conversion: convert the code values from log to the Current Gamma. When it + /// is off, the code values are shown as stored. + pub log_conversion: bool, + /// Converted Black Point: the level the 10 Bit Black Point becomes (10-bit units). + pub converted_black: f32, + /// Converted White Point: the level the 10 Bit White Point becomes (10-bit units). + pub converted_white: f32, + /// 10 Bit Black Point: the code value of minimum density. + pub black: f32, + /// 10 Bit White Point: the code value of maximum density. + pub white: f32, + /// Current Gamma: the gamma the converted values are encoded with (1: linear light). + pub gamma: f32, + /// Highlight Rolloff (0–100): values near and above the white point ramp off gradually + /// instead of clipping; 0 keeps over-range values. + pub rolloff: f32, + /// Units of the Converted Black and White Points. + pub units: CineonUnits, +} + +impl Default for CineonSettings { + fn default() -> Self { + CineonSettings { + log_conversion: false, + converted_black: 0.0, + converted_white: 1023.0, + black: 95.0, + white: 685.0, + gamma: 1.7, + rolloff: 20.0, + units: CineonUnits::TenBit, + } + } +} + +impl CineonSettings { + /// The highest 10-bit value. + pub const MAX: f32 = 1023.0; + + /// A code value (0–1 of the 10-bit range) converted from log to the Current Gamma and the + /// converted black and white levels (0–1; above 1 for over-range highlights). + pub fn convert(&self, v: f32) -> f32 { + let lin = cineon_log_to_lin(v, self.black, self.white, 1.0, self.gamma, self.rolloff); + let out = (self.converted_black + (self.converted_white - self.converted_black) * lin) / Self::MAX; + if out.is_finite() { out } else { 0.0 } + } +} + +/// Cineon log to linear, shared by Interpret Footage ▸ Cineon Settings and the Cineon Converter +/// effect. `v` is a code value as 0–1 of the 10-bit range; `black` and `white` are the 10 Bit +/// Black and White Points. The result is 0 at the black point and 1 at the white point, raised to +/// `scale / gamma`, with values above `1 - rolloff / 100` ramped off toward 1 (Highlight Rolloff +/// 0–100; 0 keeps over-range values). A Gamma below 0.01 is raised to 0.01. Cineon Settings passes +/// a `scale` of 1 (Gamma 1 gives linear light) and the Cineon Converter passes 1.7 (its default +/// Gamma 1.7 gives linear light). +pub fn cineon_log_to_lin(v: f32, black: f32, white: f32, scale: f32, gamma: f32, rolloff: f32) -> f32 { + // Kodak printing density: 0.002 density per code value, negative gamma 0.6. + let exposure = |code: f32| 10f32.powf((code - white) * 0.002 / 0.6); + let offset = exposure(black); + let mut lin = ((exposure(v * CineonSettings::MAX) - offset) / (1.0 - offset).max(1e-6)).max(0.0); + lin = lin.powf(scale / gamma.max(0.01)); + let knee = 1.0 - (rolloff / 100.0).clamp(0.0, 0.99); + if knee < 1.0 && lin > knee { + let w = 1.0 - knee; + lin = knee + w * (1.0 - (-(lin - knee) / w).exp()); + } + lin +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Footage { pub path: String, @@ -98,6 +234,9 @@ pub struct Footage { /// colour profile, Interpret As Linear Light and the working space don't apply. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub preserve_rgb: bool, + /// Interpret Footage ▸ Cineon Settings (DPX and Cineon files only; [`Footage::cineon_applies`]). + #[serde(default, skip_serializing_if = "is_default")] + pub cineon: CineonSettings, /// Data footage ([`FootageKind::Data`]): the file's text (JSON, CSV or TSV), kept in the /// project so expressions read it everywhere (and on the web). #[serde(default, skip_serializing_if = "Option::is_none")] @@ -115,6 +254,10 @@ pub struct Footage { /// timecode isn't read). It only relabels source time, as in After Effects. #[serde(default, skip_serializing_if = "Option::is_none")] pub start_timecode: Option, + /// Markers the file carries (a WAV's cue points and XMP markers), at footage time. A layer + /// made from the footage starts with them, and Update Markers From Source copies new ones. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub markers: Vec, } fn is_default(v: &T) -> bool { @@ -145,16 +288,29 @@ impl Default for Footage { invert_alpha: false, linear_light: false, preserve_rgb: false, + cineon: CineonSettings::default(), data: None, layer: None, page: 0, alphabetical: false, start_timecode: None, + markers: vec![], } } } impl Footage { + /// The file is DPX or Cineon (the formats Interpret Footage ▸ Cineon Settings applies to). + pub fn is_cineon_file(&self) -> bool { + let ext = std::path::Path::new(&self.path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase(); + matches!(ext.as_str(), "dpx" | "cin") + } + + /// Cineon Settings ▸ Logarithmic Conversion applies: it is on and the file is DPX or Cineon. + pub fn cineon_applies(&self) -> bool { + self.cineon.log_conversion && self.is_cineon_file() + } + /// Interpret As Linear Light applies (Preserve RGB passes the file's values through). pub fn linear_light_applies(&self) -> bool { self.linear_light && !self.preserve_rgb diff --git a/crates/project/src/item.rs b/crates/project/src/item.rs index 22759e27d..b2b64ed2c 100644 --- a/crates/project/src/item.rs +++ b/crates/project/src/item.rs @@ -267,10 +267,11 @@ impl Project { /// (NaN or infinity, written as `null`, and nesting too deep to parse) are refused on every /// save; debug builds also read the text back. pub fn to_file_json(&self) -> Result { - self.to_file_json_with_view(&SavedView::default()) + self.to_file_json_with_view(&SavedView::default(), false) } - /// [`Project::to_file_json`] that also records the open compositions ([`SavedView`]). - pub fn to_file_json_with_view(&self, view: &SavedView) -> Result { + /// [`Project::to_file_json`] that also records the open compositions ([`SavedView`]), on one + /// line when `compact` (Settings ▸ Project ▸ Save Compact Project Files). + pub fn to_file_json_with_view(&self, view: &SavedView, compact: bool) -> Result { if self.schema > SCHEMA_VERSION { return Err(ProjectError::Invalid(format!( "the project can't be saved: it holds a value a project file can't store (project schema {} is newer than this version supports ({SCHEMA_VERSION}))", @@ -289,7 +290,8 @@ impl Project { let file = File { saved_by: APP_VERSION, view, project: self }; file_check::check(&file) .map_err(|e| ProjectError::Invalid(format!("the project can't be saved: it holds a value a project file can't store ({e})")))?; - let text = serde_json::to_string_pretty(&file).map_err(|e| ProjectError::Invalid(format!("the project can't be saved: {e}")))?; + let text = if compact { serde_json::to_string(&file) } else { serde_json::to_string_pretty(&file) } + .map_err(|e| ProjectError::Invalid(format!("the project can't be saved: {e}")))?; #[cfg(debug_assertions)] 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})")))?; diff --git a/crates/project/src/layers.rs b/crates/project/src/layers.rs new file mode 100644 index 000000000..6119a5753 --- /dev/null +++ b/crates/project/src/layers.rs @@ -0,0 +1,209 @@ +//! A comp's layer stack with structural sharing. +//! +//! Every undoable edit keeps the previous project for undo, so the comp it touches is shared and +//! `Arc::make_mut` copies it. With the layers stored inline that copy is the whole stack, every +//! layer and every keyframe, so an edit costs time in proportion to the comp's size and a script +//! that sets many keys in a big comp goes quadratic. Here each layer sits behind its own `Arc`: +//! copying the stack copies pointers, and only the layer an edit actually changes is copied. +//! +//! The API mirrors the `Vec` it replaces. Serialized form is identical (a list of layers). + +use std::ops::{Index, IndexMut}; +use std::sync::Arc; + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +use super::layer::Layer; + +/// Layer #1 first (top of the stack). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Layers(Vec>); + +impl Layers { + pub const fn new() -> Self { + Self(Vec::new()) + } + pub fn with_capacity(n: usize) -> Self { + Self(Vec::with_capacity(n)) + } + pub fn len(&self) -> usize { + self.0.len() + } + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + pub fn iter(&self) -> impl DoubleEndedIterator + ExactSizeIterator + Clone { + self.0.iter().map(|l| &**l) + } + /// Mutable access to every layer: each one that is still shared with an undo state is copied, + /// so prefer [`Layers::get_mut`] (or `iter` to find, then `get_mut`) when only some change. + pub fn iter_mut(&mut self) -> impl DoubleEndedIterator + ExactSizeIterator { + self.0.iter_mut().map(Arc::make_mut) + } + /// Mutable access to just the layers `keep` accepts. `keep` reads each layer without copying + /// it; only the matching ones are copied (if shared). Use this, not `iter_mut().filter(..)`. + pub fn iter_mut_where<'a>(&'a mut self, mut keep: impl FnMut(&Layer) -> bool + 'a) -> impl Iterator + 'a { + self.0.iter_mut().filter(move |l| keep(l)).map(Arc::make_mut) + } + /// The first layer `pred` accepts, copied only if it is that one. Use this, not `iter_mut().find(..)`. + pub fn find_mut(&mut self, mut pred: impl FnMut(&Layer) -> bool) -> Option<&mut Layer> { + self.0.iter_mut().find(|l| pred(l)).map(Arc::make_mut) + } + pub fn get(&self, i: usize) -> Option<&Layer> { + self.0.get(i).map(|l| &**l) + } + pub fn get_mut(&mut self, i: usize) -> Option<&mut Layer> { + self.0.get_mut(i).map(Arc::make_mut) + } + pub fn first(&self) -> Option<&Layer> { + self.get(0) + } + pub fn last(&self) -> Option<&Layer> { + self.0.last().map(|l| &**l) + } + pub fn first_mut(&mut self) -> Option<&mut Layer> { + self.get_mut(0) + } + pub fn last_mut(&mut self) -> Option<&mut Layer> { + self.0.last_mut().map(Arc::make_mut) + } + pub fn push(&mut self, l: Layer) { + self.0.push(Arc::new(l)); + } + pub fn insert(&mut self, i: usize, l: Layer) { + self.0.insert(i, Arc::new(l)); + } + pub fn remove(&mut self, i: usize) -> Layer { + Arc::unwrap_or_clone(self.0.remove(i)) + } + pub fn pop(&mut self) -> Option { + self.0.pop().map(Arc::unwrap_or_clone) + } + pub fn retain(&mut self, mut f: impl FnMut(&Layer) -> bool) { + self.0.retain(|l| f(l)); + } + pub fn swap(&mut self, a: usize, b: usize) { + self.0.swap(a, b); + } + pub fn truncate(&mut self, n: usize) { + self.0.truncate(n); + } + pub fn clear(&mut self) { + self.0.clear(); + } + pub fn reverse(&mut self) { + self.0.reverse(); + } + pub fn sort_by_key(&mut self, mut f: impl FnMut(&Layer) -> K) { + self.0.sort_by_key(|l| f(l)); + } + pub fn sort_by(&mut self, mut f: impl FnMut(&Layer, &Layer) -> std::cmp::Ordering) { + self.0.sort_by(|a, b| f(a, b)); + } + pub fn rotate_left(&mut self, n: usize) { + self.0.rotate_left(n); + } + pub fn rotate_right(&mut self, n: usize) { + self.0.rotate_right(n); + } + /// `layers[range].rotate_left(n)` (a slice of `Arc`s can't be handed out as a `&mut [Layer]`). + pub fn rotate_range_left(&mut self, r: impl std::slice::SliceIndex<[Arc], Output = [Arc]>, n: usize) { + if let Some(s) = self.0.get_mut(r) { + s.rotate_left(n); + } + } + pub fn rotate_range_right(&mut self, r: impl std::slice::SliceIndex<[Arc], Output = [Arc]>, n: usize) { + if let Some(s) = self.0.get_mut(r) { + s.rotate_right(n); + } + } + pub fn split_off(&mut self, at: usize) -> Layers { + Layers(self.0.split_off(at)) + } + pub fn append(&mut self, other: &mut Layers) { + self.0.append(&mut other.0); + } + /// Remove a range of layers and return them (owned). + pub fn drain(&mut self, r: impl std::ops::RangeBounds) -> std::vec::IntoIter { + self.0.drain(r).map(Arc::unwrap_or_clone).collect::>().into_iter() + } + /// Whether `self` and `other` hold the very same (unchanged) layer at `i`: a cheap "did this + /// layer change" test between two project states. + pub fn same_at(&self, other: &Layers, i: usize) -> bool { + matches!((self.0.get(i), other.0.get(i)), (Some(a), Some(b)) if Arc::ptr_eq(a, b)) + } + /// The shared layers themselves, for parallel iteration (`rayon` over `&[Arc]`). + pub fn as_arcs(&self) -> &[Arc] { + &self.0 + } + pub fn to_vec(&self) -> Vec { + self.iter().cloned().collect() + } +} + +impl Index for Layers { + type Output = Layer; + fn index(&self, i: usize) -> &Layer { + &self.0[i] + } +} + +impl IndexMut for Layers { + fn index_mut(&mut self, i: usize) -> &mut Layer { + Arc::make_mut(&mut self.0[i]) + } +} + +impl From> for Layers { + fn from(v: Vec) -> Self { + Self(v.into_iter().map(Arc::new).collect()) + } +} + +impl FromIterator for Layers { + fn from_iter>(it: I) -> Self { + Self(it.into_iter().map(Arc::new).collect()) + } +} + +impl Extend for Layers { + fn extend>(&mut self, it: I) { + self.0.extend(it.into_iter().map(Arc::new)); + } +} + +impl IntoIterator for Layers { + type Item = Layer; + type IntoIter = std::iter::Map>, fn(Arc) -> Layer>; + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter().map(Arc::unwrap_or_clone as fn(Arc) -> Layer) + } +} + +impl<'a> IntoIterator for &'a Layers { + type Item = &'a Layer; + type IntoIter = std::iter::Map>, fn(&'a Arc) -> &'a Layer>; + fn into_iter(self) -> Self::IntoIter { + self.0.iter().map(|l| &**l) + } +} + +impl<'a> IntoIterator for &'a mut Layers { + type Item = &'a mut Layer; + type IntoIter = std::iter::Map>, fn(&'a mut Arc) -> &'a mut Layer>; + fn into_iter(self) -> Self::IntoIter { + self.0.iter_mut().map(Arc::make_mut) + } +} + +impl Serialize for Layers { + fn serialize(&self, s: S) -> Result { + s.collect_seq(self.iter()) + } +} + +impl<'de> Deserialize<'de> for Layers { + fn deserialize>(d: D) -> Result { + Vec::::deserialize(d).map(Layers::from) + } +} diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 8e1409c37..3aa855835 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -1,8 +1,8 @@ //! The EffectCraft document model. //! //! `Project` → items (folders, compositions, footage, solids) → `Comp` → `Layer` → property tree -//! ([`props`]). Compositions sit behind `Arc` so undo snapshots share everything that did not -//! change. +//! ([`props`]). Compositions, and each layer within them ([`Layers`]), sit behind `Arc` so undo +//! snapshots share everything that did not change. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -33,12 +33,14 @@ mod comp; mod footage; mod item; mod layer; +mod layers; mod settings; pub use comp::{Comp, Guide, Renderer, Resolution}; -pub use footage::{AlphaMode, FieldOrder, Footage, FootageKind, Proxy, Solid, SourceLayer}; +pub use footage::{AlphaMode, CineonSettings, CineonUnits, FieldOrder, Footage, FootageKind, Proxy, Solid, SourceLayer, cineon_log_to_lin}; pub use item::{Item, ItemId, ItemKind, LayerId}; pub use layer::{AutoOrient, FrameBlend, Layer, LayerSource, LightKind, MatteKind, PrimitiveKind, Quality, Sampling, Switches, TrackMatte}; +pub use layers::Layers; pub use settings::{ APP_VERSION, BitDepth, ColorEngine, CuePoint, HdrMode, Marker, ProjectError, ProjectSettings, SCHEMA_EFFECT_SPACE, SCHEMA_VERSION, SavedView, TimeDisplayStyle, is_newer_version, saved_by, saved_view, diff --git a/crates/project/src/render_queue.rs b/crates/project/src/render_queue.rs index 777b7e289..1a829630c 100644 --- a/crates/project/src/render_queue.rs +++ b/crates/project/src/render_queue.rs @@ -442,9 +442,18 @@ impl RenderSettings { pub fn frame_time(&self, comp: &Comp, i: u64) -> Tick { self.rate(comp).tick_of(self.first_frame(comp) + i as i64) } - pub fn output_size(&self, comp: &Comp) -> (u32, u32) { + pub fn effective_scale(&self, comp: &Comp) -> f64 { let s = self.resolution.clamp(0.01, 4.0); - (((comp.width as f64 * s).round() as u32).max(1), ((comp.height as f64 * s).round() as u32).max(1)) + let max = f64::from(crate::limits::MAX_SIZE); + s.min(max / f64::from(comp.width.max(1))).min(max / f64::from(comp.height.max(1))) + } + pub fn output_size(&self, comp: &Comp) -> (u32, u32) { + let s = self.effective_scale(comp); + // The scale is clamped so neither side exceeds the comp limit, keeping the aspect + // ratio: a 30 000 px side at 4x renders at 1x instead of cropping to a corner. + let w = ((comp.width as f64 * s).round() as u32).max(1); + let h = ((comp.height as f64 * s).round() as u32).max(1); + (w, h) } pub fn resolution_label(&self) -> &'static str { match self.resolution { @@ -779,15 +788,18 @@ impl Crop { if self.use_roi && let Some([x, y, rw, rh]) = self.roi { - // The quad arrives unvalidated (control channel or a hand-edited project): - // clamp like the sibling edge fields (plus/minus 20 000) and subtract - // without overflow. - let conv = |v: f64| v.round().clamp(-20_000.0, 20_000.0) as i32; - let l = conv(x * scale); - let t = conv(y * scale); - let r = (w as i32).saturating_sub(conv((x + rw) * scale)); - let b = (h as i32).saturating_sub(conv((y + rh) * scale)); - return (t, l, b, r); + // The quad arrives unvalidated (control channel or a hand-edited project). As in + // After Effects, the region of interest lies inside the frame: each converted edge + // is clamped to [0, w] / [0, h] (inf clamps to a side, NaN converts to 0), and the + // far edge never passes the near one. Every edge then lies inside the frame, so the + // crop can't be larger than the frame and no subtraction can overflow. + let (wi, hi) = (i32::try_from(w).unwrap_or(i32::MAX), i32::try_from(h).unwrap_or(i32::MAX)); + let conv = |v: f64, side: i32| v.round().clamp(0.0, f64::from(side)) as i32; + let l = conv(x * scale, wi); + let t = conv(y * scale, hi); + let r = conv((x + rw) * scale, wi).max(l); + let b = conv((y + rh) * scale, hi).max(t); + return (t, l, hi - b, wi - r); } (self.top, self.left, self.bottom, self.right) } @@ -1126,7 +1138,7 @@ impl OutputModule { /// rounding). pub fn output_size(&self, comp: &Comp, settings: &RenderSettings) -> (u32, u32) { let (w, h) = settings.output_size(comp); - let (w, h) = self.frame_size(w, h, settings.resolution.clamp(0.01, 4.0)); + let (w, h) = self.frame_size(w, h, settings.effective_scale(comp)); self.format.coded_size(w, h) } /// Every setting as `(label, value)` lines (render logs). @@ -1659,8 +1671,8 @@ mod tests { #[test] fn hostile_roi_cannot_overflow_crop_math() { - // `-1e12` saturates to `i32::MIN` on conversion: `w as i32 - l` must saturate, - // not overflow, and still yield a usable (degenerate) frame size. + // `-1e12` is clamped into the frame on conversion: it must not overflow and must still + // yield a usable (degenerate) frame size. let c = Comp::new(1920, 1080, FrameRate::new(30, 1), Tick::from_seconds_f64(1.0)); let rs = RenderSettings::default(); let mut m = OutputModule::for_format(OutputFormat::PngSequence); @@ -1673,6 +1685,66 @@ mod tests { assert_eq!(m.output_size(&c, &half), (400, 200)); } + #[test] + fn output_size_never_exceeds_the_comp_limit() { + // The effective scale is clamped so neither side exceeds the limit (exporter + // `w * h * 4` would overflow at 120 000 px sides); the pipeline renders at the + // same scale, so it never requests a frame larger than this reports. + let rs = RenderSettings { resolution: 4.0, ..Default::default() }; + let m = OutputModule::for_format(OutputFormat::PngSequence); + let square = Comp::new(30_000, 30_000, FrameRate::new(30, 1), Tick::from_seconds_f64(1.0)); + assert_eq!(rs.effective_scale(&square), 1.0); + assert_eq!(rs.output_size(&square), (30_000, 30_000)); + assert_eq!(m.output_size(&square, &rs), (30_000, 30_000)); + // Wide comps keep their aspect ratio instead of clamping each side on its own. + let wide = Comp::new(30_000, 15_000, FrameRate::new(30, 1), Tick::from_seconds_f64(1.0)); + assert_eq!(rs.effective_scale(&wide), 1.0); + assert_eq!(rs.output_size(&wide), (30_000, 15_000)); + assert_eq!(m.output_size(&wide, &rs), (30_000, 15_000)); + // A mid-size comp renders at the largest scale that still fits. + let mid = Comp::new(20_000, 10_000, FrameRate::new(30, 1), Tick::from_seconds_f64(1.0)); + assert_eq!(mid.width as f64 * rs.effective_scale(&mid), 30_000.0); + assert_eq!(rs.output_size(&mid), (30_000, 15_000)); + } + + #[test] + fn valid_wide_roi_endpoints_are_preserved() { + // A supported 30 000 x 100 frame at scale 1: ROI [10000, 0, 20000, 100] spans x 10 000..30 000 + // and must give 20 000 x 100 (the absolute right edge 30 000 exceeds the old +/-20 000 clamp). + let c = Comp::new(30_000, 100, FrameRate::new(30, 1), Tick::from_seconds_f64(1.0)); + let rs = RenderSettings::default(); + let mut m = OutputModule::for_format(OutputFormat::PngSequence); + m.crop = Crop { enabled: true, use_roi: true, roi: Some([10_000.0, 0.0, 20_000.0, 100.0]), ..Default::default() }; + assert_eq!(m.output_size(&c, &rs), (20_000, 100)); + assert_eq!(m.crop.edges(30_000, 100, 1.0), (0, 10_000, 0, 0)); + // Scale 4.0 pushes a valid edge to 120 000 px (30 000 x 4); the left edge 40 000 and the + // right edge 120 000 are both beyond 20 000 and must survive. + assert_eq!(m.crop.edges(120_000, 400, 4.0), (0, 40_000, 0, 0)); + assert_eq!(m.frame_size(120_000, 400, 4.0), (80_000, 400)); + // Hostile quads stay inside the frame: never empty, never larger than the frame, so the + // export can't be asked for an unbounded crop (an ROI reaching far outside the comp + // used to pad the frame instead). + let (fw, fh) = (1920, 1080); + let hostile = [ + [1e300, 1e300, 1e300, 1e300], + [-1e300, -1e300, 1e300, 1e300], + [-1e6, -1e6, 2e6 + 1920.0, 2e6 + 1080.0], + [-1e12, -1e12, -1e12, -1e12], + [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, f64::NAN], + [f64::NEG_INFINITY, f64::NEG_INFINITY, f64::INFINITY, f64::INFINITY], + ]; + for roi in hostile { + m.crop.roi = Some(roi); + let (t, l, b, r) = m.crop.edges(fw, fh, 1.0); + assert!([t, l, b, r].iter().all(|e| *e >= 0), "{roi:?}: an edge pads the frame: {:?}", (t, l, b, r)); + let (w, h) = m.frame_size(fw, fh, 1.0); + assert!((1..=fw).contains(&w) && (1..=fh).contains(&h), "{roi:?}: {w}x{h} is outside 1x1..={fw}x{fh}"); + } + // An ROI covering more than the frame is the whole frame. + m.crop.roi = Some([-1e6, -1e6, 2e6 + 1920.0, 2e6 + 1080.0]); + assert_eq!(m.frame_size(fw, fh, 1.0), (fw, fh)); + } + #[test] fn uncompressed_quicktime_format() { let f = OutputFormat::from_name("uncompressed").unwrap(); diff --git a/crates/project/src/styles.rs b/crates/project/src/styles.rs index 83db5e581..2b2bdf92c 100644 --- a/crates/project/src/styles.rs +++ b/crates/project/src/styles.rs @@ -470,7 +470,7 @@ mod tests { let mut ids = Ids(&mut p.next_id); add_style(&mut a, &mut ids, "dropShadow", &gl, true); add_style(&mut b, &mut ids, "bevelEmboss", &gl, true); - comp.layers = vec![a, b]; + comp.layers = vec![a, b].into(); comp.layers[1].props.sub_mut(GROUP).unwrap().sub_mut(BLENDING).unwrap().get_mut("globalLightAngle").unwrap().value = Value::Scalar(45.0); assert!(needs_sync(&comp)); sync_comp(&mut comp); diff --git a/crates/project/src/tests.rs b/crates/project/src/tests.rs index f9ee16167..afd3d3f03 100644 --- a/crates/project/src/tests.rs +++ b/crates/project/src/tests.rs @@ -183,7 +183,7 @@ fn project_files_refuse_newer_schemas() { crate::SCHEMA_VERSION ); assert_eq!(p.to_file_json().unwrap_err().to_string(), expected); - assert_eq!(p.to_file_json_with_view(&crate::SavedView::default()).unwrap_err().to_string(), expected); + assert_eq!(p.to_file_json_with_view(&crate::SavedView::default(), false).unwrap_err().to_string(), expected); } #[test] @@ -197,7 +197,7 @@ fn saved_files_reopen_in_pretty_and_compact_form() { 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], comp_times: [(cid, effectcraft_time::Tick(4_237_833_600))].into() }; - let text = p.to_file_json_with_view(&view).unwrap(); + let text = p.to_file_json_with_view(&view, false).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. @@ -291,6 +291,49 @@ fn comp_contains_ends_on_cycles_long_chains_and_shared_precomps() { assert!(!p.comp_contains(c[n - 1], c[0])); } +/// Interpret Footage ▸ Cineon Settings: the 10 Bit Black and White Points become the converted +/// points, Current Gamma 1 gives linear light, Highlight Rolloff 0 keeps over-range values and +/// a higher one ramps them off; the settings apply only to DPX and Cineon files, and projects +/// saved without them read the defaults. +#[test] +fn cineon_settings_convert_log_code_values() { + use crate::{CineonSettings, CineonUnits, Footage}; + let code = |v: f32| v / 1023.0; + let c = CineonSettings { log_conversion: true, rolloff: 0.0, ..Default::default() }; + assert!(c.convert(code(95.0)).abs() < 1e-6, "black point"); + assert!((c.convert(code(685.0)) - 1.0).abs() < 1e-5, "white point"); + // Code 400: (10^(-0.95) - 10^(-1.9667)) / (1 - 10^(-1.9667)) = 0.10251 linear, ^(1/1.7). + assert!((c.convert(code(400.0)) - 0.26190).abs() < 1e-4, "{}", c.convert(code(400.0))); + let linear = CineonSettings { gamma: 1.0, ..c }; + assert!((linear.convert(code(400.0)) - 0.10251).abs() < 1e-4, "{}", linear.convert(code(400.0))); + assert!(c.convert(code(1023.0)) > 1.5, "over range kept: {}", c.convert(code(1023.0))); + // The default rolloff (20) ramps the white point and above off, up to 1. + let rolled = CineonSettings { rolloff: 20.0, ..c }; + let (w, mid, top) = (rolled.convert(code(685.0)), rolled.convert(code(720.0)), rolled.convert(code(1023.0))); + assert!(w > 0.8 && w < mid && mid < top && top <= 1.0, "{w} {mid} {top}"); + let points = CineonSettings { converted_black: 64.0, converted_white: 940.0, ..c }; + assert!((points.convert(code(95.0)) - 64.0 / 1023.0).abs() < 1e-5); + assert!((points.convert(code(685.0)) - 940.0 / 1023.0).abs() < 1e-5); + // Units. + assert!((CineonUnits::EightBit.from_ten_bit(1023.0) - 255.0).abs() < 1e-9); + assert!((CineonUnits::Float.to_ten_bit(0.5) - 511.5).abs() < 1e-9); + assert_eq!(CineonUnits::parse("8-bit"), Some(CineonUnits::EightBit)); + assert!(CineonUnits::ALL.iter().all(|u| CineonUnits::parse(u.id()) == Some(*u))); + // DPX and Cineon files only, and only with Logarithmic Conversion on. + let on = CineonSettings { log_conversion: true, ..Default::default() }; + let dpx = Footage { path: "/m/plate.0001.DPX".into(), cineon: on, ..Default::default() }; + assert!(dpx.cineon_applies()); + assert!(Footage { path: "/m/scan.cin".into(), cineon: on, ..Default::default() }.cineon_applies()); + assert!(!Footage { path: "/m/still.png".into(), cineon: on, ..Default::default() }.cineon_applies()); + assert!(!Footage { path: "/m/plate.dpx".into(), ..Default::default() }.cineon_applies()); + // Saved only when changed; read back, and absent in older projects. + let plain = serde_json::to_value(Footage { path: "/m/a.dpx".into(), ..Default::default() }).unwrap(); + assert!(plain.get("cineon").is_none(), "{plain}"); + let saved = serde_json::to_string(&dpx).unwrap(); + assert_eq!(serde_json::from_str::(&saved).unwrap().cineon, on); + assert_eq!(serde_json::from_value::(plain).unwrap().cineon, CineonSettings::default()); +} + /// A Wiggle Paths 100 000 groups deep gets Spatial Phase on a 2 MiB stack. #[test] fn upgrading_deeply_nested_shape_groups_fits_a_small_stack() { diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index 9f2e4e755..fd2fd183b 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -23,6 +23,11 @@ pub use warp::{Sampling, WarpOpts, accumulate_warp, composite_warp, composite_wa pub type Px = [f32; 4]; +/// Maximum padded side in pixels — mirrors the project crate's `limits::MAX_SIZE` (30 000; +/// raster sits below it in the layering and may not depend on it, so the two must stay equal). +/// A padded side beyond the largest supported comp side has no render use. +pub const MAX_PADDED_SIDE: u32 = 30_000; + /// Clamp (colour ≤ alpha ≤ 1, NaN → 0) and round half up to `levels` steps (`inv` = 1 / levels): /// one pixel of an 8/16 bpc buffer. Values are non-negative after the clamp, so the integer /// conversion rounds like `round()` and vectorises. @@ -161,13 +166,28 @@ impl Image { } o } - /// Copy with `pad` transparent pixels on every side. + /// The pad [`Image::padded`] applies for a requested `pad`: clamped so neither output side + /// exceeds [`MAX_PADDED_SIDE`]. At most `(MAX_PADDED_SIDE - side) / 2`, so + /// `side + 2 · pad ≤ MAX_PADDED_SIDE` and overflow is unreachable by construction (a side + /// already past the cap gets pad 0). Callers that track where the pixels went (a buffer + /// offset) must move by this, not by the requested pad. + pub fn effective_pad(&self, pad: u32) -> u32 { + pad.min(MAX_PADDED_SIDE.saturating_sub(self.width) / 2).min(MAX_PADDED_SIDE.saturating_sub(self.height) / 2) + } + /// Copy with [`Image::effective_pad`] transparent pixels on every side. + /// + /// The clamp keeps the output within the largest image the engine supports (a max-size comp + /// frame), so an unbounded pad cannot overflow or size a buffer past that. It is not a memory + /// budget: callers that pad by a derived, possibly large amount bound the area themselves + /// (`DOF_PAD_BUDGET` in render's `three_d/compose.rs`, `fits()` on the GPU path) or reject an + /// oversized pad first (`padded_size`); legitimate bounded pads are byte-identical. pub fn padded(&self, pad: u32) -> Image { - let mut out = Image::new(self.width + 2 * pad, self.height + 2 * pad); + let eff = self.effective_pad(pad); + let mut out = Image::new(self.width + 2 * eff, self.height + 2 * eff); let ow = out.width as usize; for y in 0..self.height as usize { let src = &self.data[y * self.width as usize..(y + 1) * self.width as usize]; - let o = (y + pad as usize) * ow + pad as usize; + let o = (y + eff as usize) * ow + eff as usize; out.data[o..o + self.width as usize].copy_from_slice(src); } out @@ -222,7 +242,7 @@ impl Image { } /// Straight-alpha 8-bit RGBA (sRGB-encoded values as stored). pub fn to_rgba8(&self) -> Vec { - self.map_rgba8(|o, p| { + self.map_rgba8(|o, p, _, _| { let a = p[3].clamp(0.0, 1.0); let inv = if a > 0.0 { 1.0 / p[3] } else { 0.0 }; for c in 0..3 { @@ -231,9 +251,20 @@ impl Image { o[3] = (a * 255.0 + 0.5) as u8; }) } + /// [`Image::to_rgba8`] with the colour dithered ([`dither_u8`]); alpha is rounded. + pub fn to_rgba8_dithered(&self) -> Vec { + self.map_rgba8(|o, p, x, y| { + let a = p[3].clamp(0.0, 1.0); + let inv = if a > 0.0 { 1.0 / p[3] } else { 0.0 }; + for c in 0..3 { + o[c] = dither_u8(p[c] * inv, x, y); + } + o[3] = (a * 255.0 + 0.5) as u8; + }) + } /// Composite over an opaque background colour and return opaque 8-bit RGBA. pub fn to_rgba8_over(&self, bg: [f32; 3]) -> Vec { - self.map_rgba8(|o, p| { + self.map_rgba8(|o, p, _, _| { let k = 1.0 - p[3].clamp(0.0, 1.0); for c in 0..3 { o[c] = ((p[c] + bg[c] * k).clamp(0.0, 1.0) * 255.0 + 0.5) as u8; @@ -241,13 +272,23 @@ impl Image { o[3] = 255; }) } - /// 8-bit RGBA with each pixel written by `f`, row-parallel. - fn map_rgba8(&self, f: impl Fn(&mut [u8; 4], &Px) + Sync) -> Vec { + /// [`Image::to_rgba8_over`] with the colour dithered ([`dither_u8`]). + pub fn to_rgba8_over_dithered(&self, bg: [f32; 3]) -> Vec { + self.map_rgba8(|o, p, x, y| { + let k = 1.0 - p[3].clamp(0.0, 1.0); + for c in 0..3 { + o[c] = dither_u8(p[c] + bg[c] * k, x, y); + } + o[3] = 255; + }) + } + /// 8-bit RGBA with each pixel written by `f` (given its x and y), row-parallel. + fn map_rgba8(&self, f: impl Fn(&mut [u8; 4], &Px, u32, u32) + Sync) -> Vec { let mut out = vec![0u8; self.data.len() * 4]; let w = self.width.max(1) as usize; - out.par_chunks_mut(w * 4).zip(self.data.par_chunks(w)).for_each(|(o, row)| { - for (o, p) in o.as_chunks_mut::<4>().0.iter_mut().zip(row) { - f(o, p); + out.par_chunks_mut(w * 4).zip(self.data.par_chunks(w)).enumerate().for_each(|(y, (o, row))| { + for (x, (o, p)) in o.as_chunks_mut::<4>().0.iter_mut().zip(row).enumerate() { + f(o, p, x as u32, y as u32); } }); out @@ -322,6 +363,22 @@ pub fn hash_noise(x: u32, y: u32, seed: u32) -> f32 { (h & 0x00ff_ffff) as f32 / 16_777_216.0 } +/// Seed of the output dither: fixed, so a frame converts to the same bytes every time. +const DITHER_SEED: u32 = 0x5bd1_e995; + +/// A colour value in 0..1 as an 8-bit code, dithered by pixel position (#2022): the value is +/// scaled to 0..255 and rounded up or down against a per-pixel hash threshold, so the codes of +/// a smooth gradient average to the gradient instead of stepping in bands. A value already on +/// an 8-bit code (anything rendered at 8 bpc) comes out unchanged. Deterministic: depends only +/// on the value and the pixel position, never on a clock or a global generator. +#[inline] +pub fn dither_u8(v: f32, x: u32, y: u32) -> u8 { + let s = v.clamp(0.0, 1.0) * 255.0; + // The threshold stays inside (1e-4, 1 - 1e-4) so float error on an exact code can't flip it. + let t = hash_noise(x, y, DITHER_SEED) * (1.0 - 2e-4) + 1e-4; + (s + t).floor() as u8 +} + /// A single-channel coverage mask. #[derive(Clone, Debug, Default, PartialEq)] pub struct Mask { @@ -346,6 +403,35 @@ impl Mask { mod tests { use super::*; + #[test] + fn dither_keeps_exact_codes_and_averages_the_rest() { + // A value on an 8-bit code is that code at every position. + for code in 0..=255u32 { + let v = code as f32 / 255.0; + for (x, y) in [(0, 0), (1, 0), (17, 3), (1919, 1079), (4095, 4095)] { + assert_eq!(dither_u8(v, x, y) as u32, code, "code {code} at ({x}, {y})"); + } + } + // Out-of-range and NaN values saturate instead of wrapping. + assert_eq!((dither_u8(-3.0, 5, 5), dither_u8(7.0, 5, 5), dither_u8(f32::NAN, 5, 5)), (0, 255, 0)); + // A value between two codes lands on both, in proportion: the mean over a block is the value. + let v = (100.0 + 0.3) / 255.0; + let n = 64u32; + let sum: u32 = (0..n * n).map(|i| dither_u8(v, i % n, i / n) as u32).sum(); + let mean = sum as f32 / (n * n) as f32; + assert!((mean - 100.3).abs() < 0.03, "{mean}"); + // Deterministic. + assert_eq!(dither_u8(v, 9, 4), dither_u8(v, 9, 4)); + } + + #[test] + fn dithered_conversions_match_the_plain_ones_on_exact_codes() { + let src: Vec = vec![255, 128, 0, 255, 10, 20, 30, 255, 0, 0, 0, 255, 1, 2, 3, 255]; + let img = Image::from_rgba8(2, 2, &src); + assert_eq!(img.to_rgba8_dithered(), img.to_rgba8()); + assert_eq!(img.to_rgba8_over_dithered([0.0; 3]), img.to_rgba8_over([0.0; 3])); + } + #[test] fn rgba8_roundtrip() { let src: Vec = vec![255, 128, 0, 255, 10, 20, 30, 128, 0, 0, 0, 0, 1, 2, 3, 4]; @@ -384,4 +470,29 @@ mod tests { assert!((0.0..1.0).contains(&n)); } } + + #[test] + fn padded_bounds_hostile_pad_at_the_api() { + // Fail-before (#2092 class): before the clamp, a caller passing a hostile pad sized an + // oversized buffer here — the very OOM this guard exists to stop, now caught in padded(). + // (Wide-flat source so the clamped-to-cap output stays a test-sized allocation.) + let mut img = Image::new(29_990, 2); + img.set(29_989, 1, [1.0, 0.0, 0.0, 1.0]); + let out = img.padded(u32::MAX); + // Width binds first: eff = (MAX_PADDED_SIDE - 29 990) / 2 = 5. + assert_eq!((out.width, out.height), (MAX_PADDED_SIDE, 12)); + // The source lands intact at the clamped offset, not the requested one. + assert_eq!(out.get(29_994, 6), [1.0, 0.0, 0.0, 1.0]); + assert_eq!(out.get(0, 0), [0.0; 4]); // clamped pad region stays transparent + } + + #[test] + fn padded_leaves_legit_pads_untouched() { + // Anti-over-clamp: an ordinary pad must round exactly, byte for byte. + let mut img = Image::new(8, 6); + img.set(3, 2, [1.0, 0.0, 0.0, 1.0]); + let out = img.padded(4); + assert_eq!((out.width, out.height), (16, 14)); + assert_eq!(out.get(7, 6), [1.0, 0.0, 0.0, 1.0]); // (3+4, 2+4) + } } diff --git a/crates/render/src/accel.rs b/crates/render/src/accel.rs index accc1fc54..e6bbd2113 100644 --- a/crates/render/src/accel.rs +++ b/crates/render/src/accel.rs @@ -131,6 +131,14 @@ pub trait FxTarget { buf }); } + /// Give the buffer the pixel-grid phase the layer rectangle's cover would + /// ([`effectcraft_effects::Buf::align`]): grows left/top by less than 64 pixels. + fn align(&mut self, size: [f64; 2]) { + self.cpu(&mut |mut buf| { + buf.align(size); + buf + }); + } } /// The CPU effect target (GPU chains go through [`Accelerator::effects`]: upload, readback). @@ -184,8 +192,9 @@ impl<'a> Renderer<'a> { pub fn layer_aux(&self, ctx: &EvalCtx, layer: &Layer) -> Option> { match &layer.source { LayerSource::Footage { item } => { - let ItemKind::Footage(f) = &self.project.item(*item)?.kind else { return None }; - let a = self.footage.aux(*item, f, ctx.source_time(layer))?; + let item = ctx.media_item(layer, *item); + let ItemKind::Footage(f) = &self.project.item(item)?.kind else { return None }; + let a = self.footage.aux(item, f, ctx.source_time(layer))?; // Aux pixels per layer pixel (the layer is the footage at its nominal size). The // planes are only copied when that changes the scale. let scale = a.width as f64 / f.width as f64; @@ -332,8 +341,8 @@ impl<'a> Renderer<'a> { } /// Draw a collapsed precomp layer into `canvas` on the CPU. - pub fn draw_collapsed_cpu(&self, ctx: &EvalCtx<'a>, layer: &Layer, item: ItemId, canvas: &mut Image) { - self.draw_collapsed(ctx, layer, item, canvas, false); + pub fn draw_collapsed_cpu(&self, ctx: &EvalCtx<'a>, layer: &'a Layer, collapsed: &crate::Collapsed, canvas: &mut Image) { + self.draw_collapsed(ctx, layer, collapsed, canvas, false); } /// The layer's finished buffers (styles included) in the blending space. diff --git a/crates/render/src/audio.rs b/crates/render/src/audio.rs index 66b54b6d0..e2861340c 100644 --- a/crates/render/src/audio.rs +++ b/crates/render/src/audio.rs @@ -4,10 +4,13 @@ //! (Effect > Audio, see [`effectcraft_effects::audio_fx`]) run on each layer's audio before //! its levels; Backwards plays the layer's [in, out) span reversed. //! -//! Callers mix in short blocks (one video frame, or an audio device buffer). Effects and levels -//! are evaluated once per block. To make the result independent of block boundaries, layer -//! effects are run from a cleared state over a pre-roll of earlier audio and only the block's -//! tail is kept — so preview playback and export produce identical samples. +//! Callers mix in short blocks (one video frame, an audio device buffer, or a second of export). +//! Effect parameters are evaluated once per block. Animated Audio Levels, and animated effects +//! that work sample by sample (Stereo Mixer), are evaluated on a grid of comp samples every +//! millisecond instead, so keyframed fades ramp smoothly whatever the block size. To make the +//! result independent of block boundaries, layer effects are run from a cleared state over a +//! pre-roll of earlier audio and only the block's tail is kept — so preview playback and export +//! produce identical samples. //! //! Time-stretched layers are resampled nearest-sample; reversed (negative stretch) layers are //! silent for now. @@ -85,6 +88,20 @@ fn units_ceil(t: Tick, rate: u32) -> i64 { (n.div_euclid(d) + i128::from(n.rem_euclid(d) != 0)) as i64 } +/// Samples between evaluations of animated levels and per-sample effects: one millisecond, on +/// a grid of comp sample indices so the result does not depend on where a block starts. +fn param_step(rate: u32) -> i64 { + i64::from(rate / 1000).max(1) +} + +/// Does any property of the group change over time? +fn animated(g: &effectcraft_project::PropGroup) -> bool { + g.children.iter().any(|c| match c { + Node::Prop(p) => p.is_animated() || p.has_expression(), + Node::Group(sub) => animated(sub), + }) +} + fn sample_tick(i: i64, rate: u32) -> Tick { // Keep the requested sample when the rate does not divide the tick clock. let t = Tick::from_units(i, i64::from(rate)); @@ -189,21 +206,70 @@ impl MixEnv<'_> { /// `i0 .. i0 + n`. fn layer_audio(&self, l: &Layer, i0: i64, n: usize) -> Vec { let t = sample_tick(i0, self.rate); - let fx: Vec<(&str, Params)> = audio_effect_groups(l).map(|(id, g)| (id, self.params(l, g, t))).collect(); + let fx: Vec<(&str, &effectcraft_project::PropGroup, Params)> = audio_effect_groups(l).map(|(id, g)| (id, g, self.params(l, g, t))).collect(); if fx.is_empty() { return self.layer_raw(l, i0, n, false); } - let reversed = fx.iter().any(|(id, _)| audio_fx::reverses(id)); - let pre_s: f64 = fx.iter().map(|(id, p)| audio_fx::preroll(id, p)).sum(); + let reversed = fx.iter().any(|(id, _, _)| audio_fx::reverses(id)); + let pre_s: f64 = fx.iter().map(|(id, _, p)| audio_fx::preroll(id, p)).sum(); let pre = (pre_s * self.rate as f64).ceil() as usize; - let mut buf = self.layer_raw(l, i0 - pre as i64, n + pre, reversed); - let start = l.layer_time(sample_tick(i0 - pre as i64, self.rate)).seconds(); - for (id, p) in &fx { - audio_fx::process(id, p, &mut buf, self.rate, start); + let b0 = i0 - pre as i64; + let mut buf = self.layer_raw(l, b0, n + pre, reversed); + let start = l.layer_time(sample_tick(b0, self.rate)).seconds(); + for (id, g, p) in &fx { + if audio_fx::per_sample(id) && animated(g) { + self.process_stepped(l, id, g, &mut buf, b0); + } else { + audio_fx::process(id, p, &mut buf, self.rate, start); + } } buf.drain(..pre * 2); buf } + + /// Run per-sample effect `id` over `buf` (comp samples from `b0`) in pieces of the parameter + /// grid, each with the parameters at its first sample. + fn process_stepped(&self, l: &Layer, id: &str, g: &effectcraft_project::PropGroup, buf: &mut [f32], b0: i64) { + let step = param_step(self.rate); + let frames = buf.len() / 2; + let mut k = 0usize; + while k < frames { + let i = b0.saturating_add(k as i64); + let next = i.div_euclid(step).saturating_add(1).saturating_mul(step); + let end = usize::try_from(next.saturating_sub(b0)).unwrap_or(frames).clamp(k + 1, frames); + let t = sample_tick(i, self.rate); + if let Some(piece) = buf.get_mut(k * 2..end * 2) { + audio_fx::process(id, &self.params(l, g, t), piece, self.rate, l.layer_time(t).seconds()); + } + k = end; + } + } + + /// Linear gains (left, right) of the layer's Audio Levels for comp samples `i0 .. i0 + n`. + /// Animated levels are evaluated on the parameter grid and interpolated in dB in between. + fn level_gains(&self, l: &Layer, i0: i64, n: usize) -> Vec<[f32; 2]> { + let Some(pr) = l.props.sub("audio").and_then(|g| g.get("levels")) else { return vec![[1.0, 1.0]; n] }; + let db = |i: i64| self.ctx(sample_tick(i, self.rate)).value(l, pr).as_vec2(); + let gain = |[a, b]: [f64; 2]| [db_to_gain(a), db_to_gain(b)]; + if !pr.is_animated() && !pr.has_expression() { + return vec![gain(db(i0)); n]; + } + let step = param_step(self.rate); + let end = i0.saturating_add(n as i64); + let mut out = Vec::with_capacity(n); + let mut k = i0.div_euclid(step).saturating_mul(step); + let mut lo = db(k); + while k < end { + let hi = db(k.saturating_add(step)); + for i in k.max(i0)..k.saturating_add(step).min(end) { + let f = (i - k) as f64 / step as f64; + out.push(gain([lo[0] + (hi[0] - lo[0]) * f, lo[1] + (hi[1] - lo[1]) * f])); + } + k = k.saturating_add(step); + lo = hi; + } + out + } } /// Shared inputs for composition audio mixing, including nested compositions. @@ -234,25 +300,18 @@ fn mix_into(sources: &AudioSources<'_>, comp_id: ItemId, start: Tick, out: &mut if a >= b { continue; } - let t_a = sample_tick(s0 + a as i64, rate); - let (ll, lr) = levels(&env, l, t_a); - if ll == 0.0 && lr == 0.0 { + let gains = env.level_gains(l, s0 + a as i64, b - a); + if gains.iter().all(|g| *g == [0.0, 0.0]) { continue; } let buf = env.layer_audio(l, s0 + a as i64, b - a); - for (i, f) in buf.as_chunks::<2>().0.iter().enumerate() { - out[(a + i) * 2] += f[0] * ll; - out[(a + i) * 2 + 1] += f[1] * lr; + for (i, (f, g)) in buf.as_chunks::<2>().0.iter().zip(&gains).enumerate() { + out[(a + i) * 2] += f[0] * g[0]; + out[(a + i) * 2 + 1] += f[1] * g[1]; } } } -fn levels(env: &MixEnv, l: &Layer, t: Tick) -> (f32, f32) { - let Some(g) = l.props.sub("audio") else { return (1.0, 1.0) }; - let [a, b] = env.ctx(t).v2(l, g, "levels", [0.0, 0.0]); - (db_to_gain(a), db_to_gain(b)) -} - /// A min/max peak summary of interleaved stereo audio for waveform drawing: one entry per /// `bin` sample frames, `[min L, max L, min R, max R]`. pub fn peak_summary(samples: &[f32], bin: usize) -> Vec<[f32; 4]> { @@ -347,7 +406,7 @@ mod tests { pr.value = effectcraft_project::Value::Vec2([-6.0206, -96.0]); } let mut comp = comp; - comp.layers = vec![l1, l2]; + comp.layers = vec![l1, l2].into(); let cid = p.add_item("C", effectcraft_color::Label::Sandstone, None, ItemKind::Comp(comp.into())); assert!(comp_has_audio(&p, cid)); let rate = 1000; @@ -393,11 +452,11 @@ mod tests { let base = Comp::new(64, 64, FrameRate::new(25, 1), Tick::from_seconds_f64(2.0)); let tone = build::layer(&mut p, &base, "tone", LayerSource::Footage { item: fid }, (0, 0), None); let mut inner = base.clone(); - inner.layers = vec![tone]; + inner.layers = vec![tone].into(); let inner_id = p.add_item("Inner", effectcraft_color::Label::Sandstone, None, ItemKind::Comp(inner.into())); let pre = build::layer(&mut p, &base, "pre", LayerSource::Comp { item: inner_id }, (64, 64), None); let mut outer = base.clone(); - outer.layers = vec![pre]; + outer.layers = vec![pre].into(); let outer_id = p.add_item("Outer", effectcraft_color::Label::Sandstone, None, ItemKind::Comp(outer.into())); let rate = 8000; let t = Tick::from_seconds_f64(0.5); @@ -424,7 +483,7 @@ mod tests { let mut tone = build::layer(&mut p, &base, "tone", LayerSource::Footage { item: fid }, (0, 0), None); tone.stretch = stretch; let mut comp = base.clone(); - comp.layers = vec![tone]; + comp.layers = vec![tone].into(); let cid = p.add_item("C", effectcraft_color::Label::Sandstone, None, ItemKind::Comp(comp.into())); let n = 2000usize; let t = Tick::from_seconds_f64(0.5); diff --git a/crates/render/src/cache.rs b/crates/render/src/cache.rs index 28d2a8a89..b6f2e4076 100644 --- a/crates/render/src/cache.rs +++ b/crates/render/src/cache.rs @@ -458,7 +458,7 @@ fn time_dependent(layer: &Layer, effects: bool) -> bool { if matches!(layer.source, LayerSource::Shape) && let Some(c) = layer.props.sub("contents") { - return group_has(c, "wiggle"); + return group_has(c, "wiggle") || group_has(c, "wiggler"); } // Wiggly selectors move with time; Expression selectors evaluate per character (their // property value alone doesn't capture the result). @@ -479,6 +479,19 @@ fn group_has(g: &PropGroup, match_id: &str) -> bool { /// Feed every property of `g` that can change over time (keyframes or expression) as its value /// at the context time. The static structure is hashed separately. +/// Mix the collapsed-instance overrides that target `id` into a cache key. They replace values +/// without editing the layer, so a key without them would reuse another instance's buffer. +fn hash_overrides(h: &mut KeyHasher, ctx: &EvalCtx, id: effectcraft_project::LayerId) { + for o in ctx.property_overrides.iter().filter(|o| o.layer == id) { + o.prop.hash(h); + o.font.hash(h); + hash_debug(h, &o.value); + } + for m in ctx.media_overrides.iter().filter(|m| m.layer == id) { + m.item.hash(h); + } +} + fn hash_values(h: &mut KeyHasher, ctx: &EvalCtx, layer: &Layer, g: &PropGroup) { for c in &g.children { match c { @@ -605,8 +618,9 @@ fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, fo hash_debug(&mut h, s); } LayerSource::Text | LayerSource::Shape => {} - LayerSource::Footage { item } if footage || still_footage(ctx, *item) => { - let it = ctx.project.item(*item)?; + LayerSource::Footage { item } if footage || still_footage(ctx, ctx.media_item(layer, *item)) => { + let item = ctx.media_item(layer, *item); + let it = ctx.project.item(item)?; let ItemKind::Footage(f) = &it.kind else { return None }; hash_debug(&mut h, f); // A proxy (and its Use Proxy switch) changes the pixels. @@ -629,6 +643,10 @@ fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, fo scale.to_bits().hash(&mut h); draft.hash(&mut h); layer.id.hash(&mut h); + // Collapsed-instance overrides replace values without editing the nested layer, so mix them + // into the key: a Font override, effect parameter or any other source-affecting override + // must not reuse another instance's processed buffer. + hash_overrides(&mut h, ctx, layer.id); hash_debug(&mut h, &layer.source); // (Audio, Lock and Shy don't change pixels.) hash_debug(&mut h, &layer.switches.pixels()); diff --git a/crates/render/src/cache/dependencies.rs b/crates/render/src/cache/dependencies.rs index 5deffa4db..1a550f9c7 100644 --- a/crates/render/src/cache/dependencies.rs +++ b/crates/render/src/cache/dependencies.rs @@ -3,7 +3,7 @@ //! cyclic/over-budget graphs or expressions sampled at unknown times. use super::{KeyHasher, hash_debug, hash_values}; use crate::EvalCtx; -use effectcraft_project::{ItemId, ItemKind, Layer, LayerId, LayerSource, Node, PropGroup}; +use effectcraft_project::{ItemId, ItemKind, Layer, LayerId, LayerSource, LightKind, Node, PropGroup}; use std::hash::Hash; pub(super) fn hash(h: &mut KeyHasher, ctx: &EvalCtx, layer: &Layer, effects: Option) -> Option<()> { @@ -63,11 +63,68 @@ impl Dependencies<'_> { Some(()) } + /// What a referenced 3D model or primitive layer's solo render depends on: the layer itself + /// (geometry, materials, switches, in/out), its animated values and transform at the time, + /// the mesh item, the comp's camera (with its depth of field) and its lights at the time + /// (the lights are off in draft 3D, and the renderer setting changes the pixels), the model's + /// layer time while it plays an animation clip, and the in/out gate (outside the range the + /// read is transparent). An Environment light's image can be any layer's render: with one on + /// the read is not cached. Comp size and render scale are in the owner's key already. + fn soloed_model(&mut self, ctx: &EvalCtx, layer: &Layer) -> Option<()> { + self.remaining = self.remaining.checked_sub(1)?; + // Expressions sampled at other times: not cacheable, as in the ordinary path. + if (self.other_times || super::reads_other_times(layer, None)) && super::has_expression(&layer.props) { + return None; + } + hash_debug(self.h, layer); + hash_values(self.h, ctx, layer, &layer.props); + hash_debug(self.h, &ctx.world_matrix(layer)); + if let Some(item) = layer.source.item() { + hash_debug(self.h, &ctx.project.item(item)); + } + // The solo render goes through the comp's camera and lights. The camera's depth of + // field blurs it (focus, aperture, iris, highlights) — none of that is in the + // projection hashed above. + hash_debug(self.h, &ctx.camera()); + hash_debug(self.h, &crate::three_d::active_camera(ctx).dof); + hash_debug(self.h, &crate::three_d::light::lights_at(ctx)); + hash_debug(self.h, &ctx.comp.draft_3d); + hash_debug(self.h, &ctx.comp.renderer); + // An Environment light's image lights Advanced 3D models (its source can be any + // layer's render, or any environment layer) — too open-ended to hash: while one is + // on (and lighting: Draft 3D lights nothing), don't cache the read. + if ctx.comp.renderer == effectcraft_project::Renderer::Advanced3D + && !ctx.comp.draft_3d + && ctx.comp.layers.iter().any(|l| { + l.is_light() && l.switches.video && l.is_active_at(ctx.time) && matches!(l.source, LayerSource::Light { kind: LightKind::Environment }) + }) + { + return None; + } + // A model with an animation clip selected is posed at its layer time (Animation Speed + // and Looping are static, hashed above): every time is its own entry. Without one the + // pose is the same at every time: keep the static key. + if layer.props.sub("geometryOptions").map_or(0, |g| ctx.e(layer, g, "animation")) != 0 { + layer.layer_time(ctx.time).0.hash(self.h); + } + // Outside the layer's in/out range the read is transparent: fold the gate in, so a + // static model renders once inside it and again when it leaves. + layer.is_active_at(ctx.time).hash(self.h); + Some(()) + } + fn layer(&mut self, ctx: &EvalCtx, layer: &Layer) -> Option<()> { let id = (ctx.comp_id, layer.id); if self.path.len() > crate::MAX_FX_DEPTH || self.path.contains(&id) { return None; } + // A referenced 3D model or primitive layer renders through the comp's camera and lights + // (an effect layer parameter reads it that way — Create 3D Layer Instance): hash what + // moves its pixels, so the owner keeps a cache entry that changes when any of it does. + if layer.source.is_model() { + self.soloed_model(ctx, layer)?; + return Some(()); + } self.remaining = self.remaining.checked_sub(1)?; if (self.other_times || super::reads_other_times(layer, None)) && super::has_expression(&layer.props) { return None; @@ -76,6 +133,9 @@ impl Dependencies<'_> { // Include transforms/parents as well as pixels: selected-layer anchors and // continuously rasterized sources can depend on them. hash_debug(self.h, layer); + // A referenced layer's static values can be overridden by the collapsed instance without + // editing it (`hash_values` only sees animated ones), so the owner's key needs them too. + super::hash_overrides(self.h, ctx, layer.id); hash_values(self.h, ctx, layer, &layer.props); hash_debug(self.h, &ctx.world_matrix(layer)); // A cached owner never runs this layer's effects, so the comp camera and lights its @@ -88,6 +148,10 @@ impl Dependencies<'_> { } match layer.source { LayerSource::Solid { item } | LayerSource::Footage { item } | LayerSource::Comp { item } => { + let item = match layer.source { + LayerSource::Footage { .. } => ctx.media_item(layer, item), + _ => item, + }; let source = ctx.project.item(item); hash_debug(self.h, &source); if let Some(source) = source { diff --git a/crates/render/src/disk_cache.rs b/crates/render/src/disk_cache.rs index 8d6761516..4ea36d9b8 100644 --- a/crates/render/src/disk_cache.rs +++ b/crates/render/src/disk_cache.rs @@ -1278,7 +1278,7 @@ mod tests { let f = Footage { path: "/virtual.mov".into(), width: 16, height: 9, duration: Tick::from_seconds_f64(1.0), has_video: true, ..Default::default() }; let fid = p.add_item("virtual.mov", effectcraft_color::Label::None, None, ItemKind::Footage(f)); let mut comp = Comp::new(16, 9, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); - comp.layers = vec![build::layer(&mut p, &comp, "clip", LayerSource::Footage { item: fid }, (16, 9), None)]; + comp.layers = vec![build::layer(&mut p, &comp, "clip", LayerSource::Footage { item: fid }, (16, 9), None)].into(); let cid = p.add_item("C", effectcraft_color::Label::None, None, ItemKind::Comp(comp.into())); let id = |mark: u64| move |path: &str| (path == "/virtual.mov").then_some((8, mark)); diff --git a/crates/render/src/eval.rs b/crates/render/src/eval.rs index 14249900f..81fe45d6c 100644 --- a/crates/render/src/eval.rs +++ b/crates/render/src/eval.rs @@ -39,17 +39,33 @@ pub struct EvalCtx<'a> { /// Footage frames for expressions that read rendered pixels (`sampleImage`): the renderer's /// own source while rendering; `None` renders footage as transparent. pub footage: Option<&'a dyn crate::FootageSource>, + /// Property values overridden by the Essential Properties of a collapsed precomp instance. + pub property_overrides: &'a [crate::PropertyOverride], + /// Media Replacement values overridden by the Essential Properties of a collapsed precomp + /// instance. + pub media_overrides: &'a [crate::MediaOverride], } impl<'a> EvalCtx<'a> { pub fn new(project: &'a Project, comp_id: ItemId, comp: &'a Comp, time: Tick) -> EvalCtx<'a> { - EvalCtx { project, comp_id, comp, time, expr: None, footage: None } + EvalCtx { project, comp_id, comp, time, expr: None, footage: None, property_overrides: &[], media_overrides: &[] } } pub fn at(&self, time: Tick) -> EvalCtx<'a> { EvalCtx { time, ..*self } } /// Value of a property at the context time (keyframes, then expression). pub fn value(&self, layer: &Layer, prop: &Property) -> Value { + // A collapsed precomp instance's Essential Property override replaces the base value; + // its expression (if any) still runs, and sees the overrides of this context. + if let Some(o) = self.property_overrides.iter().rev().find(|o| o.layer == layer.id && o.prop == prop.uid) { + let v = o.value(layer, prop, self.time); + if prop.has_expression() + && let Some(h) = self.expr + { + return h.eval(self, layer, prop, &v).unwrap_or(v); + } + return v; + } // Separated Position reads as the combination of X/Y/Z Position. if prop.match_id == "position" && let Some(tr) = layer.transform() @@ -145,13 +161,15 @@ impl<'a> EvalCtx<'a> { /// Source pixel aspect relative to the comp's: non-square footage (or solids, or precomps /// with another pixel aspect) is stretched horizontally by this factor in the comp. pub fn par_ratio(&self, layer: &Layer) -> f64 { - let par = match &layer.source { - LayerSource::Footage { item } | LayerSource::Solid { item } | LayerSource::Comp { item } => match self.project.item(*item).map(|i| &i.kind) { - Some(effectcraft_project::ItemKind::Footage(f)) => f.pixel_aspect, - Some(effectcraft_project::ItemKind::Solid(s)) => s.pixel_aspect, - Some(effectcraft_project::ItemKind::Comp(c)) => c.pixel_aspect, - _ => 1.0, - }, + let item = match &layer.source { + LayerSource::Footage { item } => Some(self.media_item(layer, *item)), + LayerSource::Solid { item } | LayerSource::Comp { item } => Some(*item), + _ => None, + }; + let par = match item.and_then(|item| self.project.item(item)).map(|i| &i.kind) { + Some(effectcraft_project::ItemKind::Footage(f)) => f.pixel_aspect, + Some(effectcraft_project::ItemKind::Solid(s)) => s.pixel_aspect, + Some(effectcraft_project::ItemKind::Comp(c)) => c.pixel_aspect, _ => 1.0, }; let r = par / if self.comp.pixel_aspect > 0.0 { self.comp.pixel_aspect } else { 1.0 }; @@ -237,9 +255,27 @@ impl<'a> EvalCtx<'a> { (Mat3([[m[0][0], m[0][1], m[0][3]], [m[1][0], m[1][1], m[1][3]], [0.0, 0.0, 1.0]]), 0.0) } } + /// The footage item a layer uses, honouring this instance's Media Replacement override. + pub fn media_item(&self, layer: &Layer, item: ItemId) -> ItemId { + self.media_overrides.iter().rev().find(|m| m.layer == layer.id).map_or(item, |m| m.item) + } + + /// Size of a layer's source in layer pixels, honouring Media Replacement. + pub fn source_size(&self, layer: &Layer) -> (u32, u32) { + let item = match &layer.source { + LayerSource::Footage { item } => Some(self.media_item(layer, *item)), + LayerSource::Comp { item } | LayerSource::Solid { item } => Some(*item), + _ => None, + }; + match layer.source { + LayerSource::Null => (100, 100), + _ => item.and_then(|item| self.project.item(item)).and_then(|i| i.dimensions()).unwrap_or((0, 0)), + } + } + /// [`effect_bounds`] of a layer of this comp. pub fn effect_bounds(&self, layer: &Layer) -> ([f64; 2], [f64; 2]) { - effect_bounds(self.project, self.comp, layer) + effect_bounds_of(self.comp, self.source_size(layer)) } /// Effect space (see [`effect_bounds`]) → comp matrix. pub fn effect_to_comp(&self, layer: &Layer) -> Mat3 { @@ -248,6 +284,20 @@ impl<'a> EvalCtx<'a> { } } +impl crate::PropertyOverride { + fn value(&self, layer: &Layer, prop: &Property, time: Tick) -> Value { + if self.font { + // A Font control merges only the font into the target text, as + // `essential::with_overrides` does. + let mut value = prop.value_at(layer.layer_time(time)); + effectcraft_project::essential::merge_font(&mut value, &self.value); + value + } else { + self.value.clone() + } + } +} + /// The protected regions of a comp (Responsive Design — Time markers), merged, sorted and /// clamped to the comp, in seconds. pub fn protected_regions(comp: &Comp) -> Vec<(f64, f64)> { @@ -321,7 +371,11 @@ pub fn responsive_source_time(comp: &Comp, stretch: f64, elapsed: f64) -> Option /// surrounds. Effects work in **effect space**, measured from that corner (After Effects' layer /// space for effect points: a shape layer's default effect point is its bounds' centre). pub fn effect_bounds(project: &Project, comp: &Comp, layer: &Layer) -> ([f64; 2], [f64; 2]) { - match source_size(project, layer) { + effect_bounds_of(comp, source_size(project, layer)) +} + +fn effect_bounds_of(comp: &Comp, size: (u32, u32)) -> ([f64; 2], [f64; 2]) { + match size { (0, _) => { let (w, h) = (comp.width as f64, comp.height as f64); ([w, h], [-w / 2.0, -h / 2.0]) diff --git a/crates/render/src/frames.rs b/crates/render/src/frames.rs index 7dd0fc0de..7d1929ecd 100644 --- a/crates/render/src/frames.rs +++ b/crates/render/src/frames.rs @@ -1,6 +1,7 @@ use super::footage::NoFootage; use super::opts::RenderOpts; -use super::{EvalCtx, Footage, FrameBlend, FrameRate, Image, ItemId, Layer, LayerSource, Project, Renderer, TICKS_PER_SECOND, Tick, shapes, source_size, text}; +use super::{Arc, EvalCtx, Footage, FrameBlend, FrameRate, Image, ItemId, Layer, LayerSource, Project, Renderer, TICKS_PER_SECOND, Tick, shapes, text}; +use rayon::prelude::*; /// The layer's frame blending mode, if the comp's Enable Frame Blending switch is on. /// One field of an interlaced frame as a full frame: the field's lines (`parity` 0 = even / @@ -33,6 +34,23 @@ pub fn field_frame(img: &Image, parity: usize) -> Image { out } +/// Interpret Footage ▸ Cineon Settings ▸ Logarithmic Conversion converts the log code values of +/// a decoded (premultiplied) frame of `f` to the Current Gamma. When the conversion does not +/// apply, the frame is returned unchanged. +pub fn cineon_frame(img: Arc, f: &Footage) -> Arc { + if !f.cineon_applies() { + return img; + } + let cs = f.cineon; + let mut out = (*img).clone(); + out.data.par_iter_mut().for_each(|p| { + let a = p[3]; + let c = if a > 1e-6 { [p[0] / a, p[1] / a, p[2] / a] } else { [0.0; 3] }.map(|v| cs.convert(v)); + *p = [c[0] * a, c[1] * a, c[2] * a, a]; + }); + Arc::new(out) +} + /// Interpret Footage ▸ Invert Alpha and Interpret As Linear Light on decoded (premultiplied) /// pixels; linear light is encoded in `space` ([`color::Pipe::linear_footage_space`]). pub fn interpret_pixels(img: &Image, f: &Footage, space: effectcraft_color::ColorSpace) -> Image { @@ -132,7 +150,7 @@ pub fn content_bounds(ctx: &EvalCtx, layer: &effectcraft_project::Layer) -> Opti LayerSource::Shape => layer.props.sub("contents").and_then(|c| shapes::content_bounds(ctx, layer, c)).map(|r| [r.x0, r.y0, r.x1, r.y1]), LayerSource::Camera | LayerSource::Light { .. } => None, _ => { - let (w, h) = source_size(ctx.project, layer); + let (w, h) = ctx.source_size(layer); (w > 0 && h > 0).then_some([0.0, 0.0, w as f64, h as f64]) } } diff --git a/crates/render/src/fx_host.rs b/crates/render/src/fx_host.rs index a0c3e7490..65bafe002 100644 --- a/crates/render/src/fx_host.rs +++ b/crates/render/src/fx_host.rs @@ -72,6 +72,14 @@ impl EffectHost for FxHost<'_, '_, '_> { return None; } let sub = self.r.nested(); + // A 3D model or primitive layer has no 2D source of its own: an effect referencing one + // reads it as After Effects does — the layer rendered on its own through the comp's + // camera (Create 3D Layer Instance's effect), in the layer's effect space (the + // comp, centred on the layer's anchor). + if other.source.is_model() { + let buf = sub.solo_3d(self.ctx, other); + return Some(self.pixels(other, buf, false)); + } // The cached layer buffer is shared; effects get their own copy. let buf = if masks_and_effects { (*sub.content_buf(self.ctx, other)?).clone() } else { sub.source(self.ctx, other)? }; Some(self.pixels(other, buf, true)) @@ -90,12 +98,13 @@ impl EffectHost for FxHost<'_, '_, '_> { fn audio(&self, id: u64, start: f64, frames: usize, rate: u32) -> Option> { let other = self.ctx.layer(effectcraft_project::LayerId(id))?; let LayerSource::Footage { item } = &other.source else { return None }; - let ItemKind::Footage(f) = &self.r.project.item(*item)?.kind else { return None }; + let item = self.ctx.media_item(other, *item); + let ItemKind::Footage(f) = &self.r.project.item(item)?.kind else { return None }; if !f.has_audio { return None; } let st = other.layer_time(Tick::from_seconds_f64(start)); - self.r.footage.audio(*item, f, st, frames, rate) + self.r.footage.audio(item, f, st, frames, rate) } fn self_at(&self, layer_time: f64, effects: usize) -> Option { diff --git a/crates/render/src/layers.rs b/crates/render/src/layers.rs index cac2add43..ce0a2dcde 100644 --- a/crates/render/src/layers.rs +++ b/crates/render/src/layers.rs @@ -2,7 +2,7 @@ use super::frames::adaptive_samples; use super::opts::{LayerTiming, Through}; use super::{ Arc, BlendMode, Buf, Comp, EvalCtx, Image, IndexedParallelIterator, IntoParallelRefIterator, IntoParallelRefMutIterator, Layer, LayerSource, Mat3, - MatteKind, ParallelIterator, Quality, Region, Renderer, Sampling, Tick, WarpOpts, cache, color, composite_warp_changed, masks, source_size, styles, vec2, + MatteKind, ParallelIterator, Quality, Region, Renderer, Sampling, Tick, WarpOpts, cache, color, composite_warp_changed, masks, styles, vec2, }; /// Blend policy for placing a layer, including optional output-depth quantization. @@ -52,7 +52,7 @@ impl<'a> Renderer<'a> { } } let t0 = web_time::Instant::now(); - let size = source_size(self.project, layer); + let size = ctx.source_size(layer); let layer_size = if size.0 == 0 { [ctx.comp.width as f64, ctx.comp.height as f64] } else { [size.0 as f64, size.1 as f64] }; let st = styles::render(ctx, layer, &content, layer_size); if let Some(t) = timing { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 0552a44d4..dd5335623 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -48,8 +48,8 @@ mod pipeline; pub use accel::{Accelerator, Backend, FxStep, FxTarget}; pub use footage::{CancellableFootage, FloatCurve, FootageSource, NoFootage, is_vector_footage}; pub use frames::{ - adaptive_samples, blend_frames, content_bounds, extended_region, field_frame, frame_position, frame_size, interpret_pixels, kurbo_path, render_frame, - roi_inside, + adaptive_samples, blend_frames, cineon_frame, content_bounds, extended_region, field_frame, frame_position, frame_size, interpret_pixels, kurbo_path, + render_frame, roi_inside, }; #[cfg(test)] use fx_host::FxHost; @@ -60,6 +60,7 @@ pub use layers::{Placement, StyledLayer}; pub(crate) use opts::Collapse3d; pub use opts::{LayerTiming, RenderOpts}; use opts::{MAX_THROUGH, Through}; +pub use pipeline::{Collapsed, MediaOverride, PropertyOverride}; pub struct Renderer<'a> { pub project: &'a Project, diff --git a/crates/render/src/pipeline.rs b/crates/render/src/pipeline.rs index 71ad426ce..a4927144f 100644 --- a/crates/render/src/pipeline.rs +++ b/crates/render/src/pipeline.rs @@ -1,12 +1,14 @@ use super::accel::{Accelerator, Backend, CpuFx, FxStep, FxTarget}; use super::footage::{FootageSource, is_vector_footage}; -use super::frames::{blend_frames, content_bounds, field_frame, frame_blend_mode, frame_position, frame_size, interpret_pixels, roi_inside}; +use super::frames::{blend_frames, cineon_frame, content_bounds, field_frame, frame_blend_mode, frame_position, frame_size, interpret_pixels, roi_inside}; use super::fx_host::{FxHost, essential_overrides}; use super::opts::{Collapse3d, LayerTiming, MAX_THROUGH, RenderOpts, Through}; use super::{ Arc, AutoKey, AutoPick, BlendMode, Buf, Comp, EffectCtx, EffectEnv, EvalCtx, Footage, FootageKind, FrameBlend, FrameRate, GroupKind, Image, ItemId, ItemKind, Layer, LayerCache, LayerSource, Params, Project, Quality, Region, Renderer, Tick, cache, color, masks, shapes, styles, text, three_d, vec2, }; +use effectcraft_keyframe::Value; +use effectcraft_project::LayerId; /// Coordinate space and stack range shared by CPU and accelerated effect evaluation. #[derive(Clone, Copy)] @@ -16,6 +18,88 @@ pub(crate) struct EffectStack { pub origin: [f64; 2], } +/// A property value an Essential Properties override gives a layer of a collapsed precomp. +#[derive(Clone, Debug)] +pub struct PropertyOverride { + pub(crate) layer: LayerId, + pub(crate) prop: u64, + pub(crate) value: Value, + /// A Font control changes only the font of the target text, not its text. + pub(crate) font: bool, +} + +/// A Media Replacement an Essential Properties override gives a footage layer of a collapsed +/// precomp. +#[derive(Clone, Copy, Debug)] +pub struct MediaOverride { + pub(crate) layer: LayerId, + pub(crate) item: ItemId, +} + +impl MediaOverride { + /// A Media Replacement of `layer`'s footage by `item`. + pub fn new(layer: LayerId, item: ItemId) -> Self { + MediaOverride { layer, item } + } +} + +/// A collapsed precomp layer: the nested comp and the instance overrides that apply to it. +pub struct Collapsed { + pub item: ItemId, + pub(crate) property_overrides: Vec, + pub(crate) media_overrides: Vec, +} + +/// The property and media overrides of a collapsed precomp layer. `None` when its Essential +/// Properties group or the source comp's definitions are missing (the flattened path then +/// preserves the old behaviour). +fn collapsed_instance(ctx: &EvalCtx, layer: &Layer, item: ItemId) -> Option<(Vec, Vec)> { + use effectcraft_project::essential::{self, ControlType, EgKind}; + let over = essential::overridden(layer); + if over.is_empty() { + return Some((vec![], vec![])); + } + let g = essential::group(layer)?; + let eg = ctx.project.comp(item)?.essential.as_ref()?; + let mut property = vec![]; + let mut fonts: Vec = vec![]; + let mut media = vec![]; + g.walk("", &mut |_, p| { + if !over.contains(&p.uid) { + return; + } + let Some(control) = essential::control_of(&p.match_id) else { return }; + let Some(ctl) = eg.resolve(control) else { return }; + let value = ctx.value(layer, p); + match &ctl.kind { + EgKind::Property { .. } => { + let font = ctl.as_type == Some(ControlType::Font); + for (layer, prop) in ctl.kind.targets() { + let o = PropertyOverride { layer, prop, value: value.clone(), font }; + if font { fonts.push(o) } else { property.push(o) } + } + } + EgKind::Media { layer: target } => { + let Value::Scalar(id) = value else { return }; + let item = ItemId(id.max(0.0) as u64); + if matches!(ctx.project.item(item).map(|i| &i.kind), Some(ItemKind::Footage(_))) { + media.push(MediaOverride { layer: *target, item }); + } + } + _ => {} + } + }); + // A Font control merges into the text its instance shows, after a whole-value (Text) override + // of the same property, as `essential::with_overrides` does: keep one entry per property. + for f in fonts { + match property.iter_mut().rev().find(|o| o.layer == f.layer && o.prop == f.prop) { + Some(o) => effectcraft_project::essential::merge_font(&mut o.value, &f.value), + None => property.push(f), + } + } + Some((property, media)) +} + impl<'a> Renderer<'a> { pub fn new(project: &'a Project, footage: &'a dyn FootageSource, opts: RenderOpts) -> Renderer<'a> { Renderer { @@ -122,8 +206,10 @@ impl<'a> Renderer<'a> { /// The nested comp of a precomp layer whose transformations collapse into this comp: the /// Collapse Transformations switch is on and the layer has no masks, effects or layer - /// styles (those force a flattened render of the precomp, as in After Effects). - pub fn collapsed(&self, ctx: &EvalCtx, layer: &Layer) -> Option { + /// styles (those force a flattened render of the precomp, as in After Effects). Essential + /// Properties overrides travel with it and are applied as the nested layers are evaluated, + /// so the nested comp is not rasterised at its frame. + pub fn collapsed(&self, ctx: &EvalCtx, layer: &Layer) -> Option { if !layer.switches.collapse || layer.switches.adjustment || self.depth > 12 { return None; } @@ -140,24 +226,37 @@ impl<'a> Renderer<'a> { if styles::active(ctx, layer) { return None; } - // Instance overrides (Essential Properties) need the nested comp rendered on its own. - if !effectcraft_project::essential::overridden(layer).is_empty() { + // A composition proxy or frame blending is drawn by the flattened path (`precomp_source`); + // collapsing renders the nested comp directly and would ignore both. + if self.proxy_for(*item).is_some() || frame_blend_mode(ctx, layer) != FrameBlend::Off { return None; } - Some(*item) + let (property_overrides, media_overrides) = collapsed_instance(ctx, layer, *item)?; + Some(Collapsed { item: *item, property_overrides, media_overrides }) } /// A renderer and context that draw a collapsed precomp's layers straight into this comp: /// their transforms are concatenated with the precomp layer's (one resample, no /// rasterisation at the precomp's bounds), their opacity multiplied by `opacity`, and their /// 3D layers use this comp's camera and lights. - pub fn collapse_into(&self, ctx: &EvalCtx<'a>, layer: &'a Layer, item: ItemId, opacity: f32) -> Option<(Renderer<'a>, EvalCtx<'a>)> { + pub fn collapse_into<'p>(&self, ctx: &EvalCtx<'a>, layer: &'a Layer, collapsed: &'p Collapsed, opacity: f32) -> Option<(Renderer<'p>, EvalCtx<'p>)> + where + 'a: 'p, + { + let item = collapsed.item; let nc = self.project.comp(item)?; let t = ctx.nested_time(layer); if !nc.covers(t) { return None; } - let nctx = EvalCtx { comp_id: item, comp: nc, time: t, ..*ctx }; + let nctx = EvalCtx { + comp_id: item, + comp: nc, + time: t, + property_overrides: &collapsed.property_overrides, + media_overrides: &collapsed.media_overrides, + ..*ctx + }; let (l2c, _) = ctx.layer_to_comp(layer); let outer = Some(self.outer.map_or(l2c, |o| o * l2c)); let world = ctx.world_matrix(layer); @@ -189,19 +288,19 @@ impl<'a> Renderer<'a> { /// that then takes the precomp layer's track matte, Preserve Transparency and blend mode. /// Returns the region left to re-quantise (nested layers drawn straight into `canvas` /// quantise their own footprints). - pub(crate) fn draw_collapsed(&self, ctx: &EvalCtx<'a>, layer: &Layer, item: ItemId, canvas: &mut Image, blank: bool) -> Region { + pub(crate) fn draw_collapsed(&self, ctx: &EvalCtx<'a>, layer: &'a Layer, collapsed: &Collapsed, canvas: &mut Image, blank: bool) -> Region { let opacity = ctx.opacity(layer) as f32 * self.opacity_mul; if opacity <= 0.0 { return Region::Empty; } let matte = layer.track_matte.is_some_and(|tm| tm.layer != layer.id && ctx.comp.layer(tm.layer).is_some()); if layer.blend_mode == BlendMode::Normal && !matte && !layer.preserve_transparency { - if let Some((sub, nctx)) = self.collapse_into(ctx, layer, item, opacity) { + if let Some((sub, nctx)) = self.collapse_into(ctx, layer, collapsed, opacity) { sub.draw_comp(&nctx, canvas, blank); } return Region::Empty; } - let Some((sub, nctx)) = self.collapse_into(ctx, layer, item, 1.0) else { return Region::Empty }; + let Some((sub, nctx)) = self.collapse_into(ctx, layer, collapsed, 1.0) else { return Region::Empty }; let mut iso = Image::new(canvas.width, canvas.height); sub.draw_comp(&nctx, &mut iso, true); self.composite_iso(ctx, layer, iso, canvas, opacity); @@ -230,6 +329,7 @@ impl<'a> Renderer<'a> { let continuous = match layer.source { LayerSource::Text | LayerSource::Shape => true, LayerSource::Footage { item } => { + let item = ctx.media_item(layer, item); layer.switches.collapse && matches!(self.project.item(item).map(|i| &i.kind), Some(ItemKind::Footage(f)) if is_vector_footage(f)) } _ => false, @@ -267,7 +367,7 @@ impl<'a> Renderer<'a> { } pub(crate) fn ctx(&self, comp_id: ItemId, comp: &'a Comp, t: Tick) -> EvalCtx<'a> { - EvalCtx { project: self.project, comp_id, comp, time: t, expr: self.expr, footage: Some(self.footage) } + EvalCtx { project: self.project, comp_id, comp, time: t, expr: self.expr, footage: Some(self.footage), property_overrides: &[], media_overrides: &[] } } /// This renderer with another accelerator (or none): the passes of @@ -406,7 +506,7 @@ impl<'a> Renderer<'a> { blank = false; } else { let changed = match self.collapsed(ctx, visible[i]) { - Some(item) => self.draw_collapsed(ctx, visible[i], item, canvas, blank), + Some(collapsed) => self.draw_collapsed(ctx, visible[i], &collapsed, canvas, blank), None => self.draw_layer(ctx, visible[i], canvas, blank), }; i += 1; @@ -466,14 +566,26 @@ impl<'a> Renderer<'a> { let Some(fx) = layer.effects() else { return }; let lt = layer.layer_time(ctx.time); let layer_size = ctx.effect_bounds(layer).0; - // #659: an authored layer's buffer spans only its painted content while the stack works - // on the layer's whole region (`layer_size`): give the buffer the room a sized source - // already has, so tiling or displacement can place pixels outside the content instead - // of losing them at its edge. Only when something will actually run, and not for an - // adjustment layer, whose buffer is the comp below and already spans it. - if !adjustment - && fx.groups().any(|g| g.enabled && matches!(&g.kind, GroupKind::Effect { effect } if effectcraft_effects::find(effect).is_some_and(|s| !effectcraft_effects::audio_fx::is_audio_effect(s.id)))) { - target.cover(layer_size); + // #659 / #1205: an authored layer's buffer spans only its painted content. Effects that + // place pixels outside it grow the buffer themselves, by exactly what they need + // (`layer_cover_pad`: CC Tiler, CC Smear, CC Mosaic sharp) instead of a blanket cover of + // the whole layer rectangle on every stack — which paid a comp-sized texture per layer, + // per frame in flight, for glow and blur that never use the extra room. The buffer still + // takes the cover's pixel-grid phase: a downscaled layer resamples along that grid. + // Effects not known to stay inside their own margins (Twirl, Bulge, Transform, ...) still + // get the cover: they move content past the painted bounds without growing the buffer. + let video = || { + fx.groups().filter(|g| g.enabled).filter_map(|g| match &g.kind { + GroupKind::Effect { effect } => effectcraft_effects::find(effect).filter(|s| !effectcraft_effects::audio_fx::is_audio_effect(s.id)), + _ => None, + }) + }; + if !adjustment && video().next().is_some() { + if video().all(|s| s.skips_cover()) { + target.align(layer_size); + } else { + target.cover(layer_size); + } } let mut mask_shapes = masks::shapes(ctx, layer); if origin != [0.0; 2] { @@ -637,7 +749,8 @@ impl<'a> Renderer<'a> { Some(Buf { img: sub.comp_frame(*item, lt), offset: [0.0; 2], scale: bs }) } LayerSource::Footage { item } => { - let it = self.project.item(*item)?; + let item = ctx.media_item(layer, *item); + let it = self.project.item(item)?; let ItemKind::Footage(f) = &it.kind else { return None }; if !f.has_video { return None; @@ -645,7 +758,7 @@ impl<'a> Renderer<'a> { // Continuously rasterised vector footage: drawn at the on-screen scale. if layer.switches.collapse && is_vector_footage(f) { let k = self.raster_scale(ctx, layer); - if let Some(img) = self.footage.vector_frame(*item, f, k) { + if let Some(img) = self.footage.vector_frame(item, f, k) { let mut buf = Buf { img: (*img).clone(), offset: [0.0; 2], scale: k }; if let Some(c) = self.pipe.media_in(f) { color::convert(&mut buf.img, &c); @@ -655,10 +768,10 @@ impl<'a> Renderer<'a> { } let lt = ctx.source_time(layer); // The proxy is decoded instead, at its own size, and fills the footage's frame. - let proxy = self.proxy_for(*item); + let proxy = self.proxy_for(item); let pf = proxy.unwrap_or(f); let size = self.reduced_size(f.width, f.height).filter(|_| proxy.is_none()); - let img = self.footage_frame(ctx, layer, *item, pf, lt, size)?; + let img = self.footage_frame(ctx, layer, item, pf, lt, size)?; Some(self.footage_buf(&img, pf, f.width, f.height)) } LayerSource::Text => Some(self.authored(text::render(ctx, layer, self.raster_scale(ctx, layer)))), @@ -716,16 +829,17 @@ impl<'a> Renderer<'a> { if f.fields != effectcraft_project::FieldOrder::Off && matches!(f.kind, FootageKind::Video | FootageKind::Sequence) { let field_rate = FrameRate::new(f.frame_rate.num * 2, f.frame_rate.den); let i = field_rate.frame_at(t).max(0); - let img = self.footage.frame_with(item, f, f.frame_rate.tick_of(i / 2), None, self.pipe.float_curve(f))?; + let img = cineon_frame(self.footage.frame_with(item, f, f.frame_rate.tick_of(i / 2), None, self.pipe.float_curve(f))?, f); let dominant_upper = f.fields == effectcraft_project::FieldOrder::UpperFirst; // Upper field = even lines (0, 2, …). let parity = if (i % 2 == 0) == dominant_upper { 0 } else { 1 }; return Some(Arc::new(interpret_pixels(&field_frame(&img, parity), f, self.pipe.linear_footage_space(f)))); } // Interpretation is per pixel but not linear: it runs on the frame before resampling. - let size = size.filter(|_| !(f.invert_alpha || f.linear_light_applies())); + let per_pixel = f.invert_alpha || f.linear_light_applies(); + let size = size.filter(|_| !(per_pixel || f.cineon_applies())); let img = self.footage_frame_raw(ctx, layer, item, f, t, size)?; - if f.invert_alpha || f.linear_light_applies() { Some(Arc::new(interpret_pixels(&img, f, self.pipe.linear_footage_space(f)))) } else { Some(img) } + if per_pixel { Some(Arc::new(interpret_pixels(&img, f, self.pipe.linear_footage_space(f)))) } else { Some(img) } } fn footage_frame_raw(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, f: &Footage, t: Tick, size: Option<(u32, u32)>) -> Option> { @@ -743,6 +857,10 @@ impl<'a> Renderer<'a> { if f.alpha == effectcraft_project::AlphaMode::Premultiplied { f.premul_color.map(f32::to_bits).hash(&mut h); } + if f.cineon_applies() { + let c = &f.cineon; + [c.converted_black, c.converted_white, c.black, c.white, c.gamma, c.rolloff].map(f32::to_bits).hash(&mut h); + } h.finish() }); if let (Some(c), Some(k)) = (self.cache, key) @@ -750,8 +868,9 @@ impl<'a> Renderer<'a> { { return Some(Arc::new(b.img.clone())); } - let a = self.footage.frame_with(item, f, f.frame_rate.tick_of(i), None, float); - let b = self.footage.frame_with(item, f, f.frame_rate.tick_of(i + 1), None, float); + // Log scans are converted before they blend: the conversion is not linear. + let a = self.footage.frame_with(item, f, f.frame_rate.tick_of(i), None, float).map(|a| cineon_frame(a, f)); + let b = self.footage.frame_with(item, f, f.frame_rate.tick_of(i + 1), None, float).map(|b| cineon_frame(b, f)); if let (Some(a), Some(b)) = (a, b) { let img = blend_frames(mode, &a, &b, w as f32); if let (Some(c), Some(k)) = (self.cache_to_keep(), key) { @@ -761,7 +880,7 @@ impl<'a> Renderer<'a> { } } } - self.footage.frame_with(item, f, t, size, float) + self.footage.frame_with(item, f, t, size, float).map(|img| cineon_frame(img, f)) } /// Source → masks, clamped/quantised to the project depth. @@ -836,6 +955,30 @@ impl<'a> Renderer<'a> { self.source(ctx, layer) } + /// A 3D model or primitive layer rendered on its own, through the comp's camera: what an + /// effect layer parameter reads when it references one, as After Effects, which renders 3D + /// layers through the active camera. The comp's pixels at this render's scale; the layer's + /// blend mode, track matte and video switch don't apply (a source read, not a composite — + /// the solo clone of a 3D track matte, see `three_d::adv`). + pub(crate) fn solo_3d(&self, ctx: &EvalCtx, layer: &Layer) -> Buf { + let s = self.opts.scale; + let w = ((ctx.comp.width as f64 * s).ceil() as u32).max(1); + let h = ((ctx.comp.height as f64 * s).ceil() as u32).max(1); + let mut canvas = Image::new(w, h); + // Outside the layer's in/out points there is nothing to read: the copy is transparent + // there, like any other layer reference (the 3D pipelines don't gate on the range). + if !layer.is_active_at(ctx.time) { + return Buf { img: canvas, offset: [0.0; 2], scale: s }; + } + let mut solo = layer.clone(); + solo.track_matte = None; + solo.blend_mode = effectcraft_color::BlendMode::Normal; + solo.preserve_transparency = false; + solo.switches.video = true; + three_d::compose::draw_run(self, ctx, &[&solo], &mut canvas); + Buf { img: canvas, offset: [0.0; 2], scale: s } + } + /// Layer pixels after source → masks → effects (no layer styles). pub fn content_buf(&self, ctx: &EvalCtx, layer: &Layer) -> Option> { self.content_buf_timed(ctx, layer, None).map(|(b, _)| b) diff --git a/crates/render/src/shapes.rs b/crates/render/src/shapes.rs index 67f224793..bbc4283ff 100644 --- a/crates/render/src/shapes.rs +++ b/crates/render/src/shapes.rs @@ -2,8 +2,8 @@ //! with the After Effects stacking semantics: //! //! - path operators (Trim Paths, Round Corners, Offset Paths, Pucker & Bloat, Twist, Zig Zag, -//! Wiggle Paths, Merge Paths, Repeater) act on all paths above them in the same group, including -//! the paths of nested groups; +//! Wiggle Paths, Wiggle Transform, Merge Paths, Repeater) act on all paths above them in the +//! same group, including the paths of nested groups; //! - paint items (fills, strokes, gradient fills/strokes) paint the paths above them, *as modified //! by every operator in the stack* — a Trim Paths below a Stroke still trims it; //! - items higher in the list draw on top; @@ -17,7 +17,9 @@ use effectcraft_keyframe::{Gradient, Value}; use effectcraft_path::{BezPath, Cap, FillRule, Join, StrokeStyle, ops, varstroke}; use effectcraft_project::{Layer, Node, PropGroup}; use effectcraft_raster::{Image, Mask}; +use effectcraft_text::selectors::{self, Wiggly}; use rayon::prelude::*; +use std::collections::{HashMap, HashSet}; use crate::eval::EvalCtx; @@ -185,9 +187,75 @@ fn used_slots(live: &[usize], draws: &[Draw]) -> Vec { v } +/// Wiggle Transform: moves the live slots by a random offset of the Transform group's Anchor +/// Point, Position, Scale and Rotation (the most each one wiggles), changing over time. Its +/// Wiggles/Second, Correlation, Temporal Phase, Spatial Phase and Random Seed work as the Wiggly +/// Selector's do, with each Repeater copy (`copy_of`) as one unit: the copies of a Repeater above +/// the operation move on their own as far as Correlation allows, and the copies of a Repeater +/// below it move as one. The four transformations of one operation move together. +fn wiggle_transform(ctx: &EvalCtx, layer: &Layer, g: &PropGroup, arena: &mut [BezPath], live: &[usize], copy_of: &HashMap, draws: &mut [Draw]) { + let Some(tr) = g.sub("transform") else { return }; + let w = Wiggly { + max: 1.0, + min: -1.0, + wiggles_per_second: ctx.f(layer, g, "speed", 2.0), + correlation: ctx.f(layer, g, "correlation", 50.0) / 100.0, + temporal_phase: ctx.f(layer, g, "phase", 0.0), + spatial_phase: ctx.f(layer, g, "spatialPhase", 0.0), + lock_dimensions: false, + seed: ctx.f(layer, g, "seed", 0.0).round() as i64 as u64, + }; + let anchor = ctx.v2(layer, tr, "anchor", [0.0; 2]); + let pos = ctx.v2(layer, tr, "position", [0.0; 2]); + let scale = ctx.v2(layer, tr, "scale", [0.0; 2]); + let rot = ctx.f(layer, tr, "rotation", 0.0); + let t = layer.layer_time(ctx.time).seconds(); + let matrix = |k: usize| { + let (x, y) = (selectors::wiggly_value(&w, t, k, 0), selectors::wiggly_value(&w, t, k, 1)); + Mat3::translate(vec2(pos[0] * x, pos[1] * y)) + * Mat3::rotate_deg(rot * x) + * Mat3::scale(vec2(1.0 + scale[0] * x / 100.0, 1.0 + scale[1] * y / 100.0)) + * Mat3::translate(vec2(-anchor[0] * x, -anchor[1] * y)) + }; + let mut by_copy: HashMap = HashMap::new(); + let mut done: HashSet = HashSet::new(); + for &s in live { + if !done.insert(s) { + continue; + } + let k = copy_of.get(&s).copied().unwrap_or(0); + let m = *by_copy.entry(k).or_insert_with(|| matrix(k)); + arena[s] = mat_path(&arena[s], &m); + } + // A paint above the operation whose paths all moved by one matrix moves its gradient and + // stroke scale with them, as a group transform does. A paint over copies that moved by + // different matrices stays one paint (its fill rule and opacity apply to all of them at once) + // and keeps its gradient and stroke scale. + for d in draws { + let mut ms = d.slots.iter().map(|s| done.contains(s).then(|| by_copy.get(©_of.get(s).copied().unwrap_or(0)).copied()).flatten()); + let Some(Some(m)) = ms.next() else { continue }; + if ms.all(|x| x == Some(m)) { + d.xf = m * d.xf; + } + } +} + /// Collect the draws of a contents group and the path slots live at its end (in group space). /// `arena` holds every path slot; operators rewrite slots in place. fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec) -> (Vec, Vec) { + collect_in(ctx, layer, contents, arena, &mut Copies::default()) +} + +/// The Repeater copy each path slot belongs to, for Wiggle Transform: `of` maps a slot a Repeater +/// made to its copy's id (slots no Repeater made are copy 0). Ids are unique across the layer's +/// groups, so copies made in a nested group stay apart in the groups around it. +#[derive(Default)] +struct Copies { + of: HashMap, + next: usize, +} + +fn collect_in(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec, copy_ids: &mut Copies) -> (Vec, Vec) { let mut draws: Vec = Vec::new(); let mut live: Vec = Vec::new(); let opacity = |g: &PropGroup| (ctx.f(layer, g, "opacity", 100.0) / 100.0) as f32; @@ -330,6 +398,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec wiggle_transform(ctx, layer, g, arena, &live, ©_ids.of, &mut draws), "merge" => { let mode = ops::MergeMode::from_index(ctx.e(layer, g, "mode")); let input: Vec = live.iter().map(|&i| arena[i].clone()).collect(); @@ -342,7 +411,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec { let Some(inner) = g.sub("contents") else { continue }; - let (cd, cl) = collect(ctx, layer, inner, arena); + let (cd, cl) = collect_in(ctx, layer, inner, arena, copy_ids); let (m, op) = g.sub("transform").map(|t| group_matrix(ctx, layer, t)).unwrap_or((Mat3::IDENTITY, 1.0)); for i in used_slots(&cl, &cd) { arena[i] = mat_path(&arena[i], &m); @@ -379,12 +448,20 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec> = Vec::with_capacity(n); + // Each copy of each earlier copy (a second Repeater) gets an id of its own. + let mut ids: HashMap<(usize, usize), usize> = HashMap::new(); for i in 0..n { let m = step(i as f64 + offset); let mut map = std::collections::HashMap::new(); for &s in &base_slots { arena.push(mat_path(&arena[s], &m)); map.insert(s, arena.len() - 1); + let base = copy_ids.of.get(&s).copied().unwrap_or(0); + let id = *ids.entry((i, base)).or_insert_with(|| { + copy_ids.next += 1; + copy_ids.next + }); + copy_ids.of.insert(arena.len() - 1, id); } live.extend(base_live.iter().map(|s| map[s])); copy_maps.push(map); diff --git a/crates/render/src/shapes/tests.rs b/crates/render/src/shapes/tests.rs index e6f6cba7c..e0c9086a7 100644 --- a/crates/render/src/shapes/tests.rs +++ b/crates/render/src/shapes/tests.rs @@ -310,6 +310,202 @@ fn wiggle_paths_vary_over_time_deterministically() { assert_ne!(a.data, c.data); } +/// Alpha-weighted centroid of the pixels in columns `x0..x1`. +fn centroid(img: &Image, x0: i64, x1: i64) -> [f32; 2] { + let (mut sx, mut sy, mut sa) = (0.0, 0.0, 0.0); + for x in x0..x1 { + for y in 0..img.height as i64 { + let a = alpha(img, x, y); + sx += a * x as f32; + sy += a * y as f32; + sa += a; + } + } + [sx / sa, sy / sa] +} + +fn wiggle_transform(ids: &mut Ids, params: &[(&str, Value)]) -> PropGroup { + op(ids, "wiggler", params) +} + +#[test] +fn wiggle_transform_moves_paths_over_time() { + let run = |position: [f64; 2], t: f64| { + let (p, cid) = shape_comp(|ids| { + let w = wiggle_transform(ids, &[("transform/position", Value::Vec2(position))]); + vec![build::shape_rect(ids, [20.0, 20.0], [0.0, 0.0], 0.0), w, build::shape_fill(ids, RED)] + }); + render_frame(&p, cid, Tick::from_seconds_f64(t), 1.0) + }; + // The defaults wiggle nothing: the square stays centered on pixel 99.5, 49.5. + let rest = [99.5, 49.5]; + assert_eq!(centroid(&run([0.0, 0.0], 0.37), 0, 200), rest); + let a = run([15.0, 15.0], 0.37); + assert_eq!(a.data, run([15.0, 15.0], 0.37).data, "the same time gives the same frame"); + let b = run([15.0, 15.0], 1.21); + let (ca, cb) = (centroid(&a, 0, 200), centroid(&b, 0, 200)); + assert!(ca != rest && ca != cb, "{ca:?} {cb:?}"); + for c in [ca, cb] { + assert!((c[0] - rest[0]).abs() <= 15.01 && (c[1] - rest[1]).abs() <= 15.01, "{c:?}"); + } + // A translation keeps the square's area. + assert!((coverage(&a) - 400.0).abs() < 1.0, "{}", coverage(&a)); + // The layer cache keys a layer with a Wiggle Transform by time. + let (p, cid) = shape_comp(|ids| vec![build::shape_rect(ids, [20.0, 20.0], [0.0, 0.0], 0.0), wiggle_transform(ids, &[]), build::shape_fill(ids, RED)]); + let comp = p.comp(cid).unwrap(); + let key = |t: f64| crate::cache::layer_key(&crate::eval::EvalCtx::new(&p, cid, comp, Tick::from_seconds_f64(t)), &comp.layers[0], 1.0, false, false); + assert_ne!(key(0.37), key(1.21)); +} + +#[test] +fn wiggle_transform_scales_and_rotates() { + let run = |params: &[(&str, Value)]| { + let (p, cid) = shape_comp(|ids| { + let w = wiggle_transform(ids, params); + vec![build::shape_rect(ids, [40.0, 40.0], [0.0, 0.0], 0.0), w, build::shape_fill(ids, RED)] + }); + render_frame(&p, cid, Tick::from_seconds_f64(0.37), 1.0) + }; + let scaled = coverage(&run(&[("transform/scale", Value::Vec2([50.0, 50.0]))])); + assert!((scaled - 1600.0).abs() > 20.0, "{scaled}"); + let rotated = run(&[("transform/rotation", Value::Scalar(45.0))]); + assert!((coverage(&rotated) - 1600.0).abs() < 2.0, "{}", coverage(&rotated)); + assert!(alpha(&rotated, 81, 31) < 0.5 || alpha(&rotated, 119, 31) < 0.5, "a corner turned away"); +} + +/// A gradient above a Wiggle Transform moves with the paths it paints, as under a group transform. +#[test] +fn wiggle_transform_moves_the_gradient_with_the_path() { + let (p, cid) = shape_comp(|ids| { + let g = Gradient { colors: vec![(0.0, [0.0, 0.0, 0.0, 1.0]), (1.0, [1.0, 1.0, 1.0, 1.0])], opacities: vec![(0.0, 1.0), (1.0, 1.0)] }; + vec![ + build::shape_rect(ids, [40.0, 40.0], [0.0, 0.0], 0.0), + build::shape_gradient_fill(ids, false, [-20.0, 0.0], [20.0, 0.0], g), + wiggle_transform(ids, &[("transform/position", Value::Vec2([60.0, 0.0]))]), + ] + }); + let img = render_frame(&p, cid, Tick::from_seconds_f64(0.37), 1.0); + let cols: Vec = (0..200).filter(|&x| alpha(&img, x, 50) > 0.99).collect(); + let (l, r) = (cols[0], cols[cols.len() - 1]); + assert!((l - 80).abs() > 3, "the square moved: {l}"); + let (cl, cr) = (img.get(l, 50), img.get(r, 50)); + assert!(cl[0] < 0.1 && cr[0] > 0.9, "the ramp runs edge to edge: {cl:?} {cr:?}"); +} + +/// A gradient that paints every copy of a Repeater moves with the copies when they wiggle alike +/// (Correlation 100). +#[test] +fn wiggle_transform_moves_a_gradient_shared_by_repeater_copies() { + let (p, cid) = shape_comp(|ids| { + let g = Gradient { colors: vec![(0.0, [0.0, 0.0, 0.0, 1.0]), (1.0, [1.0, 1.0, 1.0, 1.0])], opacities: vec![(0.0, 1.0), (1.0, 1.0)] }; + vec![ + build::shape_rect(ids, [20.0, 20.0], [-60.0, 0.0], 0.0), + build::shape_repeater(ids, 3.0, [60.0, 0.0]), + build::shape_gradient_fill(ids, false, [0.0, -10.0], [0.0, 10.0], g), + wiggle_transform(ids, &[("correlation", Value::Scalar(100.0)), ("transform/position", Value::Vec2([0.0, 15.0]))]), + ] + }); + let img = render_frame(&p, cid, Tick::from_seconds_f64(0.9), 1.0); + for cx in [40, 100, 160] { + let rows: Vec = (0..100).filter(|&y| alpha(&img, cx, y) > 0.99).collect(); + let (t, b) = (rows[0], rows[rows.len() - 1]); + assert!((t - 40).abs() > 3, "copy at {cx} moved: {t}"); + let (ct, cb) = (img.get(cx, t), img.get(cx, b)); + assert!(ct[0] < 0.1 && cb[0] > 0.9, "copy at {cx}: {ct:?} {cb:?}"); + } +} + +/// A gradient over copies that wiggle on their own keeps its place: every copy shows the ramp's +/// middle at the group's origin row. +#[test] +fn wiggle_transform_keeps_a_gradient_over_copies_wiggling_apart() { + let (p, cid) = shape_comp(|ids| { + let g = Gradient { colors: vec![(0.0, [0.0, 0.0, 0.0, 1.0]), (1.0, [1.0, 1.0, 1.0, 1.0])], opacities: vec![(0.0, 1.0), (1.0, 1.0)] }; + vec![ + build::shape_rect(ids, [20.0, 40.0], [-60.0, 0.0], 0.0), + build::shape_repeater(ids, 3.0, [60.0, 0.0]), + build::shape_gradient_fill(ids, false, [0.0, -20.0], [0.0, 20.0], g), + wiggle_transform(ids, &[("correlation", Value::Scalar(0.0)), ("transform/position", Value::Vec2([0.0, 15.0]))]), + ] + }); + let img = render_frame(&p, cid, Tick::from_seconds_f64(0.9), 1.0); + for cx in [40, 100, 160] { + let c = img.get(cx, 50); + assert!(c[3] > 0.99 && (c[0] - 0.5).abs() < 0.05, "copy at {cx}: {c:?}"); + } +} + +/// Copies wiggling on their own under one 50 % fill stay one paint: where they overlap, the fill +/// covers them once. +#[test] +fn wiggle_transform_keeps_one_fill_over_overlapping_copies() { + let (p, cid) = shape_comp(|ids| { + let mut fill = build::shape_fill(ids, RED); + set(&mut fill, "opacity", Value::Scalar(50.0)); + vec![ + build::shape_rect(ids, [40.0, 40.0], [-20.0, 0.0], 0.0), + build::shape_repeater(ids, 3.0, [20.0, 0.0]), + fill, + wiggle_transform(ids, &[("correlation", Value::Scalar(0.0)), ("transform/position", Value::Vec2([0.0, 15.0]))]), + ] + }); + let img = render_frame(&p, cid, Tick::from_seconds_f64(0.9), 1.0); + let top = img.data.iter().map(|px| px[3]).fold(0.0f32, f32::max); + assert!((top - 0.5).abs() < 0.01, "{top}"); +} + +/// A long stack of Repeaters, each cleared by a zero-copy Repeater, still renders the last Repeater's copies. +#[test] +fn long_repeater_stack_with_cleared_copies_renders() { + let (p, cid) = shape_comp(|ids| { + let mut items = vec![]; + for _ in 0..70 { + items.push(build::shape_rect(ids, [20.0, 20.0], [0.0, 0.0], 0.0)); + items.push(build::shape_repeater(ids, 3.0, [10.0, 0.0])); + items.push(build::shape_repeater(ids, 0.0, [10.0, 0.0])); + } + items.push(build::shape_rect(ids, [20.0, 20.0], [0.0, 0.0], 0.0)); + items.push(build::shape_repeater(ids, 3.0, [30.0, 0.0])); + items.push(wiggle_transform(ids, &[("correlation", Value::Scalar(0.0)), ("transform/position", Value::Vec2([0.0, 15.0]))])); + items.push(build::shape_fill(ids, RED)); + items + }); + let img = render_frame(&p, cid, Tick::from_seconds_f64(0.37), 1.0); + assert!((coverage(&img) - 1200.0).abs() < 2.0, "{}", coverage(&img)); +} + +/// Copies a Repeater makes above a Wiggle Transform wiggle on their own (Correlation 0) or as one +/// (Correlation 100); with the Wiggle Transform above the Repeater every copy wiggles the same. +#[test] +fn wiggle_transform_and_repeater_order() { + let offsets = |wiggle_first: bool, correlation: f64| { + let (p, cid) = shape_comp(|ids| { + let rect = build::shape_rect(ids, [20.0, 20.0], [-60.0, 0.0], 0.0); + let rep = build::shape_repeater(ids, 3.0, [60.0, 0.0]); + let w = wiggle_transform(ids, &[("correlation", Value::Scalar(correlation)), ("transform/position", Value::Vec2([0.0, 15.0]))]); + let fill = build::shape_fill(ids, RED); + if wiggle_first { vec![rect, w, rep, fill] } else { vec![rect, rep, w, fill] } + }); + let img = render_frame(&p, cid, Tick::from_seconds_f64(0.37), 1.0); + [(0, 70), (70, 130), (130, 200)].map(|(x0, x1)| centroid(&img, x0, x1)[1] - 50.0) + }; + let own = offsets(false, 0.0); + assert!((own[0] - own[1]).abs() > 0.5 || (own[1] - own[2]).abs() > 0.5, "copies wiggle on their own: {own:?}"); + // A Repeater inside a group above the Wiggle Transform: its copies still wiggle on their own. + let (p, cid) = shape_comp(|ids| { + let inner = vec![build::shape_rect(ids, [20.0, 20.0], [-60.0, 0.0], 0.0), build::shape_repeater(ids, 3.0, [60.0, 0.0]), build::shape_fill(ids, RED)]; + let group = build::shape_group(ids, "Repeated", inner); + vec![group, wiggle_transform(ids, &[("correlation", Value::Scalar(0.0)), ("transform/position", Value::Vec2([0.0, 15.0]))])] + }); + let img = render_frame(&p, cid, Tick::from_seconds_f64(0.37), 1.0); + let nested = [(0, 70), (70, 130), (130, 200)].map(|(x0, x1)| centroid(&img, x0, x1)[1] - 50.0); + assert!((nested[0] - nested[1]).abs() > 0.5 || (nested[1] - nested[2]).abs() > 0.5, "nested copies: {nested:?}"); + for same in [offsets(false, 100.0), offsets(true, 0.0)] { + assert!(same[0].abs() > 0.5, "{same:?}"); + assert!((same[0] - same[1]).abs() < 0.05 && (same[1] - same[2]).abs() < 0.05, "copies wiggle as one: {same:?}"); + } +} + #[test] fn zigzag_and_twist_render() { let (p, cid) = shape_comp(|ids| { diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index f04bd4be2..488f38ec7 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -314,7 +314,7 @@ fn particle_world_scene(pan: f64) -> (Project, ItemId) { let Value::Vec3(v) = pr.value else { panic!("{path}: {:?}", pr.value) }; pr.value = Value::Vec3([v[0] + pan, v[1], v[2]]); } - p.comp_mut(cid).unwrap().layers = vec![cam, l]; + p.comp_mut(cid).unwrap().layers = vec![cam, l].into(); (p, cid) } @@ -373,7 +373,7 @@ fn burst_scene_keys(cut: Option, start: f64, stretch: f64, hold: bool) -> ( l.in_point = l.start_time; l.out_point = Tick::from_seconds_f64(4.0); l.stretch = stretch; - p.comp_mut(cid).unwrap().layers = vec![l]; + p.comp_mut(cid).unwrap().layers = vec![l].into(); (p, cid) } @@ -507,7 +507,7 @@ fn system_scene(id: &str, rate: Option) -> (Project, ItemId) { let key = |t: f64, v: f64| Keyframe::new(Tick::from_seconds_f64(t), Value::Scalar(v)); fx.unwrap().prop_mut("birthRate").unwrap().keys = vec![key(0.0, 3.0), key(0.5, 3.0), key(0.5 + 1.0 / 30.0, 0.0)]; } - p.comp_mut(cid).unwrap().layers = vec![l]; + p.comp_mut(cid).unwrap().layers = vec![l].into(); (p, cid) } @@ -614,7 +614,7 @@ fn cache_scene() -> (Project, ItemId) { vec![Keyframe::new(Tick::ZERO, Value::Vec3([20.0, 70.0, 0.0])), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Vec3([120.0, 70.0, 0.0]))]; add_effect_200(&mut p, &mut text, "ec.blur.gaussian", &[("blurriness", Value::Scalar(6.0))]); let c = p.comp_mut(cid).unwrap(); - c.layers = vec![text, shape, bg]; + c.layers = vec![text, shape, bg].into(); (p, cid) } @@ -854,7 +854,7 @@ fn edge_pinning_displaces_all_of_a_centred_shape_layer() { p.comp_mut(cid).unwrap().layers.push(l.clone()); let plain = render_frame(&p, cid, Tick::ZERO, 1.0); add_effect_200(&mut p, &mut l, id, &vals); - p.comp_mut(cid).unwrap().layers = vec![l]; + p.comp_mut(cid).unwrap().layers = vec![l].into(); let warped = render_frame(&p, cid, Tick::ZERO, 1.0); // Alpha change per comp quadrant (TL, TR, BL, BR): the rectangle's edges move in each. let mut q = [0.0f32; 4]; @@ -888,7 +888,7 @@ fn default_effect_points_sit_at_the_centre_of_a_shape_layer() { p.comp_mut(cid).unwrap().layers.push(l.clone()); let plain = render_frame(&p, cid, Tick::ZERO, 1.0); add_effect_200(&mut p, &mut l, id, &vals); - p.comp_mut(cid).unwrap().layers = vec![l]; + p.comp_mut(cid).unwrap().layers = vec![l].into(); let warped = render_frame(&p, cid, Tick::ZERO, 1.0); // Alpha change per comp quadrant (TL, TR, BL, BR): the top and bottom edges move in each. let mut q = [0.0f32; 4]; @@ -917,7 +917,7 @@ fn effects_on_text_reach_past_the_text_bounds() { let plain = render_frame(&p, cid, Tick::ZERO, 1.0); let painted = plain.data.iter().filter(|px| px[3] > 0.5).count(); add_effect_200(&mut p, &mut l, "ec.distort.cctiler", &[("scale", Value::Scalar(25.0))]); - p.comp_mut(cid).unwrap().layers = vec![l]; + p.comp_mut(cid).unwrap().layers = vec![l].into(); let tiled = render_frame(&p, cid, Tick::ZERO, 1.0); let (mut lo, mut hi) = (u32::MAX, 0u32); for y in 0..100 { @@ -951,7 +951,7 @@ fn cc_smear_trails_text_past_its_bounds() { "ec.distort.ccsmear", &[("from", Value::Vec2([110.0, 45.0])), ("to", Value::Vec2([185.0, 45.0])), ("reach", Value::Scalar(100.0)), ("radius", Value::Scalar(40.0))], ); - p.comp_mut(cid).unwrap().layers = vec![l]; + p.comp_mut(cid).unwrap().layers = vec![l].into(); let smeared = right(&render_frame(&p, cid, Tick::ZERO, 1.0)); assert!(plain > 40 && smeared > plain + 25, "CC Smear trails the glyphs past the text bounds: right edge {plain} → {smeared}"); } @@ -974,7 +974,7 @@ fn layer_parameters_see_a_whole_shape_layer() { let matte_id = shape.id; let mut s = solid(&mut p, &comp, [0.0, 0.0, 1.0], 200, 100); add_effect_200(&mut p, &mut s, "ec.channel.setmatte", &[("takeMatteFromLayer", Value::Layer(Some(matte_id.0)))]); - p.comp_mut(cid).unwrap().layers = vec![s, shape]; + p.comp_mut(cid).unwrap().layers = vec![s, shape].into(); let f = render_frame(&p, cid, Tick::ZERO, 1.0); // The matte covers x 50..150, y 25..75: kept inside (all four quadrants), gone outside. for (x, y) in [(60, 30), (140, 30), (60, 70), (140, 70)] { @@ -1064,7 +1064,7 @@ fn self_inputs_share_source_semantics_across_effects_and_times() { "ec.channel.blend", &[("blendWithLayer", Value::Layer(Some(id))), ("blendWithLayerSource", Value::Enum(0)), ("blendWithOriginal", Value::Scalar(0.0))], ); - p.comp_mut(cid).unwrap().layers = vec![l]; + p.comp_mut(cid).unwrap().layers = vec![l].into(); let c = p.comp(cid).unwrap(); let ctx = crate::eval::EvalCtx::new(&p, cid, c, Tick::ZERO); assert!(crate::cache::input_key(&ctx, &c.layers[0], 1.0, false, false, 1).is_some()); @@ -1082,7 +1082,7 @@ fn timed_source_reads_preserve_other_layer_masks() { let mask = build::mask(&mut Ids(&mut next), "Left", ShapePath::rect([50.0, 50.0], 80.0, 80.0), MaskMode::Add, [255, 255, 0]); p.next_id = next; other.props.sub_mut("masks").unwrap().children.push(mask.into()); - p.comp_mut(cid).unwrap().layers = vec![owner.clone(), other.clone()]; + p.comp_mut(cid).unwrap().layers = vec![owner.clone(), other.clone()].into(); let ctx = crate::EvalCtx::new(&p, cid, p.comp(cid).unwrap(), Tick::ZERO); let renderer = crate::Renderer::new(&p, &crate::NoFootage, crate::RenderOpts::default()); let host = crate::FxHost { r: &renderer, ctx: &ctx, layer: &owner, origin: [0.0; 2], index: Default::default(), original: None }; @@ -1103,7 +1103,7 @@ fn layer_dependencies_remain_cacheable_and_invalidate_only_related_edits() { let unrelated = solid(&mut p, &comp, [0.2, 0.3, 0.4], 200, 100); add_effect_200(&mut p, &mut source, "ec.channel.blend", &[("blendWithLayer", Value::Layer(Some(third.id.0))), ("blendWithOriginal", Value::Scalar(0.0))]); add_effect_200(&mut p, &mut owner, "ec.channel.blend", &[("blendWithLayer", Value::Layer(Some(source.id.0))), ("blendWithOriginal", Value::Scalar(0.0))]); - p.comp_mut(cid).unwrap().layers = vec![owner, source, third, unrelated]; + p.comp_mut(cid).unwrap().layers = vec![owner, source, third, unrelated].into(); let key = |p: &Project, time: f64, limit| { let comp = p.comp(cid).unwrap(); let ctx = crate::EvalCtx::new(p, cid, comp, Tick::from_seconds_f64(time)); @@ -1144,12 +1144,12 @@ fn precomp_dependencies_hash_nested_sources_and_evaluated_animation() { child.props.prop_mut("transform/opacity").unwrap().keys = vec![Keyframe::new(Tick::ZERO, Value::Scalar(100.0)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(50.0))]; let mut nested = comp.clone(); - nested.layers = vec![child]; + nested.layers = vec![child].into(); let nested_id = p.add_item("Nested", Label::Sandstone, None, ItemKind::Comp(nested.into())); let pre = build::layer(&mut p, &comp, "Precomp", LayerSource::Comp { item: nested_id }, (200, 100), None); let mut owner = solid(&mut p, &comp, [1.0, 0.0, 0.0], 200, 100); add_effect_200(&mut p, &mut owner, "ec.channel.blend", &[("blendWithLayer", Value::Layer(Some(pre.id.0))), ("blendWithOriginal", Value::Scalar(0.0))]); - p.comp_mut(cid).unwrap().layers = vec![owner, pre]; + p.comp_mut(cid).unwrap().layers = vec![owner, pre].into(); let key = |p: &Project, time| { let c = p.comp(cid).unwrap(); crate::cache::layer_key(&crate::EvalCtx::new(p, cid, c, time), &c.layers[0], 1.0, false, false).unwrap() @@ -1224,7 +1224,7 @@ fn scene_reader_dependency(pan: f64) -> (Project, ItemId) { let Value::Vec3(v) = pr.value else { panic!("{path}: {:?}", pr.value) }; pr.value = Value::Vec3([v[0] + pan, v[1], v[2]]); } - p.comp_mut(cid).unwrap().layers = vec![cam, owner, source]; + p.comp_mut(cid).unwrap().layers = vec![cam, owner, source].into(); (p, cid) } @@ -1276,3 +1276,194 @@ fn workers_rendering_a_layer_at_once_share_one_buffer() { assert!(bufs.iter().all(|b| std::sync::Arc::ptr_eq(b, &bufs[0])), "one shared buffer"); assert_eq!(cache.stats().entries, 1); } + +// ------------------------------------------------------------------------------ 3D layer instances + +/// A red triangle in the Z = 0 plane (the engine instance3d tests' glTF, buffer embedded as a +/// data URI), parsed in memory: a FootageSource that knows one model item. +const TRIANGLE: &str = r#"{ + "asset": {"version": "2.0"}, + "scenes": [{"nodes": [0]}], + "nodes": [{"mesh": 0}], + "meshes": [{"primitives": [{"attributes": {"POSITION": 0, "NORMAL": 1}, "material": 0}]}], + "materials": [{"pbrMetallicRoughness": {"baseColorFactor": [1.0, 0.0, 0.0, 1.0]}}], + "buffers": [{"byteLength": 72, "uri": "data:application/octet-stream;base64,AACAvwAAgL8AAAAAAACAPwAAgL8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/"}], + "bufferViews": [ + {"buffer": 0, "byteOffset": 0, "byteLength": 36}, + {"buffer": 0, "byteOffset": 36, "byteLength": 36} + ], + "accessors": [ + {"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3, "min": [-1.0, -1.0, 0.0], "max": [1.0, 1.0, 0.0]}, + {"bufferView": 1, "componentType": 5126, "type": "VEC3", "count": 3} + ] +}"#; + +struct SoloModels; + +impl crate::FootageSource for SoloModels { + fn frame(&self, _: ItemId, _: &effectcraft_project::Footage, _: Tick) -> Option> { + None + } + fn model(&self, _: ItemId, f: &effectcraft_project::Footage) -> Option> { + effectcraft_model::load(&f.path, TRIANGLE.as_bytes(), &|_| None).ok().map(std::sync::Arc::new) + } +} + +/// A red triangle 3D model, a camera panned right by `pan` px and, on top of the stack, the +/// copy: a solid with a 3D Layer Instance effect referencing the model. +fn instance_scene(pan: f64) -> (Project, ItemId) { + let (mut p, cid, comp) = setup(); + // Models are Advanced 3D (layer.newModel switches a Classic comp over). + p.comp_mut(cid).unwrap().renderer = effectcraft_project::Renderer::Advanced3D; + let mid = p.add_item( + "Triangle", + Label::Red, + None, + ItemKind::Footage(effectcraft_project::Footage { + path: "Triangle.gltf".into(), + kind: effectcraft_project::FootageKind::Model, + frame_rate: FrameRate::FPS_30, + codec: "glTF".into(), + ..Default::default() + }), + ); + let model = build::layer(&mut p, &comp, "Triangle", LayerSource::Model { item: mid }, (200, 100), None); + let mut cam = build::layer(&mut p, &comp, "Camera", LayerSource::Camera, (200, 100), None); + for path in ["transform/position", "transform/poi"] { + let pr = cam.props.prop_mut(path).unwrap(); + let Value::Vec3(v) = pr.value else { panic!("{path}: {:?}", pr.value) }; + pr.value = Value::Vec3([v[0] + pan, v[1], v[2]]); + } + let mut owner = solid(&mut p, &comp, [1.0, 1.0, 1.0], 200, 100); + add_effect_200(&mut p, &mut owner, "ec.3d.instance3d", &[("layer", Value::Layer(Some(model.id.0)))]); + p.comp_mut(cid).unwrap().layers = vec![cam, owner, model].into(); + (p, cid) +} + +fn render_models(p: &Project, cid: ItemId, t: Tick, cache: Option<&crate::LayerCache>) -> crate::Image { + let mut r = crate::Renderer::new(p, &SoloModels, crate::RenderOpts::default()); + r.cache = cache; + r.comp_frame(cid, t) +} + +/// The coverage-weighted centre of the triangle (x): where its drawn pixels are, lit or not. +fn cover_x(img: &crate::Image) -> f32 { + let (mut m, mut sx) = (0.0, 0.0); + for y in 0..img.height { + for x in 0..img.width { + let a = img.get(x as i64, y as i64)[3]; + m += a; + sx += a * x as f32; + } + } + assert!(m > 1.0, "the triangle is drawn"); + sx / m +} + +/// Create 3D Layer Instance: the copy (the owner) is cached, and its key covers what the +/// referenced model's solo render depends on — the camera, and the model's in/out gate. +#[test] +fn an_instance_of_a_model_is_cached_and_follows_the_camera() { + let t = Tick::from_seconds_f64(1.0); + let (p, cid) = instance_scene(0.0); + let (q, _) = instance_scene(40.0); + let key_at = |p: &Project, t: Tick| { + let c = p.comp(cid).unwrap(); + crate::cache::layer_key(&crate::EvalCtx::new(p, cid, c, t), &c.layers[1], 1.0, false, false).unwrap() + }; + assert_ne!(key_at(&p, t), key_at(&q, t), "moving the camera changes the copy's key"); + // The same layer at a later time, outside its in/out range: the read turns transparent. + let mut out = p.clone(); + out.comp_mut(cid).unwrap().layers[2].out_point = Tick::from_seconds_f64(0.5); + assert_ne!(key_at(&out, Tick::from_seconds_f64(0.25)), key_at(&out, t), "leaving the model's in/out range changes the copy's key"); + // Two renders of one time: the second is served from the cache. + let cache = crate::LayerCache::default(); + let here = render_models(&p, cid, t, Some(&cache)); + let again = render_models(&p, cid, t, Some(&cache)); + assert_same(&again, &render_models(&p, cid, t, None), "the second render is the cached one"); + assert!(cache.stats().hits > 0, "the copy was served from the cache"); + // Panning the camera: the copy (and the model) move with it. + let panned = render_models(&q, cid, t, Some(&cache)); + assert_same(&panned, &render_models(&q, cid, t, None), "cached render after the camera moved"); + let (a, b) = (cover_x(&here), cover_x(&panned)); + assert!(b < a - 20.0, "panning the camera right moves the copy left: {a} -> {b}"); +} + +/// The camera's depth of field blurs the solo render (focus, aperture, iris, highlights) — +/// none of it is in the projection matrix: changing any of it must change the copy's key, or +/// it would serve the un-blurred pixels. +#[test] +fn the_copy_key_covers_the_cameras_depth_of_field() { + let t = Tick::from_seconds_f64(1.0); + let (p, cid) = instance_scene(0.0); + let set_dof = |p: &Project, on: bool, focus: f64, aperture: f64| { + let mut q = p.clone(); + let cam = &mut q.comp_mut(cid).unwrap().layers[0]; + for (path, v) in + [("cameraOptions/dof", Value::Bool(on)), ("cameraOptions/focusDistance", Value::Scalar(focus)), ("cameraOptions/aperture", Value::Scalar(aperture))] + { + cam.props.prop_mut(path).unwrap().value = v; + } + q + }; + let key_at = |p: &Project| { + let c = p.comp(cid).unwrap(); + crate::cache::layer_key(&crate::EvalCtx::new(p, cid, c, t), &c.layers[1], 1.0, false, false).unwrap() + }; + let ka = key_at(&set_dof(&p, true, 500.0, 20.0)); + let kb = key_at(&set_dof(&p, true, 500.0, 40.0)); // aperture + let kc = key_at(&set_dof(&p, true, 700.0, 20.0)); // focus + let kd = key_at(&set_dof(&p, false, 500.0, 20.0)); // the switch off + assert!(ka != kb && ka != kc && ka != kd && kb != kc, "aperture, focus and the switch each change the key"); +} + +/// A model with an animation clip selected is posed at its layer time: every time is its own +/// entry. Without one (the default) the pose is the same at every time: one key inside the +/// in/out range, as before. +#[test] +fn the_copy_key_covers_an_animated_models_layer_time() { + let (p, cid) = instance_scene(0.0); + let (t0, t1) = (Tick::from_seconds_f64(0.5), Tick::from_seconds_f64(1.0)); + let key_at = |p: &Project, t: Tick| { + let c = p.comp(cid).unwrap(); + crate::cache::layer_key(&crate::EvalCtx::new(p, cid, c, t), &c.layers[1], 1.0, false, false).unwrap() + }; + assert_eq!(key_at(&p, t0), key_at(&p, t1), "a model without a clip keeps one key across times"); + let mut q = p.clone(); + q.comp_mut(cid).unwrap().layers[2].props.prop_mut("geometryOptions/animation").unwrap().value = Value::Enum(1); + assert_ne!(key_at(&q, t0), key_at(&q, t1), "an animated model's key follows its layer time"); + // Two times and back: each is rendered fresh, none served another time's pixels. + let cache = crate::LayerCache::default(); + let first = render_models(&q, cid, t0, Some(&cache)); + let second = render_models(&q, cid, t1, Some(&cache)); + assert_eq!(cache.stats().hits, 0, "the second time is not served the first time's pixels"); + assert_same(&second, &render_models(&q, cid, t1, None), "the second time is a fresh render"); + let again = render_models(&q, cid, t0, Some(&cache)); + assert_eq!(cache.stats().hits, 1, "the first time is served on the way back"); + assert_same(&again, &first, "the way back is the first time's pixels"); +} + +/// An Environment light's image lights Advanced 3D models — its source can be any layer's +/// render: with one on, the read is not cached, so changes to it cannot reuse stale pixels. +#[test] +fn an_environment_light_makes_the_copy_uncached() { + let (p, cid) = instance_scene(0.0); + let mut q = p.clone(); + let comp = q.comp(cid).unwrap().clone(); + let env = build::layer(&mut q, &comp, "Env", LayerSource::Light { kind: effectcraft_project::LightKind::Environment }, (200, 100), None); + q.comp_mut(cid).unwrap().layers.insert(0, env); + let t = Tick::from_seconds_f64(1.0); + let key_at = |p: &Project| { + let c = p.comp(cid).unwrap(); + crate::cache::layer_key(&crate::EvalCtx::new(p, cid, c, t), &c.layers[2], 1.0, false, false) + }; + assert!(key_at(&q).is_none(), "with an Environment light the read is not cached"); + let cache = crate::LayerCache::default(); + let a = render_models(&q, cid, t, Some(&cache)); + let b = render_models(&q, cid, t, Some(&cache)); + assert_same(&a, &b, "two fresh renders agree"); + assert_eq!(cache.stats().hits, 0, "nothing is served from the cache"); + // The light off (not lighting): the cache comes back. + q.comp_mut(cid).unwrap().layers[0].switches.video = false; + assert!(key_at(&q).is_some(), "the light off, the read is cached again"); +} diff --git a/crates/render/src/tests/audio_fx.rs b/crates/render/src/tests/audio_fx.rs index aced8208e..a6c85f2de 100644 --- a/crates/render/src/tests/audio_fx.rs +++ b/crates/render/src/tests/audio_fx.rs @@ -2,7 +2,7 @@ use std::sync::Arc; -use effectcraft_keyframe::Value; +use effectcraft_keyframe::{Keyframe, Value}; use effectcraft_project::build::{self, Ids}; use effectcraft_project::{Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid}; use effectcraft_time::{FrameRate, Tick}; @@ -78,7 +78,7 @@ fn comp_with(id: Option<&str>, vals: &[(&str, Value)], solid: bool) -> (Project, l.props.sub_mut("effects").unwrap().children.push(g.into()); } let mut comp = comp; - comp.layers = vec![l]; + comp.layers = vec![l].into(); let cid = p.add_item("C", effectcraft_color::Label::Sandstone, None, ItemKind::Comp(comp.into())); (p, cid) } @@ -148,6 +148,42 @@ fn stereo_mixer_pans() { assert!(m.chunks(2).all(|f| f[0].abs() < 1e-6 && (f[1] - 0.5).abs() < 1e-6), "{:?}", &m[..4]); } +/// Key `prop` of layer 0 (`effect`: in its first effect, else in Audio) from `a` at 0 s to `b` at 1 s. +fn key(p: &mut Project, cid: ItemId, effect: bool, prop: &str, a: Value, b: Value) { + let l = &mut p.comp_mut(cid).unwrap().layers[0]; + let g = if effect { l.props.sub_mut("effects").unwrap().children[0].as_group_mut().unwrap() } else { l.props.sub_mut("audio").unwrap() }; + g.get_mut(prop).unwrap().keys = vec![Keyframe::new(Tick::ZERO, a), Keyframe::new(Tick::from_seconds_f64(1.0), b)]; +} + +/// Left samples of a mix of constant 0.5 audio over the layer's 1 s, in blocks of `block` samples. +fn left_ramp(p: &Project, cid: ItemId, block: usize) -> Vec { + fn half(_: f64) -> (f32, f32) { + (0.5, 0.5) + } + let mut m = vec![]; + for k in 0..SR as usize / block { + m.extend(mix(p, &Signal(half), cid, (k * block) as f64 / SR as f64, block)); + } + m.iter().step_by(2).copied().collect() +} + +/// Keyframed Audio Levels and Stereo Mixer levels ramp smoothly inside a block (export mixes one +/// second at a time), instead of holding the value at the block's first sample. +#[test] +fn animated_levels_ramp_within_a_block() { + let (mut levels, lc) = comp_with(None, &[], false); + key(&mut levels, lc, false, "levels", Value::Vec2([-60.0, -60.0]), Value::Vec2([0.0, 0.0])); + let (mut mixer, mc) = comp_with(Some("ec.audio.stereomixer"), &[], false); + key(&mut mixer, mc, true, "leftLevel", Value::Scalar(0.0), Value::Scalar(100.0)); + for (name, p, cid, mid) in [("levels", &levels, lc, 0.5 * 10f32.powf(-1.5)), ("stereo mixer", &mixer, mc, 0.25)] { + let one = left_ramp(p, cid, SR as usize); + assert!(one.windows(2).all(|w| w[1] >= w[0]), "{name}: not monotonic"); + assert!(one[100] < one[4000] && one[4000] < one[7900], "{name}: {} {} {}", one[100], one[4000], one[7900]); + assert!((one[4000] / mid - 1.0).abs() < 0.05, "{name}: {} at 0.5 s, want {mid}", one[4000]); + assert_eq!(one, left_ramp(p, cid, 320), "{name}: block size changes the result"); + } +} + #[test] fn tone_makes_a_solid_audible_at_its_frequency() { let mut vals: Vec<(&str, Value)> = vec![("frequency1", Value::Scalar(500.0))]; @@ -236,7 +272,7 @@ fn audio_spectrum_follows_time_through_the_layer_cache() { g.prop_mut("audioLayer").unwrap().value = Value::Layer(Some(a.id.0)); s.props.sub_mut("effects").unwrap().children.push(g.into()); let mut comp = comp; - comp.layers = vec![s, a]; + comp.layers = vec![s, a].into(); let cid = p.add_item("C", effectcraft_color::Label::Sandstone, None, ItemKind::Comp(comp.into())); let src = Signal(late_tone); diff --git a/crates/render/src/tests/collapse.rs b/crates/render/src/tests/collapse.rs index 797960033..30d2163c1 100644 --- a/crates/render/src/tests/collapse.rs +++ b/crates/render/src/tests/collapse.rs @@ -3,7 +3,7 @@ use effectcraft_color::{BlendMode, Label}; use effectcraft_keyframe::{TextDoc, Value}; use effectcraft_project::build::{self, Ids}; -use effectcraft_project::{BitDepth, Comp, ItemId, ItemKind, Layer, LayerSource, Project, Quality, Solid}; +use effectcraft_project::{BitDepth, Comp, FrameBlend, ItemId, ItemKind, Layer, LayerSource, Project, Quality, Solid}; use effectcraft_time::{FrameRate, Tick}; use crate::{EvalCtx, Image, NoFootage, RenderOpts, Renderer, render_frame}; @@ -53,17 +53,17 @@ fn scene(collapse: bool, flatten: bool) -> (Project, ItemId) { a.parent = Some(null.id); b.parent = Some(null.id); let mut c = outer.clone(); - c.layers = vec![null, b, a, bg]; + c.layers = vec![null, b, a, bg].into(); p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(c.into())) } else { let mut inner = inner; - inner.layers = vec![b, a]; + inner.layers = vec![b, a].into(); let iid = p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.into())); let mut pre = build::layer(&mut p, &outer, "Inner", LayerSource::Comp { item: iid }, (100, 100), None); xf(&mut pre); pre.switches.collapse = collapse; let mut c = outer.clone(); - c.layers = vec![pre, bg]; + c.layers = vec![pre, bg].into(); p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(c.into())) }; (p, cid) @@ -110,6 +110,66 @@ fn masks_on_a_collapsed_layer_force_a_flattened_render() { assert!(max_diff(&a, &b) < 1e-4, "{}", max_diff(&a, &b)); } +/// #1747: the flattened path honours a composition proxy and frame blending; a collapsed layer +/// renders the nested comp directly and ignores both, so such a layer must not collapse. +#[test] +fn proxy_and_frame_blending_keep_a_precomp_on_the_flattened_path() { + let (mut p, cid) = scene(true, false); + let id = p.comp(cid).unwrap().layers[0].id; + let collapses = |p: &Project| { + let r = Renderer::new(p, &NoFootage, RenderOpts::default()); + let ctx = EvalCtx::new(p, cid, p.comp(cid).unwrap(), Tick::ZERO); + r.collapsed(&ctx, ctx.comp.layer(id).unwrap()).is_some() + }; + assert!(collapses(&p), "a plain precomp collapses"); + // Frame blending needs both the comp's switch and the layer's. + p.comp_mut(cid).unwrap().enable_frame_blending = true; + p.comp_mut(cid).unwrap().layers[0].switches.frame_blend = FrameBlend::FrameMix; + assert!(!collapses(&p), "frame blending keeps the flattened path"); + p.comp_mut(cid).unwrap().enable_frame_blending = false; + p.comp_mut(cid).unwrap().layers[0].switches.frame_blend = FrameBlend::Off; + // A composition proxy stands in for the precomp's pixels. + let LayerSource::Comp { item } = p.comp(cid).unwrap().layers[0].source else { panic!("a precomp layer") }; + let proxy = + effectcraft_project::Proxy { footage: effectcraft_project::Footage { has_video: true, width: 32, height: 32, ..Default::default() }, enabled: true }; + p.item_mut(item).unwrap().proxy = Some(Box::new(proxy)); + assert!(!collapses(&p), "a proxy keeps the flattened path"); +} + +/// #1747: an effect on B that reads A must not reuse its cached buffer when two collapsed +/// instances give A different static overrides (`hash_values` only sees animated values). +#[test] +fn referenced_layer_overrides_change_the_owner_cache_key() { + let mut p = project(); + let comp = Comp::new(100, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let a = solid(&mut p, &comp, [1.0, 1.0, 1.0], (40, 40), [30.0, 50.0, 0.0]); + let aid = a.id; + let mut b = solid(&mut p, &comp, [1.0, 1.0, 1.0], (100, 100), [50.0, 50.0, 0.0]); + let bid = b.id; + let mut next = p.next_id; + let mut ids = Ids(&mut next); + let spec = effectcraft_effects::find("ec.channel.setmatte").unwrap(); + let mut fx = effectcraft_effects::instantiate(spec, &mut ids, spec.name, [100.0, 100.0]); + fx.prop_mut("takeMatteFromLayer").unwrap().value = Value::Layer(Some(a.id.0)); + p.next_id = next; + b.props.sub_mut("effects").unwrap().children.push(fx.into()); + let mut c = comp; + c.layers = vec![b, a].into(); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into())); + let comp = p.comp(cid).unwrap(); + let b = comp.layer(bid).unwrap(); + let opacity = comp.layer(aid).unwrap().props.prop("transform/opacity").unwrap().uid; + let key = |value: f64| { + let overrides = [crate::PropertyOverride { layer: aid, prop: opacity, value: Value::Scalar(value), font: false }]; + let ctx = + EvalCtx { project: &p, comp_id: cid, comp, time: Tick::ZERO, expr: None, footage: None, property_overrides: &overrides, media_overrides: &[] }; + crate::cache::layer_key(&ctx, b, 1.0, false, false) + }; + let (opaque, half) = (key(100.0), key(50.0)); + assert!(opaque.is_some(), "the owner is cacheable"); + assert_ne!(opaque, half, "A's static override changes B's key"); +} + #[test] fn collapsed_3d_layers_join_the_parent_scene() { // Parent: a red 3D solid at z = 0 above (in the stack) a collapsed 3D precomp holding a @@ -122,7 +182,7 @@ fn collapsed_3d_layers_join_the_parent_scene() { let mut blue = solid(&mut p, &inner, [0.0, 0.0, 1.0], (60, 60), [100.0, 100.0, -200.0]); blue.switches.three_d = true; let mut inner = inner; - inner.layers = vec![blue]; + inner.layers = vec![blue].into(); let iid = p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.into())); let mut pre = build::layer(&mut p, &outer, "Inner", LayerSource::Comp { item: iid }, (200, 200), None); pre.switches.three_d = true; @@ -130,7 +190,7 @@ fn collapsed_3d_layers_join_the_parent_scene() { let mut red = solid(&mut p, &outer, [1.0, 0.0, 0.0], (100, 100), [100.0, 100.0, 0.0]); red.switches.three_d = true; let mut c = outer; - c.layers = vec![red, pre]; + c.layers = vec![red, pre].into(); let cid = p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(c.into())); render_frame(&p, cid, Tick::ZERO, 1.0).get(100, 100) }; @@ -168,11 +228,11 @@ fn scaled_square(cr: bool, via_parent: bool) -> Image { set(&mut null, "transform/scale", Value::Vec3([800.0, 800.0, 100.0])); set(&mut l, "transform/position", Value::Vec3([0.0, 0.0, 0.0])); l.parent = Some(null.id); - c.layers = vec![l, null]; + c.layers = vec![l, null].into(); } else { set(&mut l, "transform/position", Value::Vec3([100.3, 100.3, 0.0])); set(&mut l, "transform/scale", Value::Vec3([800.0, 800.0, 100.0])); - c.layers = vec![l]; + c.layers = vec![l].into(); } let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into())); render_frame(&p, cid, Tick::ZERO, 1.0) @@ -201,7 +261,7 @@ fn wireframe_quality_draws_the_layer_bounds() { let mut l = solid(&mut p, &comp, [1.0, 0.0, 0.0], (40, 40), [50.0, 50.0, 0.0]); l.switches.quality = Quality::Wireframe; let mut c = comp; - c.layers = vec![l]; + c.layers = vec![l].into(); let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into())); let img = render_frame(&p, cid, Tick::ZERO, 1.0); assert_eq!(img.get(50, 50)[3], 0.0, "no fill"); @@ -212,7 +272,7 @@ fn wireframe_quality_draws_the_layer_bounds() { let mut l = solid(&mut p, &comp, [1.0, 1.0, 1.0], (40, 40), [50.5, 50.0, 0.0]); l.switches.quality = Quality::Draft; let mut c = comp; - c.layers = vec![l]; + c.layers = vec![l].into(); let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into())); let img = render_frame(&p, cid, Tick::ZERO, 1.0); assert!(img.data.iter().all(|q| q[3] == 0.0 || q[3] == 1.0)); @@ -230,13 +290,13 @@ fn collapsed_precomp_of_another_size_renders_through_the_parent_camera() { let mut blue = solid(&mut p, &inner, [0.0, 0.0, 1.0], (40, 40), [500.0, 300.0, 0.0]); blue.switches.three_d = true; let mut inner = inner; - inner.layers = vec![blue]; + inner.layers = vec![blue].into(); let iid = p.add_item("Scene", Label::Sandstone, None, ItemKind::Comp(inner.into())); let mut pre = build::layer(&mut p, &outer, "Scene", LayerSource::Comp { item: iid }, (1000, 600), None); pre.switches.three_d = three_d_precomp; pre.switches.collapse = true; let mut c = outer.clone(); - c.layers = vec![pre]; + c.layers = vec![pre].into(); if camera { let cam = build::layer(&mut p, &outer, "Camera", LayerSource::Camera, (300, 200), None); c.layers.insert(0, cam); @@ -267,7 +327,7 @@ fn text_scaled_up_stays_sharp_without_the_switch() { set(&mut l, "transform/scale", Value::Vec3([scale, scale, 100.0])); assert!(!l.switches.collapse); let mut c = comp; - c.layers = vec![l]; + c.layers = vec![l].into(); let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into())); render_frame(&p, cid, Tick::ZERO, 1.0) }; @@ -291,7 +351,7 @@ fn unscaled_text_rasterises_at_the_output_scale() { let mut comp = Comp::new(240, 160, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); let mut l = build::layer(&mut p, &comp, "Text", LayerSource::Text, (240, 160), None); l.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "HE".into(), size: 30.0, ..Default::default() })); - comp.layers = vec![l]; + comp.layers = vec![l].into(); let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.into())); for i in 0..500 { let pos = [3.1 + i as f64 * 0.37, 7.3 + i as f64 * 0.29, 0.0]; @@ -327,7 +387,7 @@ fn effects_on_a_scaled_shape_follow_its_scale() { set(&mut l, "transform/position", Value::Vec3([100.0, 100.0, 0.0])); set(&mut l, "transform/scale", Value::Vec3([scale, scale, 100.0])); let mut c = comp; - c.layers = vec![l]; + c.layers = vec![l].into(); let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into())); render_frame(&p, cid, Tick::ZERO, 1.0) }; diff --git a/crates/render/src/tests/guides.rs b/crates/render/src/tests/guides.rs index cb9851250..44f3fc7b0 100644 --- a/crates/render/src/tests/guides.rs +++ b/crates/render/src/tests/guides.rs @@ -40,7 +40,7 @@ fn scene(nest: Nest) -> (Project, ItemId, ItemId) { guide.switches.guide = true; let bg = solid(&mut p, &inner, [1.0, 0.0, 0.0], (100, 100), three_d); let mut inner = inner; - inner.layers = vec![guide, bg]; + inner.layers = vec![guide, bg].into(); let iid = p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.into())); let mut outer = Comp::new(100, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); if matches!(nest, Nest::CollapsedAdvanced) { @@ -49,7 +49,7 @@ fn scene(nest: Nest) -> (Project, ItemId, ItemId) { let mut pre = build::layer(&mut p, &outer, "Inner", LayerSource::Comp { item: iid }, (100, 100), None); pre.switches.collapse = !matches!(nest, Nest::Precomp); pre.switches.three_d = three_d; - outer.layers = vec![pre]; + outer.layers = vec![pre].into(); let oid = p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(outer.into())); (p, iid, oid) } diff --git a/crates/render/src/tests/motion_tile.rs b/crates/render/src/tests/motion_tile.rs index 7d1645a02..28faca73c 100644 --- a/crates/render/src/tests/motion_tile.rs +++ b/crates/render/src/tests/motion_tile.rs @@ -58,7 +58,7 @@ fn adjustment_scene(out: f64, scale: f64) -> (Project, ItemId) { tile_then_scale(&mut p, &mut adj, out, scale); let mut layers = vec![adj]; layers.extend(picture(&mut p, &comp)); - p.comp_mut(cid).unwrap().layers = layers; + p.comp_mut(cid).unwrap().layers = layers.into(); (p, cid) } @@ -68,7 +68,7 @@ fn layer_scene(out: f64, scale: f64) -> (Project, ItemId) { let inner = Comp::new(W, H, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); let iid = p.add_item("Picture", Label::Sandstone, None, ItemKind::Comp(inner.clone().into())); let layers = picture(&mut p, &inner); - p.comp_mut(iid).unwrap().layers = layers; + p.comp_mut(iid).unwrap().layers = layers.into(); let mut pre = build::layer(&mut p, &comp, "Picture", LayerSource::Comp { item: iid }, (W, H), None); tile_then_scale(&mut p, &mut pre, out, scale); p.comp_mut(cid).unwrap().layers.push(pre); @@ -151,7 +151,7 @@ fn adjustment_motion_tile_matches_the_after_effects_frames() { } adj.props.sub_mut("effects").unwrap().children.push(g.into()); } - p.comp_mut(cid).unwrap().layers = vec![adj, red]; + p.comp_mut(cid).unwrap().layers = vec![adj, red].into(); // Quarter resolution: comp pixel (x, y) is output pixel (x / 4, y / 4). let img = render_frame(&p, cid, Tick::ZERO, 0.25); let at = |x: f64, y: f64| img.get((x / 4.0) as i64, (y / 4.0) as i64); diff --git a/crates/render/src/tests/roi.rs b/crates/render/src/tests/roi.rs index 9182817cf..a9892d9c7 100644 --- a/crates/render/src/tests/roi.rs +++ b/crates/render/src/tests/roi.rs @@ -29,7 +29,7 @@ fn project(three_d: bool) -> (Project, effectcraft_project::ItemId) { } layers.push(l); } - p.comp_mut(cid).unwrap().layers = layers; + p.comp_mut(cid).unwrap().layers = layers.into(); (p, cid) } diff --git a/crates/render/src/tests/styles.rs b/crates/render/src/tests/styles.rs index b59f009de..2450fcf64 100644 --- a/crates/render/src/tests/styles.rs +++ b/crates/render/src/tests/styles.rs @@ -44,7 +44,7 @@ fn scene(color: [f32; 3], bg: Option<[f32; 3]>, f: impl FnOnce(&mut Project, &Co if let Some(bg) = bg { layers.push(solid(&mut p, &comp, bg, 200, 100)); } - p.comp_mut(cid).unwrap().layers = layers; + p.comp_mut(cid).unwrap().layers = layers.into(); (p, cid) } diff --git a/crates/render/src/tests/time.rs b/crates/render/src/tests/time.rs index 8739140de..e9599f70b 100644 --- a/crates/render/src/tests/time.rs +++ b/crates/render/src/tests/time.rs @@ -134,7 +134,16 @@ fn input_keys_ignore_later_effects() { } let key = |p: &Project| { let comp = p.comp(cid).unwrap(); - let ctx = crate::EvalCtx { project: p, comp_id: cid, comp, time: Tick::from_seconds_f64(0.5), expr: None, footage: None }; + let ctx = crate::EvalCtx { + project: p, + comp_id: cid, + comp, + time: Tick::from_seconds_f64(0.5), + expr: None, + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let l = &comp.layers[0]; (crate::cache::input_key(&ctx, l, 1.0, false, false, 1), crate::cache::input_key(&ctx, l, 1.0, false, false, 2)) }; diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 132da9bf6..19c17d53f 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -42,7 +42,8 @@ pub struct CharXf { pub stroke_hsb: [f64; 3], pub stroke_opacity: f64, pub stroke_width: f64, - /// Tracking (pixels) added before / after the character. + /// Tracking (pixels: an animator's Tracking Amount, as After Effects) added before / after + /// the character. pub track_before: f64, pub track_after: f64, /// Line Anchor (percent) and the selection weight it was set with. @@ -509,6 +510,7 @@ pub struct PlacedGlyph { pub fill: [f32; 4], pub stroke: [f32; 4], pub stroke_width: f64, + pub join: effectcraft_path::Join, /// Faux Bold (or synthetic bold for a family without a bold face): how far the outline grows /// on every side, in character space (0 when not bold). pub bold: f64, @@ -694,6 +696,9 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option { let (mut la, mut la_k) = (50.0 * f64::from(align_index(justify)), 0.0); for gi in li_start..li_end { let x = &xfs[gi]; + // An animator's Tracking Amount is in pixels, whatever the font size (After Effects + // 2026: Amount 30 adds 30 px between selected characters at 80 px and at 40 px), unlike + // the Character panel's tracking in 1/1000 em. pen += x.track_before; shift[gi] = pen; pen += x.track_after + vadv[gi]; @@ -804,9 +809,12 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option { }; // Outline relative to the pivot. let local = kurbo::Affine::translate((o[0] - pivot[0], o[1] - pivot[1])) * outline; - let char_m = Mat4::rotate_z(x.rotation) + // Rx · Ry · Rz (Z turns first): the order After Effects gives a 3D layer's X / Y / Z + // Rotation too. #1876 moves layers to it with a helper, `Mat4::rotation_xyz`; once both + // land, this should call that helper (one convention, one place). + let char_m = Mat4::rotate_x(x.rotation_x) * Mat4::rotate_y(x.rotation_y) - * Mat4::rotate_x(x.rotation_x) + * Mat4::rotate_z(x.rotation) * Mat4::from_mat3_affine(&Mat3::skew_deg(-x.skew, x.skew_axis)) * Mat4::scale(Vec3::from(x.scale) / 100.0) * Mat4::translate(-Vec3::from(x.anchor)); @@ -831,6 +839,7 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option { fill, stroke, stroke_width: (base_stroke_w + x.stroke_width).max(0.0), + join: path_join(doc.line_join), bold: if g.synth_bold { FAUX_BOLD_RADIUS * g.size } else { 0.0 }, apply_fill: st.apply_fill, char_index: g.char_index, @@ -846,6 +855,15 @@ pub fn glyph_paths(ctx: &EvalCtx, layer: &Layer) -> Vec<(BezPath, CharXf)> { text_geom(ctx, layer).map(|t| t.glyphs.iter().map(|g| (g.path(), g.xf)).collect()).unwrap_or_default() } +fn path_join(j: effectcraft_keyframe::LineJoin) -> effectcraft_path::Join { + use effectcraft_keyframe::LineJoin; + match j { + LineJoin::Miter => effectcraft_path::Join::Miter, + LineJoin::Round => effectcraft_path::Join::Round, + LineJoin::Bevel => effectcraft_path::Join::Bevel, + } +} + /// One paint pass of a glyph: fill (false) or stroke (true). type Pass = (usize, bool); @@ -912,7 +930,7 @@ fn raster_pass(g: &PlacedGlyph, stroke: bool, paths: &[BezPath], m: &Mat3, rect: } outer } else if stroke { - let st = StrokeStyle { width: g.stroke_width, join: effectcraft_path::Join::Round, ..Default::default() }; + let st = StrokeStyle { width: g.stroke_width, join: g.join, ..Default::default() }; effectcraft_path::stroke_coverage(paths, &st, &mm, w, h) } else { grown_coverage(paths, &mm, w, h, bold) diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index 43f53dce9..503ea097c 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -222,7 +222,7 @@ fn tracking_line_anchor_and_character_offset() { let w0 = bounds_of(&glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0])); set(&mut p.comp_mut(cid).unwrap().layers[0], "text/animators/#1/properties/tracking", Value::Scalar(20.0)); let w1 = bounds_of(&glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0])); - // 4 chars with 20 px tracking: the last glyph moves ~ 3.5 × 20 px further (before & after). + // Tracking Amount 20 (pixels, as After Effects): the last glyph moves ~ 3.5 × 20 px further (before & after). assert!((w1.width() - w0.width() - 3.0 * 20.0).abs() < 1.5, "{} {}", w0.width(), w1.width()); assert!((w1.x0 - w0.x0 - 10.0).abs() < 1.0, "first char shifts by half its tracking: {} {}", w0.x0, w1.x0); // Line Anchor 100%: tracking grows to the left. @@ -262,7 +262,7 @@ fn tracking_grows_from_each_paragraphs_alignment() { p.comp_mut(cid).unwrap().layers.push(l); glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]) }; - // 4 chars with 20 px of tracking each, half before and half after. + // Tracking Amount 20 (pixels): 20 px of tracking each, half before and half after. for (justify, x0, centre, x1) in [(Justify::Left, 10.0, 40.0, 70.0), (Justify::Center, -30.0, 0.0, 30.0), (Justify::Right, -70.0, -40.0, -10.0)] { let d = TextDoc { justify, ..doc("ABCD", 40.0) }; let (w0, w1) = (bounds_of(&tracked(d.clone(), 0.0)), bounds_of(&tracked(d, 20.0))); @@ -504,6 +504,37 @@ fn per_character_3d_projects_characters() { assert!(b[2] - b[0] < flat[2] - flat[0] && b[2] > b[0] + 10, "{b:?} {flat:?}"); } +/// After Effects 2026 (case 931: "H", 400 px, per-character 3D, X / Y / Z Rotation 40 / 30 / +/// 20): a character turns like a 3D layer, Rx · Ry · Rz (Z first), so its matrix is the same +/// product as a layer's X / Y / Z Rotation. The render then matches AE (3 of 5184 grid points +/// off at edges; with Rz · Ry · Rx 23). +#[test] +fn per_character_rotation_turns_z_first_as_a_3d_layer() { + let (mut p, cid, comp) = setup(400, 300); + let mut l = text_layer(&mut p, &comp, doc("H", 80.0)); + l.switches.three_d = true; + animator(&mut p, &mut l, &["rotation"], &["range"]); + let mut next = p.next_id; + let mut extra = build::text_anim_props(&mut Ids(&mut next), "rotation", true); + p.next_id = next; + let props: &mut PropGroup = l.props.group_mut("text/animators/#1/properties").unwrap(); + props.children.retain(|c| c.match_id() != "rotation"); + props.children.extend(extra.drain(..).map(Into::into)); + for (id, v) in [("rotationX", 40.0), ("rotationY", 30.0), ("rotation", 20.0)] { + props.get_mut(id).unwrap().value = Value::Scalar(v); + } + l.props.sub_mut("text").unwrap().get_mut("perChar3d").unwrap().value = Value::Bool(true); + p.comp_mut(cid).unwrap().layers.push(l); + let geom = text_geom(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]).unwrap(); + let g = &geom.glyphs[0]; + let want = Mat4::rotate_x(40.0) * Mat4::rotate_y(30.0) * Mat4::rotate_z(20.0); + for r in 0..3 { + for c in 0..3 { + assert!((g.m.0[r][c] - want.0[r][c]).abs() < 1e-9, "row {r} col {c}: {:?} vs {:?}", g.m.0, want.0); + } + } +} + #[test] fn keyframed_selector_animates() { let (mut p, cid, comp) = setup(400, 200); @@ -567,6 +598,7 @@ fn touching_glyphs_join_without_a_seam() { fill, stroke: [0.0; 4], stroke_width: 0.0, + join: effectcraft_path::Join::Miter, bold: 0.0, apply_fill: true, char_index: 0, @@ -637,6 +669,7 @@ fn faux_bold_under_a_non_uniform_scale() { fill: [1.0; 4], stroke: [0.0; 4], stroke_width: 0.0, + join: effectcraft_path::Join::Miter, bold: 2.0, apply_fill: true, char_index: 0, diff --git a/crates/render/src/three_d/adv/raster.rs b/crates/render/src/three_d/adv/raster.rs index 395057935..1c37a6267 100644 --- a/crates/render/src/three_d/adv/raster.rs +++ b/crates/render/src/three_d/adv/raster.rs @@ -13,7 +13,7 @@ use std::collections::BinaryHeap; use rayon::prelude::*; -use super::scene::{Scene, ShadowMap, Vertex}; +use super::scene::{EnvInfo, Scene, ShadowMap, Vertex}; use super::shade::{Frag, sample, shade}; /// A rendered target at raster size. @@ -500,11 +500,39 @@ pub(crate) fn shadow_sizes(width: u32, height: u32, draft: bool, kinds: &[u32]) sizes.into_iter().map(|z| (z > 0).then_some(z)).collect() } -/// Build the shadow maps of every shadow-casting light (lights marked `shadow == 0`). +/// The Environment light's shadow map: one orthographic map over the scene's bounding sphere +/// from its dominant direction (the light travels from the bright region into the scene), like +/// a parallel light's. +fn env_shadow_map(e: EnvInfo, centre: [f64; 3], radius: f64, size: u32) -> ShadowMap { + // After Effects' environment-light shadows are soft out of the box, and this + // map covers the whole scene bounding sphere — not the view — so its texels + // can be coarse (all the more in a draft render's flat 512 budget). A + // penumbra of at least eight output pixels, or four map texels where those + // are coarser, keeps the edge smooth at any map resolution; Shadow Diffusion + // widens it further, as for the other lights. + let texel = 2.0 * radius / size as f64; + let soft = (4.0 * texel).max(8.0) + (e.shadow_diffusion as f64 * 0.5).min(24.0); + let sun = e.sun.map(|v| v as f64); + let dir = [-sun[0], -sun[1], -sun[2]]; + let eye = [centre[0] + sun[0] * radius * 2.0, centre[1] + sun[1] * radius * 2.0, centre[2] + sun[2] * radius * 2.0]; + let hint = if dir[1].abs() < 0.9 { [0.0, 1.0, 0.0] } else { [0.0, 0.0, 1.0] }; + let focal = (size as f64 * 0.5 / radius) as f32; + ShadowMap { view: basis(eye, dir, hint), focal, ortho: true, size, offset: 0, bias: 1.5, radius: soft as f32 } +} + +/// Build the shadow maps of every shadow-casting light (lights marked `shadow == 0`), and of +/// the Environment light when it casts shadows and its image has a dominant direction (marked +/// `env.shadow == 0`, `env.sun != [0; 3]`): one map from that direction, like a parallel light. pub(crate) fn shadow_maps(s: &mut Scene, draft: bool) { let casters: Vec = (0..s.indices.len() / 3) .filter(|&t| s.materials.get(s.vertices[s.indices[3 * t] as usize].material as usize).is_some_and(|m| m.casts_shadows)) .collect(); + // The Environment light casts a shadow when marked and its image has a dominant direction; + // with nothing to cast one, its mark is cleared either way. + let env_shadow = !casters.is_empty() && s.env.is_some_and(|e| e.shadow == 0 && e.sun != [0.0; 3]); + if !env_shadow && let Some(e) = &mut s.env { + e.shadow = -1; + } if casters.is_empty() { for l in &mut s.lights { l.shadow = -1; @@ -536,6 +564,8 @@ pub(crate) fn shadow_maps(s: &mut Scene, draft: bool) { 1 } }) + // The Environment light's map is a single orthographic one. + .chain(std::iter::once(if env_shadow { 1 } else { 0 })) .collect(); let sizes = shadow_sizes(s.width, s.height, draft, &kinds); let mut maps: Vec = vec![]; @@ -583,6 +613,25 @@ pub(crate) fn shadow_maps(s: &mut Scene, draft: bool) { } s.lights[li].shadow = first; } + // The Environment light's shadow: one orthographic map over the scene from its dominant + // direction (the light travels from the bright region into the scene), after the lights'. + if env_shadow && let Some(e) = s.env { + match sizes.get(s.lights.len()).copied().flatten() { + Some(size) => { + let first = maps.len() as i32; + maps.push(env_shadow_map(e, centre, radius, size)); + if let Some(e) = &mut s.env { + e.shadow = first; + } + } + // The texel budget dropped it. + None => { + if let Some(e) = &mut s.env { + e.shadow = -1; + } + } + } + } let texels: Vec> = maps.par_iter().map(|m| shadow_raster(s, m, &casters)).collect(); for (m, t) in maps.iter_mut().zip(texels) { m.offset = s.shadow_texels.len() as u32; diff --git a/crates/render/src/three_d/adv/scene.rs b/crates/render/src/three_d/adv/scene.rs index a97272434..d66d8db71 100644 --- a/crates/render/src/three_d/adv/scene.rs +++ b/crates/render/src/three_d/adv/scene.rs @@ -172,6 +172,24 @@ pub struct EnvInfo { pub intensity: f32, /// Radians about the vertical axis. pub rotation: f32, + /// Unit direction from the scene towards the environment's dominant (radiance-weighted) + /// bright region — the direction its shadow is cast from, like a parallel light's + /// `to_light`. `[0; 3]` when the image is directionless (uniform, or opposing bright + /// regions that cancel) or the light casts no shadow (nothing reads it then): no shadow. + pub sun: [f32; 3], + /// The image's directional (key) fraction — `|Σ dir·radiance| / Σ radiance` over the + /// sphere, 0 for a uniform image and near 1 for a single bright spot. An Environment + /// light's shadow occludes only this much of its light: the key lobe is blocked where + /// the map says so, the remaining fill keeps shining, so the shadow stays partial the + /// way After Effects renders an area-light shadow (a broad environment like the default + /// studio shadows softly, a lone bright spot on black almost fully). + pub key: f32, + /// Shadow map index into [`Scene::shadows`]: 0 marked (assigned by `raster::shadow_maps`), + /// -1 casts no shadow. Gated by the light's Cast Shadows. + pub shadow: i32, + pub shadow_darkness: f32, + /// The light's Shadow Diffusion (the penumbra width the shadow map's PCF widens to). + pub shadow_diffusion: f32, } #[derive(Clone, Debug, Default)] @@ -572,7 +590,7 @@ pub fn extruded_meshes(ctx: &EvalCtx, layer: &Layer, p: &model::extrude::Extrude groups.push((vec![g.local.clone()], c, g.m, usize::from(!stroke_on_top), g.xf.surface_colors(c))); } if g.stroke_width > 0.0 && g.stroke[3] * op > 0.0 { - let st = effectcraft_path::StrokeStyle { width: g.stroke_width, join: effectcraft_path::Join::Round, ..Default::default() }; + let st = effectcraft_path::StrokeStyle { width: g.stroke_width, join: g.join, ..Default::default() }; if let Some(region) = crate::shapes::stroke_region(std::slice::from_ref(&g.local), &st) { let c = [g.stroke[0], g.stroke[1], g.stroke[2], g.stroke[3] * op]; groups.push((vec![region], c, g.m, usize::from(stroke_on_top), g.xf.surface_colors(c))); @@ -670,9 +688,20 @@ fn card_layer(r: &Renderer, ctx: &EvalCtx, layer: &Layer, b: &mut Builder) { } } -/// Evaluated lights: (scene lights, ambient sum, environment light (layer, intensity × colour, -/// rotation)). -fn lights(ctx: &EvalCtx, lin: bool) -> (Vec, [f32; 3], Option<(Option, [f32; 3], f32)>) { +/// The evaluated Environment light: its image source, intensity × colour, rotation, and +/// shadow settings (After Effects' Light Settings shows Cast Shadows for Environment lights). +#[derive(Clone, Copy)] +pub(crate) struct EnvLight { + src: Option, + color: [f32; 3], + rot: f32, + casts_shadows: bool, + shadow_darkness: f64, + shadow_diffusion: f64, +} + +/// Evaluated lights: (scene lights, ambient sum, environment light). +fn lights(ctx: &EvalCtx, lin: bool) -> (Vec, [f32; 3], Option) { let mut out = vec![]; let mut ambient = [0.0f32; 3]; let mut env = None; @@ -693,7 +722,14 @@ fn lights(ctx: &EvalCtx, lin: bool) -> (Vec, [f32; 3], Option<(Option { @@ -783,8 +819,54 @@ pub fn irradiance_map(sw: u32, sh: u32, src: &[[f32; 4]], w: u32, h: u32) -> Vec out } +/// The environment image's dominant direction and key strength: the mean texel direction +/// weighted by the radiance the light actually emits — solid-angle weighted, rotation applied, +/// each texel's channels scaled by `tint` (the light's colour, `[1; 3]` for a white one) — or +/// `([0; 3], 0.0)` when that radiance is directionless (a uniform environment cancels to zero, +/// and opposing bright regions that cancel leave no mean either — though a chromatic one need +/// not stay directionless once tinted: a red light over equally bright red and blue opposite +/// regions removes the blue pull and points at the red one). The mean of a single bright spot +/// on black points straight at it; a gradient points somewhere between its ends. The returned +/// key fraction is the mean vector's length over the radiance weight — how directional the +/// image is, 0 (uniform) to ~1 (a lone spot) — and is the most of its light an Environment +/// light's shadow can remove. +pub(crate) fn dominant_direction(w: u32, h: u32, d: &[[f32; 4]], tint: [f32; 3], rot: f32) -> ([f32; 3], f32) { + use super::shade::equirect_dir; + let (mut x, mut y, mut z) = (0.0f32, 0.0f32, 0.0f32); + let mut weight = 0.0f32; + for (i, p) in d.iter().enumerate() { + let (tx, ty) = ((i % w as usize) as u32, (i / w as usize) as u32); + let (u, v) = ((tx as f32 + 0.5) / w as f32, (ty as f32 + 0.5) / h as f32); + // Tinted radiance × solid angle (∝ sin θ): the shadow must follow the light the + // environment emits, not the raw texels. + let lum = (p[0] * tint[0] + p[1] * tint[1] + p[2] * tint[2]) * (v * std::f32::consts::PI).sin(); + let dir = equirect_dir(u, v, rot); + x += dir[0] * lum; + y += dir[1] * lum; + z += dir[2] * lum; + weight += lum.abs(); + } + // Under 1 % directional, the image is uniform to the eye: no dominant region to shadow + // from. A non-finite sum (a NaN or infinite texel) is garbage, not a direction: none then. + let len = (x * x + y * y + z * z).sqrt(); + if !weight.is_finite() || !len.is_finite() || weight <= 1e-9 || len < weight * 0.01 { + return ([0.0; 3], 0.0); + } + ([x / len, y / len, z / len], (len / weight).clamp(0.0, 1.0)) +} + /// Prefilter an environment image into the scene's textures. -pub(crate) fn add_environment(b: &mut Builder, w: u32, h: u32, data: Vec<[f32; 4]>, intensity: [f32; 3], rotation: f32) { +pub(crate) fn add_environment(b: &mut Builder, env: &EnvLight, w: u32, h: u32, data: Vec<[f32; 4]>) { + // Intensity × colour folded into the radiance (one scalar on the GPU): tint the texels. + // A white (or off) light leaves them as they are. + let k = (env.color[0] + env.color[1] + env.color[2]) / 3.0; + let tint = if k > 0.0 && (env.color[0] != env.color[1] || env.color[1] != env.color[2]) { env.color.map(|c| c / k) } else { [1.0, 1.0, 1.0] }; + // The shadow direction and key strength from the full-resolution image, before the mip + // chain averages it — only a shadow-casting light reads them, so only one pays for the + // pass — and weighted by the tint, so the shadow follows the radiance the light emits: + // opposing chromatic regions that cancel untinted need not cancel once the colour removes + // one of them (a red light over equally bright red and blue sides shadows from the red). + let (sun, key) = if env.casts_shadows { dominant_direction(w, h, &data, tint, env.rot) } else { ([0.0; 3], 0.0) }; // Radiance level 0 at most 1024 wide. let (mut w, mut h, mut d) = (w, h, data); while w > 1024 { @@ -807,10 +889,7 @@ pub(crate) fn add_environment(b: &mut Builder, w: u32, h: u32, data: Vec<[f32; 4 let (sw, sh, sd) = small.unwrap_or((w, h, d)); let irr = irradiance_map(sw, sh, &sd, 32, 16); let ii = b.texture_from(32, 16, [0, 1], irr.into_iter()) as u32; - // Intensity × colour folded into the radiance (one scalar on the GPU): tint the texels. - let k = (intensity[0] + intensity[1] + intensity[2]) / 3.0; - if k > 0.0 && (intensity[0] != intensity[1] || intensity[1] != intensity[2]) { - let tint = intensity.map(|c| c / k); + if tint != [1.0, 1.0, 1.0] { for t in first..=ii { let ti = b.s.textures[t as usize]; for p in &mut b.s.texels[ti.offset as usize..(ti.offset + ti.width * ti.height) as usize] { @@ -820,16 +899,45 @@ pub(crate) fn add_environment(b: &mut Builder, w: u32, h: u32, data: Vec<[f32; 4 } } } - b.s.env = Some(EnvInfo { radiance: first, mips: levels, irradiance: ii, intensity: k, rotation }); + b.s.env = Some(EnvInfo { + radiance: first, + mips: levels, + irradiance: ii, + intensity: k, + rotation: env.rot, + sun, + key, + // Marked; `raster::shadow_maps` assigns the map (a directionless image casts none). + shadow: if env.casts_shadows { 0 } else { -1 }, + shadow_darkness: env.shadow_darkness as f32, + shadow_diffusion: env.shadow_diffusion as f32, + }); } -/// The environment layer's pixels as linear straight RGBA. -fn env_pixels(r: &Renderer, ctx: &EvalCtx, src: Option, lin: bool) -> Option<(u32, u32, Vec<[f32; 4]>)> { +/// The Environment light's image: `Absent` when the light has no Source and the comp has no +/// Environment Layer (the default studio environment applies — After Effects "uses a default +/// environment map" for an Environment light too), `Image` when the source renders, `Failed` +/// when a selected or found source produced no pixels (a deleted layer, missing footage, a +/// failed decode). A failed source must not silently become the default environment — that +/// would relight and reshadow the scene behind the user's back — so, like any layer whose +/// source fails renders nothing, it contributes nothing (#2436). +enum EnvPixels { + Absent, + Image((u32, u32, Vec<[f32; 4]>)), + Failed, +} + +fn env_pixels(r: &Renderer, ctx: &EvalCtx, src: Option, lin: bool) -> EnvPixels { let layer = match src { - Some(id) => ctx.comp.layer(effectcraft_project::LayerId(id))?, - None => ctx.comp.layers.iter().find(|l| l.environment && l.is_active_at(ctx.time))?, + Some(id) => ctx.comp.layer(effectcraft_project::LayerId(id)), + None => ctx.comp.layers.iter().find(|l| l.environment && l.is_active_at(ctx.time)), }; - let buf = r.blend_layer_buf(ctx, layer)?; + let Some(layer) = layer else { + // A set Source that resolves to no layer (deleted) failed; an unset one with no + // Environment Layer is simply unconfigured. + return if src.is_some() { EnvPixels::Failed } else { EnvPixels::Absent }; + }; + let Some(buf) = r.blend_layer_buf(ctx, layer) else { return EnvPixels::Failed }; let img = &buf.img; let data = img .data @@ -843,7 +951,43 @@ fn env_pixels(r: &Renderer, ctx: &EvalCtx, src: Option, lin: bool) -> Optio if lin { [s[0], s[1], s[2], 1.0] } else { [srgb_to_linear(s[0]), srgb_to_linear(s[1]), srgb_to_linear(s[2]), 1.0] } }) .collect(); - Some((img.width, img.height, data)) + EnvPixels::Image((img.width, img.height, data)) +} + +/// The default environment when the light has no Source and no Environment Layer: a dim studio +/// gradient, bright above and toward the initial camera, dark below and behind — After +/// Effects "uses a default environment map" for an Environment light too, and its default +/// casts shadows out of the box. The solid-angle-weighted mean is 0.3, a soft studio ambience +/// (a mean of 1 would act like a 100 % white Ambient light and wash the whole scene out, which +/// is not what After Effects' default looks like); the light's colour × intensity still scales +/// the overall level, and its Rotation turns the key around the scene (#2436). +pub(crate) fn default_environment() -> (u32, u32, Vec<[f32; 4]>) { + use super::shade::{dot, equirect_dir}; + // The key region's centre: three quarters up, two thirds toward the viewer (the default + // camera looks +z from -z). + let key = [0.0f32, -0.75, -0.66]; + let (w, h) = (8u32, 4u32); + let mut data = Vec::with_capacity((w * h) as usize); + let (mut mean, mut weight) = (0.0f32, 0.0f32); + for y in 0..h { + let v = (y as f32 + 0.5) / h as f32; + let sa = (v * std::f32::consts::PI).sin(); + for x in 0..w { + let u = (x as f32 + 0.5) / w as f32; + let l = 0.15 + 1.2 * dot(equirect_dir(u, v, 0.0), key).max(0.0); + data.push([l, l, l, 1.0]); + mean += l * sa; + weight += sa; + } + } + // A dim studio: scale the gradient to a weighted mean of exactly 0.3. + let k = if mean.is_finite() && mean > 1e-6 { 0.3 * weight / mean } else { 1.0 }; + for p in &mut data { + for c in 0..3 { + p[c] *= k; + } + } + (w, h, data) } /// Supersampling factor: 2×2 unless draft or very large. @@ -857,8 +1001,8 @@ fn emit_layer<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, b: &mut Bu if layer.environment || layer.switches.adjustment || ctx.opacity(layer) <= 0.0 || !layer.has_video() { return; } - if let Some(item) = r.collapsed(ctx, layer) { - collapsed_layer(r, ctx, layer, item, b); + if let Some(collapsed) = r.collapsed(ctx, layer) { + collapsed_layer(r, ctx, layer, &collapsed, b); } else if layer.source.is_model() { model_layer(r, ctx, layer, b); } else if matches!(layer.source, LayerSource::Text | LayerSource::Shape) @@ -874,9 +1018,9 @@ fn emit_layer<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, b: &mut Bu /// (meshes, extrusions, cards) placed by the precomp layer's world transform, so they /// intersect, occlude and shadow the parent's layers. Nested 2D layers lie on the precomp /// layer's plane; nested lights and cameras are ignored (the parent's light the scene). -fn collapsed_layer<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, item: effectcraft_project::ItemId, b: &mut Builder) { +fn collapsed_layer<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, collapsed: &crate::Collapsed, b: &mut Builder) { let op = ctx.opacity(layer) as f32 * r.opacity_mul(); - let Some((sub, nctx)) = r.collapse_into(ctx, layer, item, op) else { return }; + let Some((sub, nctx)) = r.collapse_into(ctx, layer, collapsed, op) else { return }; let saved = b.outer; b.outer = saved * ctx.world_matrix(layer); let t = nctx.time; @@ -935,10 +1079,23 @@ pub(crate) fn build_at(r: &Renderer, ctx: &EvalCtx, sub: Option<&EvalCtx>, run: let (ls, amb, env) = lights(ctx, lin); b.s.lights = ls; b.s.ambient = amb; - if let Some((src, k, rot)) = env - && let Some((w, h, d)) = env_pixels(r, ctx, src, lin) - { - add_environment(&mut b, w, h, d, k, rot); + if let Some(env) = env { + match env_pixels(r, ctx, env.src, lin) { + EnvPixels::Image((w, h, d)) => add_environment(&mut b, &env, w, h, d), + // No Source and no Environment Layer: the default environment, a soft studio + // gradient of the light's colour × intensity — After Effects "uses a default + // environment map" for an Environment light — so the light always lights the + // scene per its parameters, and its dominant region casts shadows (#2436). + EnvPixels::Absent => { + let (w, h, d) = default_environment(); + add_environment(&mut b, &env, w, h, d) + } + // A selected or found source that produced no pixels (deleted layer, missing + // footage, failed decode): no environment at all — the light contributes + // nothing, exactly like a layer whose source failed renders nothing, never a + // silent swap to the default studio's lighting and shadows (#2436). + EnvPixels::Failed => {} + } } if r.opts.shadows() { super::raster::shadow_maps(&mut b.s, r.opts.draft); @@ -947,6 +1104,9 @@ pub(crate) fn build_at(r: &Renderer, ctx: &EvalCtx, sub: Option<&EvalCtx>, run: for l in &mut b.s.lights { l.shadow = -1; } + if let Some(e) = &mut b.s.env { + e.shadow = -1; + } } } // Camera matrices. diff --git a/crates/render/src/three_d/adv/shade.rs b/crates/render/src/three_d/adv/shade.rs index 19548b224..820e8fecf 100644 --- a/crates/render/src/three_d/adv/shade.rs +++ b/crates/render/src/three_d/adv/shade.rs @@ -382,6 +382,23 @@ pub fn light_shadow(s: &Scene, l: &Light, p: [f32; 3], ng: [f32; 3], n_l: f32) - 1.0 - l.shadow_darkness * (1.0 - f) } +/// The visibility of the Environment light at `p` (1 = unshadowed), darkened by its Shadow +/// Darkness — but only by the image's key fraction: the shadow map occludes the dominant +/// (key) lobe, while the environment's fill keeps shining on the shadowed surface, so an +/// Environment light's shadow stays partial the way After Effects renders an area-light +/// shadow (a broad environment like the default studio shadows softly, a lone bright spot +/// on black almost fully). A directionless image (or a non-casting light) has no map +/// and shades unshadowed. +pub fn env_shadow(s: &Scene, e: &EnvInfo, p: [f32; 3], ng: [f32; 3], n: [f32; 3]) -> f32 { + if e.shadow < 0 || e.sun == [0.0; 3] { + return 1.0; + } + let Some(m) = s.shadows.get(e.shadow as usize) else { return 1.0 }; + let n_l = dot(n, e.sun).clamp(0.0, 1.0); + let f = shadow_factor(m, &s.shadow_texels, p, ng, n_l); + 1.0 - e.shadow_darkness * e.key * (1.0 - f) +} + /// Unit direction from `p` to light `l`, and the distance (infinite for a parallel light). fn to_light(l: &Light, p: [f32; 3]) -> ([f32; 3], f32) { if l.kind == 0 { @@ -529,9 +546,11 @@ pub fn shade(s: &Scene, f: &Frag) -> Option<[f32; 4]> { let (irr, spec) = env_light(s, e, n, r, rough); let f0 = [mix(0.04 * m.specular_k, rgb[0], metallic), mix(0.04 * m.specular_k, rgb[1], metallic), mix(0.04 * m.specular_k, rgb[2], metallic)]; let (a, b) = env_brdf(n_v, rough); + // The Environment light's shadow darkens its own diffuse and specular. + let sh = if m.receives_shadows { env_shadow(s, e, f.pos, ng, n) } else { 1.0 }; for c in 0..3 { let fs = f0[c] * a + b; - out[c] += (irr[c] * rgb[c] * (1.0 - fs) * (1.0 - metallic) * m.diffuse_k + spec[c] * fs) * ao; + out[c] += (irr[c] * rgb[c] * (1.0 - fs) * (1.0 - metallic) * m.diffuse_k + spec[c] * fs) * ao * sh; } } let c = add(out, emissive); @@ -557,5 +576,9 @@ fn shadow_only(s: &Scene, m: &Material, f: &Frag, alpha: f32) -> [f32; 4] { } amount = amount.max(1.0 - light_shadow(s, l, f.pos, n, n_l)); } + // The Environment light's shadow counts too (it reaches every surface: no falloff). + if let Some(e) = &s.env { + amount = amount.max(1.0 - env_shadow(s, e, f.pos, n, n)); + } [0.0, 0.0, 0.0, alpha * amount] } diff --git a/crates/render/src/three_d/adv/tests.rs b/crates/render/src/three_d/adv/tests.rs index 6e626ad0d..b2612de98 100644 --- a/crates/render/src/three_d/adv/tests.rs +++ b/crates/render/src/three_d/adv/tests.rs @@ -2,7 +2,7 @@ use effectcraft_keyframe::Value; use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, LightKind, PrimitiveKind, Project, Renderer as R3, build}; use effectcraft_time::{FrameRate, Tick}; -use super::scene::{Material, Scene, TexInfo, Vertex, irradiance_map}; +use super::scene::{Material, Scene, TexInfo, Vertex, dominant_direction, irradiance_map}; use super::shade::{self, PI}; use super::*; use crate::{NoFootage, RenderOpts, Renderer}; @@ -67,6 +67,90 @@ fn constant_environment_has_constant_irradiance() { assert!(irr[16][0] > 0.9 && irr[15 * 32 + 16][0] < 0.1, "{:?} {:?}", irr[16], irr[15 * 32 + 16]); } +/// A NaN or infinite texel poisons the sums (every NaN comparison is false, so the thresholds +/// alone would pass it through as a NaN direction); an infinite one even where sin θ = 0 turns +/// into NaN. Either way the sum is garbage, not a direction: no sun and no key, so no shadow +/// (#2436). +#[test] +fn a_non_finite_environment_has_no_sun_direction() { + let nan = vec![[f32::NAN; 4]; 8 * 4]; + assert_eq!(dominant_direction(8, 4, &nan, [1.0; 3], 0.0), ([0.0; 3], 0.0)); + let inf = vec![[f32::INFINITY, 0.0, 0.0, 1.0]; 8 * 4]; + assert_eq!(dominant_direction(8, 4, &inf, [1.0; 3], 0.0), ([0.0; 3], 0.0)); +} + +/// The key fraction: a uniform image is directionless (key 0), a lone bright spot on black is +/// almost fully directional, and a broad gradient sits in between — the soft studio default +/// shadows visibly but keeps most of its light as fill (#2436). +#[test] +fn environment_key_strength_tracks_directionality() { + let uniform = vec![[0.5f32, 0.5, 0.5, 1.0]; 8 * 4]; + assert_eq!(dominant_direction(8, 4, &uniform, [1.0; 3], 0.0).1, 0.0); + let mut spot = vec![[0.0f32; 4]; 64 * 32]; + spot[16 * 64 + 32] = [1.0, 1.0, 1.0, 1.0]; + let (_, key) = dominant_direction(64, 32, &spot, [1.0; 3], 0.0); + assert!(key > 0.95, "a lone spot is almost fully directional: {key}"); + let (w, h, d) = scene::default_environment(); + let (_, key) = dominant_direction(w, h, &d, [1.0; 3], 0.0); + assert!((0.35..0.55).contains(&key), "the default studio is a broad key with fill: {key}"); +} + +/// #2436 review: a non-uniform image can be directionless too — two equal bright spots at +/// opposite azimuths pull the radiance-weighted mean in opposite directions and cancel, +/// leaving no dominant region to shadow from (under 1 % directional): no sun, no key, so no +/// shadow, just like a uniform image. Tilt the balance and a direction re-emerges, pointing +/// at the brighter spot. +#[test] +fn opposing_bright_regions_cancel_to_no_shadow() { + let (w, h) = (64u32, 32u32); + let mut opposed = vec![[0.0f32; 4]; (w * h) as usize]; + // Rows 15 and 16 mirror about the equator (v = 0.484375 / 0.515625), and columns 16 and 48 + // sit half a width apart (u = 0.2578125 / 0.7578125): the two texel-centre directions are + // exactly opposite — equal solid-angle weight, opposite pull. + let (a, b) = ((15 * w + 16) as usize, (16 * w + 48) as usize); + opposed[a] = [1.0, 1.0, 1.0, 1.0]; + opposed[b] = [1.0, 1.0, 1.0, 1.0]; + assert_eq!(dominant_direction(w, h, &opposed, [1.0; 3], 0.0), ([0.0; 3], 0.0)); + // One side twice as bright: the mean points at the brighter spot with its share of the + // weight (key = 1/3 — two parts against one). + let mut tilted = opposed.clone(); + tilted[a] = [2.0, 2.0, 2.0, 1.0]; + let (sun, key) = dominant_direction(w, h, &tilted, [1.0; 3], 0.0); + assert!(sun != [0.0; 3] && key > 0.3, "the brighter side wins: {sun:?} key {key}"); + let want = shade::equirect_dir(16.5 / 64.0, 15.5 / 32.0, 0.0); + let dot = sun[0] * want[0] + sun[1] * want[1] + sun[2] * want[2]; + assert!(dot > 0.999, "the sun points at the brighter spot: {sun:?} vs {want:?}"); +} + +/// #2436 review: the sun follows the tinted radiance, not the raw texels — equally bright red +/// and blue regions on exactly opposite texels cancel to a white light (no shadow), but the +/// red light's tint removes the blue side's pull, so the mean points straight at the red one +/// (a blue light, at the blue one): the shadow lands where the emitted light comes from. +#[test] +fn a_coloured_light_shadows_from_the_region_it_tints() { + let (w, h) = (64u32, 32u32); + let mut opposed = vec![[0.0f32; 4]; (w * h) as usize]; + // Rows 15 and 16 mirror about the equator, columns 16 and 48 half a width apart: the two + // texel-centre directions are exactly opposite — equal solid-angle weight, opposite pull. + let (a, b) = ((15 * w + 16) as usize, (16 * w + 48) as usize); + opposed[a] = [1.0, 0.0, 0.0, 1.0]; + opposed[b] = [0.0, 0.0, 1.0, 1.0]; + // To a white light the channel sums are equal: they cancel, no shadow. + assert_eq!(dominant_direction(w, h, &opposed, [1.0; 3], 0.0), ([0.0; 3], 0.0)); + // A red light's tint zeroes the blue texel's weight: the red one becomes a lone spot. + // (Any positive scale of the tint — the normalised [3, 0, 0] included — points the same + // way; only the channel ratios matter.) + let (sun, key) = dominant_direction(w, h, &opposed, [1.0, 0.0, 0.0], 0.0); + let want = shade::equirect_dir(16.5 / 64.0, 15.5 / 32.0, 0.0); + let dot = sun[0] * want[0] + sun[1] * want[1] + sun[2] * want[2]; + assert!(dot > 0.999 && key > 0.95, "the red side wins: {sun:?} key {key}"); + // A blue light points at the blue one instead. + let (sun, _) = dominant_direction(w, h, &opposed, [0.0, 0.0, 1.0], 0.0); + let want = shade::equirect_dir(48.5 / 64.0, 16.5 / 32.0, 0.0); + let dot = sun[0] * want[0] + sun[1] * want[1] + sun[2] * want[2]; + assert!(dot > 0.999, "the blue side wins: {sun:?}"); +} + #[test] fn equirect_round_trip_and_env_brdf() { for (u, v) in [(0.1f32, 0.2f32), (0.5, 0.5), (0.9, 0.7)] { @@ -337,6 +421,365 @@ fn cards_and_environment_light() { assert_eq!(lit.data[80 * 120 + 110][3], 0.0); } +/// An Environment light with no Source and no Environment Layer lights the scene with a flat +/// default environment of its colour × intensity — After Effects "uses a default environment +/// map" for an Environment light — not nothing at all (#2436). +#[test] +fn environment_light_without_a_source_lights_the_scene() { + let (mut p, cid) = project(120, 90); + let sid = p.add_item( + "S", + Default::default(), + None, + ItemKind::Solid(effectcraft_project::Solid { color: [0.5, 0.5, 0.5], width: 60, height: 40, pixel_aspect: 1.0 }), + ); + add(&mut p, cid, LayerSource::Solid { item: sid }, |l| l.switches.three_d = true); + let unlit = render(&p, cid); + let c = unlit.data[20 * 120 + 30]; + assert!((c[0] - 0.5).abs() < 0.01 && c[3] > 0.99, "unlit card keeps its colour: {c:?}"); + // No Source, no Environment Layer: the light still lights the card (a flat white + // environment at the light's colour × intensity; a card's Material Options shade it). + add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |_| {}); + let lit = render(&p, cid); + let c2 = lit.data[20 * 120 + 30]; + assert!((c2[0] - c[0]).abs() > 0.05 && c2[3] > 0.99, "the light has an effect: {c2:?} vs {c:?}"); + // The light's parameters drive it: half intensity darkens towards zero intensity's black. + set(p.comp_mut(cid).unwrap().layers.first_mut().unwrap(), "lightOptions/intensity", Value::Scalar(50.0)); + let half = render(&p, cid); + let c3 = half.data[20 * 120 + 30]; + assert!(c3[0] < c2[0] - 0.02, "half intensity lights less: {c3:?} vs {c2:?}"); + set(p.comp_mut(cid).unwrap().layers.first_mut().unwrap(), "lightOptions/intensity", Value::Scalar(0.0)); + let dark = render(&p, cid); + let c4 = dark.data[20 * 120 + 30]; + assert!(c4[0] < 0.02 && c4[3] > 0.99, "zero intensity lights nothing: {c4:?}"); + // And its colour tints the light red. + set(p.comp_mut(cid).unwrap().layers.first_mut().unwrap(), "lightOptions/intensity", Value::Scalar(100.0)); + set(p.comp_mut(cid).unwrap().layers.first_mut().unwrap(), "lightOptions/color", Value::Color([1.0, 0.0, 0.0, 1.0])); + let red = render(&p, cid); + let c5 = red.data[20 * 120 + 30]; + assert!(c5[0] > 0.15 && c5[1] < 0.05 && c5[2] < 0.05, "red environment: {c5:?}"); +} + +/// #2436 review: a selected or auto-picked environment source that fails to render (missing +/// footage, a deleted layer) must not silently swap in the default studio environment — the +/// light contributes nothing, exactly like a layer whose source failed renders nothing. Only a +/// light with no Source at all (and no Environment Layer in the comp) uses the default. +#[test] +fn a_failed_environment_source_lights_nothing() { + let (mut p, cid) = project(120, 90); + let sid = p.add_item( + "S", + Default::default(), + None, + ItemKind::Solid(effectcraft_project::Solid { color: [1.0, 1.0, 1.0], width: 60, height: 40, pixel_aspect: 1.0 }), + ); + add(&mut p, cid, LayerSource::Solid { item: sid }, |l| l.switches.three_d = true); + let unlit = render(&p, cid); + // The Environment Layer's footage is unreadable (missing; NoFootage serves no frames). + let fid = p.add_item( + "F", + Default::default(), + None, + ItemKind::Footage(effectcraft_project::Footage { + path: "missing.exr".into(), + kind: effectcraft_project::FootageKind::Still, + width: 64, + height: 32, + pixel_aspect: 1.0, + duration: Tick::from_seconds_f64(1.0), + has_video: true, + missing: true, + ..Default::default() + }), + ); + add(&mut p, cid, LayerSource::Footage { item: fid }, |l| { + l.switches.three_d = true; + l.environment = true; + }); + let env_layer = p.comp(cid).unwrap().layers.iter().find(|l| l.environment).unwrap().id; + add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| { + set(l, "lightOptions/castsShadows", Value::Bool(true)); + }); + fn light(p: &mut Project, cid: ItemId) -> &mut effectcraft_project::Layer { + p.comp_mut(cid).unwrap().layers.iter_mut().find(|l| l.is_light()).unwrap() + } + // Auto-picked Environment Layer, unreadable: the light is off — no studio default, no + // default-environment shadow either. + let failed = render(&p, cid); + assert_eq!(failed.data, unlit.data, "an unreadable Environment Layer lights nothing"); + // The light's Source set to that unreadable layer: same. + set(light(&mut p, cid), "lightOptions/source", Value::Layer(Some(env_layer.0))); + let failed = render(&p, cid); + assert_eq!(failed.data, unlit.data, "an unreadable selected Source lights nothing"); + // The Source pointing at a deleted layer: same. + set(light(&mut p, cid), "lightOptions/source", Value::Layer(Some(999_999))); + let failed = render(&p, cid); + assert_eq!(failed.data, unlit.data, "a deleted selected Source lights nothing"); + // Contrast: with no Source and no Environment Layer, the default studio still lights it. + let comp = p.comp_mut(cid).unwrap(); + comp.layers.retain(|l| l.is_light() || !l.environment); + set(comp.layers.iter_mut().find(|l| l.is_light()).unwrap(), "lightOptions/source", Value::Layer(None)); + let lit = render(&p, cid); + let (c, c2) = (unlit.data[20 * 120 + 30], lit.data[20 * 120 + 30]); + assert!(c2[3] > 0.99 && (c2[0] - c[0]).abs() > 0.05, "the unconfigured light still lights: {c2:?} vs {c:?}"); +} + +/// #2436: an Environment light's Cast Shadows works like After Effects' — its image's dominant +/// (brightest) region shadows the scene. A bright disc above the image's centre, turned to +/// shine from up and behind the camera (rotation 180°), shadows a sphere onto the floor below +/// it; Cast Shadows off lights it evenly. +#[test] +fn environment_lights_cast_shadows() { + let (mut p, cid) = project(200, 150); + // The floor facing the camera, a sphere in front of it casting shadows only. + add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Plane }, |l| { + set(l, "geometryOptions/width", Value::Scalar(400.0)); + set(l, "geometryOptions/height", Value::Scalar(400.0)); + set(l, "transform/position", Value::Vec3([100.0, 75.0, 110.0])); + set(l, "materialOptions/baseColor", Value::Color([1.0, 1.0, 1.0, 1.0])); + }); + add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Sphere }, |l| { + set(l, "geometryOptions/radius", Value::Scalar(30.0)); + set(l, "transform/position", Value::Vec3([100.0, 75.0, 50.0])); + set(l, "materialOptions/castsShadows", Value::Enum(2)); + }); + // The Environment Layer: a tight bright key above the image's centre, on black — a + // full-comp rect keeps the layer buffer full-size, the radial gradient is the key. + add(&mut p, cid, LayerSource::Shape, |l| { + l.switches.three_d = true; + let mut next = 10_000u64; + let ids = &mut build::Ids(&mut next); + let rect = build::shape_rect(ids, [200.0, 150.0], [0.0, 0.0], 0.0); + let key = build::shape_gradient_fill( + ids, + true, + [0.0, -30.0], + [0.0, 5.0], + effectcraft_keyframe::Gradient { colors: vec![(0.0, [1.0, 1.0, 1.0, 1.0]), (1.0, [0.0, 0.0, 0.0, 1.0])], opacities: vec![(0.0, 1.0), (1.0, 1.0)] }, + ); + let g = build::shape_group(ids, "Key", vec![rect, key]); + l.props.sub_mut("contents").unwrap().children.push(g.into()); + l.environment = true; + }); + // Turned 180°, the key shines from up and behind the camera onto the sphere and floor. + add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| { + set(l, "lightOptions/rotation", Value::Scalar(180.0)); + set(l, "lightOptions/castsShadows", Value::Bool(true)); + }); + // The shadow lands below the sphere on the floor: its core is the darkest floor pixel + // below the sphere; the lit floor is the sample to the left. The frame is sRGB-encoded, + // so the ratios below are gamma-compressed (a linear halving reads as ~0.73). + let luma = |img: &crate::Image, x: usize, y: usize| (img.data[y * 200 + x][0] + img.data[y * 200 + x][1] + img.data[y * 200 + x][2]) / 3.0; + let img = render(&p, cid); + let side = luma(&img, 25, 75); + let (mut core, mut cx, mut cy) = (f32::INFINITY, 0usize, 0usize); + for y in 100..150 { + for x in 60..140 { + if luma(&img, x, y) < core { + core = luma(&img, x, y); + cx = x; + cy = y; + } + } + } + assert!(side > 0.1, "the floor is lit: {side}"); + assert!(core < side * 0.65, "the key shadows the sphere onto the floor: {core} vs {side} at ({cx}, {cy})"); + // Cast Shadows off: the floor is lit everywhere, core included. + let comp = p.comp_mut(cid).unwrap(); + set(&mut comp.layers[0], "lightOptions/castsShadows", Value::Bool(false)); + let img = render(&p, cid); + let mid = luma(&img, cx, cy); + assert!(mid > side * 0.9, "no shadow without Cast Shadows: {mid} vs {side}"); +} + +/// #2436: a uniform environment has no dominant region, so even with Cast Shadows on it casts +/// nothing — the render is the same as with the option off. +#[test] +fn a_uniform_environment_casts_no_shadow() { + let (mut p, cid) = project(120, 90); + let card = p.add_item( + "S", + Default::default(), + None, + ItemKind::Solid(effectcraft_project::Solid { color: [1.0, 1.0, 1.0], width: 60, height: 40, pixel_aspect: 1.0 }), + ); + add(&mut p, cid, LayerSource::Solid { item: card }, |l| l.switches.three_d = true); + let env = p.add_item( + "E", + Default::default(), + None, + ItemKind::Solid(effectcraft_project::Solid { color: [1.0, 1.0, 1.0], width: 64, height: 32, pixel_aspect: 1.0 }), + ); + add(&mut p, cid, LayerSource::Solid { item: env }, |l| { + l.environment = true; + l.switches.three_d = true; + }); + add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| { + set(l, "lightOptions/castsShadows", Value::Bool(true)); + }); + let casting = render(&p, cid); + let comp = p.comp_mut(cid).unwrap(); + set(&mut comp.layers[0], "lightOptions/castsShadows", Value::Bool(false)); + let off = render(&p, cid); + assert!(casting.data[20 * 120 + 30][0] > 0.3, "the card is lit"); + assert_eq!(casting.data, off.data, "a directionless image casts no shadow"); +} + +/// #2436 review: a chromatic environment under a coloured light — equally bright red and blue +/// regions on exactly opposite sides of the image cancel to a white light (it casts no +/// shadow), but the red Environment light's tint removes the blue region's pull, so the red +/// one becomes the key and casts the shadow the user asked for, from the red side. +#[test] +fn a_red_light_shadows_from_the_red_region_of_a_chromatic_environment() { + let (mut p, cid) = project(200, 150); + // The floor facing the camera, a sphere in front of it casting shadows only. + add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Plane }, |l| { + set(l, "geometryOptions/width", Value::Scalar(400.0)); + set(l, "geometryOptions/height", Value::Scalar(400.0)); + set(l, "transform/position", Value::Vec3([100.0, 75.0, 110.0])); + set(l, "materialOptions/baseColor", Value::Color([1.0, 1.0, 1.0, 1.0])); + }); + add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Sphere }, |l| { + set(l, "geometryOptions/radius", Value::Scalar(30.0)); + set(l, "transform/position", Value::Vec3([100.0, 75.0, 50.0])); + set(l, "materialOptions/castsShadows", Value::Enum(2)); + }); + // The Environment Layer: a black background with a red square and an equally bright blue + // one at exactly antipodal image positions (the blue is the point mirror of the red through + // the antipodal map x -> x + w/2, y -> h - y: centres (14.5, 44.5) and (114.5, 105.5) in a + // 200x150 image, 20x20 each) — equal solid-angle weight, opposite pull. The red sits up, + // left and toward the camera; the blue down, right and away from it. + add(&mut p, cid, LayerSource::Shape, |l| { + l.environment = true; + let mut next = 10_000u64; + let ids = &mut build::Ids(&mut next); + // Contents stack like the AE timeline — the first group is on top — so the background + // goes in last, under the red and blue squares (and its full-comp rect keeps the + // layer buffer full-size: a shape layer's buffer otherwise crops to its content). + let red_rect = build::shape_rect(ids, [20.0, 20.0], [-85.5, -30.5], 0.0); + let red_fill = build::shape_fill(ids, [1.0, 0.0, 0.0, 1.0]); + let red = build::shape_group(ids, "Red", vec![red_rect, red_fill]); + let blue_rect = build::shape_rect(ids, [20.0, 20.0], [14.5, 30.5], 0.0); + let blue_fill = build::shape_fill(ids, [0.0, 0.0, 1.0, 1.0]); + let blue = build::shape_group(ids, "Blue", vec![blue_rect, blue_fill]); + let bg_rect = build::shape_rect(ids, [200.0, 150.0], [0.0, 0.0], 0.0); + let bg_fill = build::shape_fill(ids, [0.0, 0.0, 0.0, 1.0]); + let bg = build::shape_group(ids, "BG", vec![bg_rect, bg_fill]); + for g in [red, blue, bg] { + l.props.sub_mut("contents").unwrap().children.push(g.into()); + } + }); + // A white light over the chromatic image: the regions cancel — Cast Shadows changes + // nothing, exactly like a directionless image. + add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| { + set(l, "lightOptions/castsShadows", Value::Bool(true)); + }); + let white_casting = render(&p, cid); + let comp = p.comp_mut(cid).unwrap(); + set(comp.layers.iter_mut().find(|l| l.is_light()).unwrap(), "lightOptions/castsShadows", Value::Bool(false)); + let white_off = render(&p, cid); + assert_eq!(white_casting.data, white_off.data, "a white light sees no dominant region in the opposed red and blue"); + // The red light: the tint removes the blue region, the red one shadows the sphere onto the + // floor below and right of it (the shadow falls opposite the red region). + let comp = p.comp_mut(cid).unwrap(); + let light = comp.layers.iter_mut().find(|l| l.is_light()).unwrap(); + set(light, "lightOptions/color", Value::Color([1.0, 0.0, 0.0, 1.0])); + set(light, "lightOptions/castsShadows", Value::Bool(true)); + let img = render(&p, cid); + let chan = |img: &crate::Image, x: usize, y: usize| img.data[y * 200 + x][0]; + let side = chan(&img, 25, 75); + let (mut core, mut cx) = (f32::INFINITY, 0usize); + for y in 100..150 { + for x in 60..170 { + if chan(&img, x, y) < core { + core = chan(&img, x, y); + cx = x; + } + } + } + assert!(side > 0.1, "the floor is lit red: {side}"); + assert!(core < side * 0.65, "the red region shadows the sphere onto the floor: {core} vs {side} at x {cx}"); + assert!(cx > 100, "the shadow is opposite the red region (right of the sphere): {cx}"); +} + +/// #2436: the default environment (no Source, no Environment Layer) is a dim studio — a mean +/// of 0.3, not the 1.0 that would act like a 100 % white Ambient light and wash the scene out +/// (After Effects' default does not) — and directional: its dominant region points up and +/// toward the initial camera, so a shadow-casting Environment light casts soft, partial +/// shadows out of the box, like After Effects' default. +#[test] +fn default_environment_keeps_the_level_and_points_up() { + let (w, h, d) = scene::default_environment(); + assert_eq!((w, h), (8, 4)); + let (mut mean, mut weight) = (0.0f32, 0.0f32); + for (i, p) in d.iter().enumerate() { + let v = ((i / w as usize) as f32 + 0.5) / h as f32; + let sa = (v * PI).sin(); + mean += (p[0] + p[1] + p[2]) / 3.0 * sa; + weight += sa; + } + assert!((mean / weight - 0.3).abs() < 0.01, "dim studio mean: {}", mean / weight); + let (sun, key) = dominant_direction(w, h, &d, [1.0; 3], 0.0); + assert!(sun[1] < -0.5, "the key is up: {sun:?}"); + assert!(sun[2] < -0.3, "the key is toward the camera: {sun:?}"); + assert!(sun[0].abs() < 0.1, "symmetric left-right: {sun:?}"); + assert!((0.35..0.55).contains(&key), "a broad key with fill: {key}"); + // No texel clips past white at the default intensity (the fog of a mean-1 environment). + assert!(d.iter().all(|p| p[0] <= 1.0 && p[0] > 0.0), "0 < radiance <= 1"); +} + +/// #2436: with no Source and no Environment Layer, the Environment light's default environment +/// casts soft, partial shadows like After Effects' default environment map — a sphere shadows +/// the plane behind it, darkening part of the light while the fill keeps the floor visible +/// (an area-light shadow, not the pitch black a point light casts); Cast Shadows off lights +/// the floor evenly. +#[test] +fn the_default_environment_casts_shadows() { + let (mut p, cid) = project(200, 150); + // The floor facing the camera, a sphere in front of it casting shadows only. + add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Plane }, |l| { + set(l, "geometryOptions/width", Value::Scalar(400.0)); + set(l, "geometryOptions/height", Value::Scalar(400.0)); + set(l, "transform/position", Value::Vec3([100.0, 75.0, 120.0])); + set(l, "materialOptions/baseColor", Value::Color([1.0, 1.0, 1.0, 1.0])); + }); + add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Sphere }, |l| { + set(l, "geometryOptions/radius", Value::Scalar(30.0)); + set(l, "transform/position", Value::Vec3([100.0, 45.0, 50.0])); + set(l, "materialOptions/castsShadows", Value::Enum(2)); + }); + add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| { + set(l, "lightOptions/castsShadows", Value::Bool(true)); + }); + // The default key (up and toward the camera) drops the shadow below the sphere: the core + // is the darkest floor pixel below it, the lit floor the sample to the left. + let luma = |img: &crate::Image, x: usize, y: usize| (img.data[y * 200 + x][0] + img.data[y * 200 + x][1] + img.data[y * 200 + x][2]) / 3.0; + let img = render(&p, cid); + let side = luma(&img, 30, 100); + let (mut core, mut cx, mut cy) = (f32::INFINITY, 0usize, 0usize); + for y in 110..150 { + for x in 80..120 { + if luma(&img, x, y) < core { + core = luma(&img, x, y); + cx = x; + cy = y; + } + } + } + assert!(side > 0.1, "the floor is lit: {side}"); + // Partial: the key lobe is occluded (a clear darkening) but the fill keeps shining — + // not the pitch black a point light's shadow would cast at the same darkness. The frame + // is sRGB-encoded, so the ratios are gamma-compressed (a linear halving reads as ~0.73). + assert!(core < side * 0.8, "the default environment shadows the sphere onto the floor: {core} vs {side} at ({cx}, {cy})"); + assert!(core > side * 0.45, "an area-light shadow, partial like After Effects': {core} vs {side}"); + // Cast Shadows off: the floor is lit everywhere, core included. + let comp = p.comp_mut(cid).unwrap(); + set(&mut comp.layers[0], "lightOptions/castsShadows", Value::Bool(false)); + let img = render(&p, cid); + let mid = luma(&img, cx, cy); + assert!(mid > side * 0.9, "no shadow without Cast Shadows: {mid} vs {side}"); +} + #[test] fn depth_of_field_blurs_out_of_focus_surfaces() { let mut img = Image::new(64, 64); @@ -370,7 +813,16 @@ fn extruded_text_renders_as_a_mesh() { set(l, "geometryOptions/bevelStyle", Value::Enum(1)); set(l, "transform/rotationY", Value::Scalar(40.0)); }); - let ctx = crate::EvalCtx { project: &p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None }; + let ctx = crate::EvalCtx { + project: &p, + comp_id: cid, + comp: p.comp(cid).unwrap(), + time: Tick::ZERO, + expr: None, + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let l = &ctx.comp.layers[0]; let params = scene::extrusion_params(&ctx, l).unwrap(); let meshes = scene::extruded_meshes(&ctx, l, ¶ms); @@ -409,7 +861,16 @@ fn shape_surface_items_colour_the_extruded_surfaces() { set(l, "geometryOptions/extrusionDepth", Value::Scalar(40.0)); set(l, "transform/position", Value::Vec3([100.0, 100.0, 0.0])); }); - let ctx = crate::EvalCtx { project: &p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None }; + let ctx = crate::EvalCtx { + project: &p, + comp_id: cid, + comp: p.comp(cid).unwrap(), + time: Tick::ZERO, + expr: None, + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let l = &ctx.comp.layers[0]; let params = scene::extrusion_params(&ctx, l).unwrap(); let meshes = scene::extruded_meshes(&ctx, l, ¶ms); @@ -445,7 +906,16 @@ fn text_animators_colour_the_extruded_surfaces() { set(l, "geometryOptions/extrusionDepth", Value::Scalar(30.0)); set(l, "geometryOptions/bevelStyle", Value::Enum(1)); }); - let ctx = crate::EvalCtx { project: &p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None }; + let ctx = crate::EvalCtx { + project: &p, + comp_id: cid, + comp: p.comp(cid).unwrap(), + time: Tick::ZERO, + expr: None, + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let l = &ctx.comp.layers[0]; let params = scene::extrusion_params(&ctx, l).unwrap(); let meshes = scene::extruded_meshes(&ctx, l, ¶ms); @@ -643,7 +1113,16 @@ fn extruded_strokes_render_as_bevelled_meshes() { set(l, "geometryOptions/extrusionDepth", Value::Scalar(20.0)); set(l, "geometryOptions/bevelStyle", Value::Enum(1)); }); - let ctx = crate::EvalCtx { project: &p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None }; + let ctx = crate::EvalCtx { + project: &p, + comp_id: cid, + comp: p.comp(cid).unwrap(), + time: Tick::ZERO, + expr: None, + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let l = &ctx.comp.layers[0]; let params = scene::extrusion_params(&ctx, l).unwrap(); let meshes = scene::extruded_meshes(&ctx, l, ¶ms); @@ -1067,3 +1546,54 @@ fn hard_edges_ramp_over_two_texels() { assert!((1.7..3.0).contains(&w), "v {v0}: hard edge ramps over {w} texels"); } } + +// ---------------------------------------------------------------- the After Effects look (#2436) + +/// #2436: an Environment light with every option at its default renders like After Effects': +/// a dim studio that leaves the lit floor a mid gray instead of washing it out to white (the +/// fog of a mean-1 default), a partial shadow (the key lobe occluded, the fill still shining — +/// not the pitch black a point light casts), and a soft edge in both quality and draft renders +/// (the map's texel grid never shows through as stair-steps). +#[test] +fn environment_shadows_match_the_after_effects_look() { + let (mut p, cid) = project(800, 600); + add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Plane }, |l| { + set(l, "geometryOptions/width", Value::Scalar(1200.0)); + set(l, "geometryOptions/height", Value::Scalar(900.0)); + set(l, "transform/position", Value::Vec3([400.0, 300.0, 300.0])); + set(l, "materialOptions/baseColor", Value::Color([1.0, 1.0, 1.0, 1.0])); + }); + add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Sphere }, |l| { + set(l, "geometryOptions/radius", Value::Scalar(80.0)); + set(l, "transform/position", Value::Vec3([400.0, 300.0, 120.0])); + set(l, "materialOptions/castsShadows", Value::Enum(2)); + }); + add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| { + set(l, "lightOptions/castsShadows", Value::Bool(true)); + }); + let luma = |img: &crate::Image, x: usize, y: usize| (img.data[y * 800 + x][0] + img.data[y * 800 + x][1] + img.data[y * 800 + x][2]) / 3.0; + for (name, opts) in [("quality", RenderOpts::default()), ("draft", RenderOpts { draft: true, ..Default::default() })] { + let img = Renderer::new(&p, &NoFootage, opts).comp_frame(cid, Tick::ZERO); + // The shadow core: the darkest pixel of the lower half (the key drops the shadow + // below the sphere); the lit floor is the mean of the band to its left. + let (mut core, mut cx, mut cy) = (f32::INFINITY, 0usize, 0usize); + for y in 420..580 { + for x in 300..500 { + if luma(&img, x, y) < core { + core = luma(&img, x, y); + cx = x; + cy = y; + } + } + } + let lit: f32 = (60..260).map(|x| luma(&img, x, cy)).sum::() / 200.0; + assert!(lit > 0.15 && lit < 0.85, "{name}: the lit floor is a mid gray, no white fog: {lit}"); + // The frame is sRGB-encoded, so the ratio is gamma-compressed (a linear halving + // reads as ~0.73): partial means clearly darker than lit, far from black. + assert!(core > lit * 0.45 && core < lit * 0.85, "{name}: a partial area-light shadow: core {core} vs lit {lit}"); + // The edge's 10-90 % ramp from the lit plateau (left) down into the shadow. + let first_below = |t: f32| (260..cx).position(|x| luma(&img, x, cy) < core + (lit - core) * t).map(|i| 260 + i); + let w = (first_below(0.1).unwrap() as f32) - (first_below(0.9).unwrap() as f32); + assert!(w >= 3.5, "{name}: the edge ramps over {w} px (After Effects' env shadows are soft)"); + } +} diff --git a/crates/render/src/three_d/bokeh.rs b/crates/render/src/three_d/bokeh.rs index 12bcbcf37..098f095d0 100644 --- a/crates/render/src/three_d/bokeh.rs +++ b/crates/render/src/three_d/bokeh.rs @@ -289,16 +289,24 @@ pub fn progressive_blur(img: &Image, iris: &Iris, radius: &[f32]) -> Image { return bokeh_blur(img, iris, levels[0] as f64); } let (lo, hi, n) = (levels[0], levels[levels.len() - 1], levels.len()); - let blurred: Vec = levels.par_iter().map(|&r| bokeh_blur(img, iris, r as f64)).collect(); + // A pixel blends only the two levels around its radius, so the levels are made in order and + // only that pair is held: peak memory stays a few images whatever the level count, rather + // than one image per level. Each level's blur is parallel over its rows. let mut out = Image::new(img.width, img.height); - out.data.par_iter_mut().enumerate().for_each(|(i, o)| { - let r = radius[i]; - let f = ((r - lo) / (hi - lo) * (n - 1) as f32).clamp(0.0, (n - 1) as f32); - let k = (f.floor() as usize).min(n - 2); - let t = f - k as f32; - let (a, b) = (blurred[k].data[i], blurred[k + 1].data[i]); - *o = [0, 1, 2, 3].map(|c| a[c] + (b[c] - a[c]) * t); - }); + let mut below = bokeh_blur(img, iris, lo as f64); + for (k, &r) in levels.iter().enumerate().skip(1) { + let above = bokeh_blur(img, iris, r as f64); + out.data.par_iter_mut().enumerate().for_each(|(i, o)| { + let f = ((radius[i] - lo) / (hi - lo) * (n - 1) as f32).clamp(0.0, (n - 1) as f32); + let kk = (f.floor() as usize).min(n - 2); + if kk + 1 == k { + let t = f - kk as f32; + let (a, b) = (below.data[i], above.data[i]); + *o = [0, 1, 2, 3].map(|c| a[c] + (b[c] - a[c]) * t); + } + }); + below = above; + } out } diff --git a/crates/render/src/three_d/camera.rs b/crates/render/src/three_d/camera.rs index 5af8859d5..a927e2ceb 100644 --- a/crates/render/src/three_d/camera.rs +++ b/crates/render/src/three_d/camera.rs @@ -37,7 +37,8 @@ pub struct Dof { impl Dof { /// Circle-of-confusion diameter (comp pixels) for a point at camera depth `z`. pub fn coc(&self, z: f64) -> f64 { - if z <= NEAR || self.focus <= 0.0 { + // No defocus can be measured against a non-finite focus or depth, so it counts as in focus. + if !z.is_finite() || z <= NEAR || !self.focus.is_finite() || self.focus <= 0.0 { return 0.0; } self.aperture * (z - self.focus).abs() / z * self.blur_level @@ -360,22 +361,27 @@ pub fn layer_camera(ctx: &EvalCtx, cam: &Layer) -> CameraState { let (eye, fwd, down) = layer_frame(ctx, cam); let opts = cam.props.sub("cameraOptions"); let zoom = opts.map(|g| ctx.f(cam, g, "zoom", 1000.0)).unwrap_or(1000.0).max(1e-3); - let dof = opts.filter(|g| ctx.b(cam, g, "dof")).map(|g| Dof { - focus: ctx.f(cam, g, "focusDistance", zoom), - aperture: ctx.f(cam, g, "aperture", default_aperture(w)).max(0.0), - blur_level: ctx.f(cam, g, "blurLevel", 100.0).max(0.0) / 100.0, - iris: super::bokeh::Iris { - sides: super::bokeh::Iris::sides_of_shape(ctx.e(cam, g, "irisShape")), - rotation: ctx.f(cam, g, "irisRotation", 0.0), - roundness: ctx.f(cam, g, "irisRoundness", 0.0) / 100.0, - aspect: ctx.f(cam, g, "irisAspectRatio", 1.0).max(0.01), - fringe: ctx.f(cam, g, "irisDiffractionFringe", 0.0).max(0.0) / 100.0, - }, - highlight: super::bokeh::Highlight { - gain: ctx.f(cam, g, "highlightGain", 0.0).clamp(0.0, 100.0) / 100.0, - threshold: ctx.f(cam, g, "highlightThreshold", 255.0).clamp(0.0, 255.0) / 255.0, - saturation: ctx.f(cam, g, "highlightSaturation", 0.0).clamp(0.0, 100.0) / 100.0, - }, + let dof = opts.filter(|g| ctx.b(cam, g, "dof")).map(|g| { + // A non-finite Focus Distance would make every blur radius non-finite; use the value the + // property defaults to (the zoom plane) instead. + let focus = ctx.f(cam, g, "focusDistance", zoom); + Dof { + focus: if focus.is_finite() { focus } else { zoom }, + aperture: ctx.f(cam, g, "aperture", default_aperture(w)).max(0.0), + blur_level: ctx.f(cam, g, "blurLevel", 100.0).max(0.0) / 100.0, + iris: super::bokeh::Iris { + sides: super::bokeh::Iris::sides_of_shape(ctx.e(cam, g, "irisShape")), + rotation: ctx.f(cam, g, "irisRotation", 0.0), + roundness: ctx.f(cam, g, "irisRoundness", 0.0) / 100.0, + aspect: ctx.f(cam, g, "irisAspectRatio", 1.0).max(0.01), + fringe: ctx.f(cam, g, "irisDiffractionFringe", 0.0).max(0.0) / 100.0, + }, + highlight: super::bokeh::Highlight { + gain: ctx.f(cam, g, "highlightGain", 0.0).clamp(0.0, 100.0) / 100.0, + threshold: ctx.f(cam, g, "highlightThreshold", 255.0).clamp(0.0, 255.0) / 255.0, + saturation: ctx.f(cam, g, "highlightSaturation", 0.0).clamp(0.0, 100.0) / 100.0, + }, + } }); CameraState { view: basis_view(eye, fwd, down), eye, zoom, ortho: false, dof } } diff --git a/crates/render/src/three_d/compose.rs b/crates/render/src/three_d/compose.rs index 38ac5b962..a5a7c170e 100644 --- a/crates/render/src/three_d/compose.rs +++ b/crates/render/src/three_d/compose.rs @@ -18,7 +18,7 @@ use std::sync::Arc; use effectcraft_color::{BlendMode, blend_pixel}; use effectcraft_effects::Buf; use effectcraft_geom::{Mat3, Mat4, Vec3, vec3}; -use effectcraft_project::{Layer, LightKind, MatteKind}; +use effectcraft_project::{Layer, LightKind, MatteKind, limits}; use effectcraft_raster::{Image, Px, hash_noise}; use effectcraft_time::Tick; use rayon::prelude::*; @@ -195,13 +195,13 @@ fn mb_samples(r: &Renderer, ctx: &EvalCtx, layer: &Layer) -> usize { } /// Prepare a layer (buffer, geometry, material). `None` when it shows nothing. -pub(crate) fn prepare<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool) -> Option> { +pub(crate) fn prepare<'a>(r: &Renderer, ctx: &EvalCtx<'_>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool) -> Option> { let buf = r.blend_layer_buf(ctx, layer)?; prepare_with(r, ctx, layer, order, out, in_run, PlaneInput { buf, local: Mat4::IDENTITY }) } /// Prepare a layer as planes: one per character for per-character 3D text, else one. -pub(crate) fn prepare_all<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool) -> Vec> { +pub(crate) fn prepare_all<'a>(r: &Renderer, ctx: &EvalCtx<'_>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool) -> Vec> { if crate::text::per_char_3d(ctx, layer) { return crate::text::per_char_planes(ctx, layer, r.opts.scale) .into_iter() @@ -218,7 +218,7 @@ struct PlaneInput { } /// Prepare the plane's geometry, motion samples and material. -fn prepare_with<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool, plane: PlaneInput) -> Option> { +fn prepare_with<'a>(r: &Renderer, ctx: &EvalCtx<'_>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool, plane: PlaneInput) -> Option> { let PlaneInput { mut buf, local } = plane; if buf.img.is_empty() { return None; @@ -287,12 +287,45 @@ fn prepare_with<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer, order: us } /// Blur radius (buffer pixels) of every buffer pixel of a plane at `world` (layer → world), from -/// its camera depth: the circle of confusion over the on-screen size of one layer pixel there. -fn dof_radii(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: &Buf) -> Vec { - dof_radii_at(cam, dof, world, (buf.img.width as usize, buf.img.height as usize), buf.offset, buf.scale) +/// its camera depth: the circle of confusion over the on-screen size of one layer pixel there, at +/// most `cap` ([`dof_radius_cap`]). +fn dof_radii(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: &Buf, cap: f64) -> Vec { + dof_radii_at(cam, dof, world, (buf.img.width as usize, buf.img.height as usize), buf.offset, buf.scale, cap) } -fn dof_radii_at(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, (w, h): (usize, usize), offset: [f64; 2], scale: f64) -> Vec { +/// Pixels a padded blur buffer may have: the ~16 million layer buffers are held to +/// ([`crate::Renderer::raster_scale`]), or twice the buffer when that is larger, so a big buffer +/// can still be blurred. The blur holds a few images of this size at once whatever its level +/// count ([`super::bokeh::progressive_blur`]), so blurring a small plane costs no more memory than +/// blurring a large layer. +const DOF_PAD_BUDGET: f64 = 16.0e6; + +/// Comp pixels per buffer pixel below which a plane isn't blurred. Such a plane covers almost no +/// screen area (a 0%-scaled layer, one at an extreme distance), while its radius in buffer +/// pixels grows without bound as it shrinks: blurring it would cost the most for the least. +const DOF_MIN_SCREEN_SCALE: f64 = 1e-3; + +/// The largest blur radius (buffer pixels) of a `w`×`h` buffer: the one whose padded copy just +/// fits [`DOF_PAD_BUDGET`]. The circle of confusion has no bound of its own (a focus distance far +/// beyond the scene, a near-zero layer scale), so memory sets the limit; ordinary bokeh stays far +/// below it and is never clamped. +pub(super) fn dof_radius_cap(dof: &super::camera::Dof, w: u32, h: u32) -> f64 { + let (w, h) = (w as f64, h as f64); + let budget = DOF_PAD_BUDGET.max(2.0 * w * h); + // (w + 2p)·(h + 2p) = budget, solved for the padding p. + let pad = (((w - h) * (w - h) + 4.0 * budget).sqrt() - (w + h)) / 4.0; + ((pad - 2.0) / iris_stretch(dof)).max(0.0) +} + +pub(super) fn dof_radii_at( + cam: &CameraState, + dof: &super::camera::Dof, + world: Mat4, + (w, h): (usize, usize), + offset: [f64; 2], + scale: f64, + cap: f64, +) -> Vec { let s = scale.max(1e-9); let sx = world.apply_vec(vec3(1.0, 0.0, 0.0)).length().max(1e-9); let at = |x: f64, y: f64| cam.depth(world.apply(vec3((x - offset[0]) / s, (y - offset[1]) / s, 0.0))); @@ -306,31 +339,48 @@ fn dof_radii_at(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, (w, h) if z <= super::camera::NEAR { continue; } - // coc · ½ / (zoom / z · sx) · scale - *o = (dof.coc(z) * 0.5 * z / (cam.zoom * sx) * s) as f32; + // coc · ½ / (zoom / z · sx) · scale. Infinity clamps to the cap like any large radius, + // so the blur keeps growing with defocus; only NaN, which has no defocus, stays sharp. + let r = dof.coc(z) * 0.5 * z / (cam.zoom * sx) * s; + *o = if r.is_nan() || cam.zoom * sx / (z * s) < DOF_MIN_SCREEN_SCALE { 0.0 } else { r.min(cap) as f32 }; } }); out } +/// How much the iris' aspect ratio stretches a blur radius along its longer axis. +fn iris_stretch(dof: &super::camera::Dof) -> f64 { + let a = dof.iris.aspect.clamp(0.01, 100.0).sqrt(); + a.max(1.0 / a) +} + /// The padding [`dof_blur`] gives a buffer whose largest blur radius is `rmax`. fn dof_pad(dof: &super::camera::Dof, rmax: f64) -> u32 { - let a = dof.iris.aspect.clamp(0.01, 100.0).sqrt().max(1.0 / dof.iris.aspect.clamp(0.01, 100.0).sqrt()); - (rmax * a).ceil() as u32 + 2 + ((rmax * iris_stretch(dof)).ceil() as u32).saturating_add(2) +} + +/// The size of `img` padded by `pad` on every side, or `None` past [`limits::MAX_SIZE`] on a side: +/// [`DOF_PAD_BUDGET`] bounds the area, so a long thin buffer could still outgrow a side. +fn padded_size(img: &Image, pad: u32) -> Option<(u32, u32)> { + let grow = |n: u32| n.saturating_add(pad.saturating_mul(2)); + let (w, h) = (grow(img.width), grow(img.height)); + (w <= limits::MAX_SIZE && h <= limits::MAX_SIZE).then_some((w, h)) } /// [`dof_blur`] planned for an accelerator: the padding, radius map and levels, and the padded /// buffer's layer-space bounds. `None` when nothing blurs. fn dof_plan(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: &Buf) -> Option<(PlaneDof, [f64; 4])> { - let radii = dof_radii(cam, dof, world, buf); + let cap = dof_radius_cap(dof, buf.img.width, buf.img.height); + let radii = dof_radii(cam, dof, world, buf, cap); let rmax = radii.iter().copied().fold(0.0f32, f32::max) as f64; if rmax <= 0.3 { return None; } let pad = dof_pad(dof, rmax); - let (w, h) = (buf.img.width as usize + 2 * pad as usize, buf.img.height as usize + 2 * pad as usize); + let (wp, hp) = padded_size(&buf.img, pad)?; + let (w, h) = (wp as usize, hp as usize); let offset = [buf.offset[0] + pad as f64, buf.offset[1] + pad as f64]; - let radius = dof_radii_at(cam, dof, world, (w, h), offset, buf.scale); + let radius = dof_radii_at(cam, dof, world, (w, h), offset, buf.scale, cap); let levels = super::bokeh::blur_levels(&radius)?; let s = buf.scale.max(1e-9); let bounds = [-offset[0] / s, -offset[1] / s, (w as f64 - offset[0]) / s, (h as f64 - offset[1]) / s]; @@ -339,16 +389,20 @@ fn dof_plan(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: &Buf) /// Depth of field for one plane: highlights boosted, then a bokeh-shaped blur by depth. fn dof_blur(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: Arc) -> Arc { - let radii = dof_radii(cam, dof, world, &buf); + let cap = dof_radius_cap(dof, buf.img.width, buf.img.height); + let radii = dof_radii(cam, dof, world, &buf, cap); let rmax = radii.iter().copied().fold(0.0f32, f32::max) as f64; if rmax <= 0.3 { return buf; } let pad = dof_pad(dof, rmax); + if padded_size(&buf.img, pad).is_none() { + return buf; + } let mut padded = buf.img.padded(pad); super::bokeh::boost_highlights(&mut padded, &dof.highlight); let pbuf = Buf { img: padded, offset: [buf.offset[0] + pad as f64, buf.offset[1] + pad as f64], scale: buf.scale }; - let pr = dof_radii(cam, dof, world, &pbuf); + let pr = dof_radii(cam, dof, world, &pbuf, cap); // The (cached, shared) layer buffer stays untouched; the blurred copy is per frame. Arc::new(Buf { img: super::bokeh::progressive_blur(&pbuf.img, &dof.iris, &pr), offset: pbuf.offset, scale: pbuf.scale }) } @@ -449,8 +503,8 @@ fn gather<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, run: &[&'a Layer], out: (u32, .flat_map_iter(|(i, l)| { // Stack order with room for a collapsed precomp's layers in between. let i = i * 1024; - if let Some(item) = r.collapsed(ctx, l) { - return collapsed_items(r, ctx, l, item, i, out); + if let Some(collapsed) = r.collapsed(ctx, l) { + return collapsed_items(r, ctx, l, &collapsed, i, out); } let mut its = prepare_all(r, ctx, l, i, out, true); if !its.is_empty() { @@ -469,6 +523,7 @@ fn gather<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, run: &[&'a Layer], out: (u32, extra = ctx .comp .layers + .as_arcs() .par_iter() .filter(|l| l.switches.three_d && l.has_video() && l.is_active_at(ctx.time) && !in_run.contains(&l.id) && !l.switches.adjustment) .filter(|l| Material::of(ctx, l).casts_shadows != 0) @@ -483,20 +538,13 @@ fn gather<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, run: &[&'a Layer], out: (u32, /// 3D space (world = precomp layer's world × nested world), depth-sorted with the run, so they /// intersect the parent's 3D layers; nested 2D layers lie on the precomp layer's plane in stack /// order. (Nested adjustment layers are skipped here.) -fn collapsed_items<'a>( - r: &Renderer<'a>, - ctx: &EvalCtx<'a>, - layer: &'a Layer, - item: effectcraft_project::ItemId, - order: usize, - out: (u32, u32), -) -> Vec> { +fn collapsed_items<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &'a Layer, collapsed: &crate::Collapsed, order: usize, out: (u32, u32)) -> Vec> { let op = ctx.opacity(layer) as f32 * r.opacity_mul(); - let Some((sub, nctx)) = r.collapse_into(ctx, layer, item, op) else { return vec![] }; + let Some((sub, nctx)) = r.collapse_into(ctx, layer, collapsed, op) else { return vec![] }; let t = nctx.time; - let any_solo = nctx.comp.any_solo(); - let nested: Vec<&'a Layer> = nctx - .comp + let Some(nc) = r.project.comp(collapsed.item) else { return vec![] }; + let any_solo = nc.any_solo(); + let nested: Vec<&'a Layer> = nc .layers .iter() .rev() @@ -863,7 +911,7 @@ pub(crate) fn aux_pass(r: &Renderer, ctx: &EvalCtx) -> effectcraft_raster::AuxCh if nv[2] > 0.0 { nv = nv.map(|v| -v); } - let (sw, sh) = crate::source_size(r.project, it.layer); + let (sw, sh) = ctx.source_size(it.layer); let size = if sw == 0 { [ctx.comp.width as f64, ctx.comp.height as f64] } else { [sw as f64, sh as f64] }; (number(it.layer), source_id(it.layer), crypto_hash(&it.layer.name), crypto_hash(&source_name(it.layer)), nv, size) }) diff --git a/crates/render/src/three_d/tests.rs b/crates/render/src/three_d/tests.rs index 5e20b4635..026929623 100644 --- a/crates/render/src/three_d/tests.rs +++ b/crates/render/src/three_d/tests.rs @@ -13,10 +13,15 @@ const W: u32 = 200; const H: u32 = 100; fn setup() -> (Project, ItemId, Comp) { + setup_sized(W, H) +} + +/// [`setup`] with a `w`×`h` comp. +fn setup_sized(w: u32, h: u32) -> (Project, ItemId, Comp) { let mut p = Project::default(); // Exact float maths; 8/16 bpc quantisation has its own tests (tests_color). p.settings.bit_depth = effectcraft_project::BitDepth::Bpc32; - let comp = Comp::new(W, H, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let comp = Comp::new(w, h, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); (p, cid, comp) } @@ -374,7 +379,7 @@ fn shadow_diffusion_is_a_soft_ramp_not_copies() { // ---------------------------------------------------------------- cameras fn camera(p: &mut Project, comp: &Comp) -> Layer { - build::layer(p, comp, "Camera", LayerSource::Camera, (W, H), None) + build::layer(p, comp, "Camera", LayerSource::Camera, (comp.width, comp.height), None) } #[test] @@ -450,9 +455,155 @@ fn depth_of_field_blurs_out_of_focus_layers() { assert!(b.get(66, 50)[3] > 0.01); } +/// A non-finite Focus Distance doesn't reach the blur: the camera focuses on the property's +/// default plane (`zoom`) instead. +#[test] +fn dof_falls_back_when_focus_distance_is_not_finite() { + for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + let (mut p, cid, comp) = setup(); + let mut cam = camera(&mut p, &comp); + set(&mut cam, "cameraOptions/dof", Value::Bool(true)); + set(&mut cam, "cameraOptions/aperture", Value::Scalar(60.0)); + set(&mut cam, "cameraOptions/focusDistance", Value::Scalar(bad)); + let s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 60, 60, [100.0, 50.0, 0.0]); + push(&mut p, cid, cam); + push(&mut p, cid, s); + let cp = p.comp(cid).unwrap(); + let ctx = crate::EvalCtx::new(&p, cid, cp, Tick::ZERO); + let cs = crate::three_d::camera::layer_camera(&ctx, &cp.layers[0]); + let f = cs.dof.unwrap().focus; + assert!((f - zoom()).abs() < 1e-9, "{bad}: evaluated to {f}, expected the fallback to zoom {}", zoom()); + // The z = 0 plane is in focus: hard edge, finite pixels. + let img = render(&p, cid); + assert!(img.get(71, 50)[3] > 0.99 && img.get(69, 50)[3] < 0.01, "{bad}: the edge should be hard"); + assert!(img.data.iter().all(|px| px.iter().all(|c| c.is_finite())), "{bad}: non-finite pixels"); + } +} + +/// The Classic 3D blur stays bounded for degenerate inputs: a 0%-scaled 3D layer off the focal +/// plane (e.g. the first frame of a scale-in) or a huge but finite Focus Distance asks for a +/// radius of billions of buffer pixels, whose padded buffer can't be allocated. +#[test] +fn dof_blur_radius_is_bounded_for_degenerate_inputs() { + let scene = |focus: f64, scale: f64| { + let (mut p, cid, comp) = setup(); + let mut cam = camera(&mut p, &comp); + set(&mut cam, "cameraOptions/dof", Value::Bool(true)); + set(&mut cam, "cameraOptions/aperture", Value::Scalar(60.0)); + set(&mut cam, "cameraOptions/focusDistance", Value::Scalar(focus)); + let mut s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 60, 60, [100.0, 50.0, 0.0]); + set(&mut s, "transform/scale", Value::Vec3([scale, scale, 100.0])); + push(&mut p, cid, cam); + push(&mut p, cid, s); + render(&p, cid) + }; + let finite = |img: &effectcraft_raster::Image| img.data.iter().all(|px| px.iter().all(|c| c.is_finite())); + // A 0%-scaled layer 10 px off the focal plane: an ordinary defocus times 1/sx (sx = 1e-9). + assert!(finite(&scene(zoom() + 10.0, 0.0)), "zero-scaled: non-finite pixels"); + // Huge but finite Focus Distances (typed in, or a large expression result): the blur is as + // large as the buffer budget allows, never refused (which would render the layer sharp). + // 1e307 overflows the circle of confusion itself to infinity: still the largest blur. + for focus in [1.0e12, 1.0e307] { + let img = scene(focus, 100.0); + assert!(finite(&img), "focus {focus:e}: non-finite pixels"); + assert!(img.get(100, 50)[3] < 0.5, "focus {focus:e}: the layer should be spread out, not sharp ({})", img.get(100, 50)[3]); + } +} + +/// The blur radius map of a plane: a 0%-scaled plane is degenerate on screen and isn't blurred; +/// any other radius stays within the buffer budget's cap. +#[test] +fn dof_radii_skip_degenerate_planes_and_respect_the_cap() { + let radii = |focus: f64, scale: f64| { + let (mut p, cid, comp) = setup(); + let mut cam = camera(&mut p, &comp); + set(&mut cam, "cameraOptions/dof", Value::Bool(true)); + set(&mut cam, "cameraOptions/aperture", Value::Scalar(60.0)); + set(&mut cam, "cameraOptions/focusDistance", Value::Scalar(focus)); + let mut s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 60, 60, [100.0, 50.0, 0.0]); + set(&mut s, "transform/scale", Value::Vec3([scale, scale, 100.0])); + push(&mut p, cid, cam); + push(&mut p, cid, s); + let cp = p.comp(cid).unwrap(); + let ctx = crate::EvalCtx::new(&p, cid, cp, Tick::ZERO); + let cs = super::camera::layer_camera(&ctx, &cp.layers[0]); + let dof = cs.dof.unwrap(); + let cap = super::compose::dof_radius_cap(&dof, 60, 60); + (super::compose::dof_radii_at(&cs, &dof, ctx.world_matrix(&cp.layers[1]), (60, 60), [0.0; 2], 1.0, cap), cap) + }; + let (r, _) = radii(zoom() + 10.0, 0.0); + assert!(r.iter().all(|&r| r == 0.0), "a 0%-scaled plane is not blurred"); + let (r, cap) = radii(1.0e12, 100.0); + assert!(cap > 1000.0, "a 60×60 buffer may blur by well over 1000 px: {cap}"); + assert!(r.iter().all(|&r| r as f64 <= cap && r as f64 > cap - 1.0), "huge focus: every radius at the cap {cap}"); +} + +/// Two layers that look the same on screen (400×100 at 100%, 1600×400 at 25%) at the same depth +/// blur by the same amount on screen: the radius bound must not depend on the layer's scale. +#[test] +fn dof_blur_is_independent_of_layer_scale() { + let ramp = |w: u32, h: u32, scale: f64| { + let (mut p, cid, comp) = setup_sized(800, 400); + let zoom = 800.0 * 50.0 / 36.0; + let mut cam = camera(&mut p, &comp); + set(&mut cam, "cameraOptions/dof", Value::Bool(true)); + set(&mut cam, "cameraOptions/aperture", Value::Scalar(200.0)); + // coc = aperture·|z − f| / z: a 40 px comp radius on the z = 0 plane (depth = zoom). + set(&mut cam, "cameraOptions/focusDistance", Value::Scalar(zoom + 2.0 * 40.0 * zoom / 200.0)); + let mut s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], w, h, [400.0, 200.0, 0.0]); + set(&mut s, "transform/scale", Value::Vec3([scale, scale, 100.0])); + push(&mut p, cid, cam); + push(&mut p, cid, s); + let img = render(&p, cid); + soft_count(&img, (0..400).map(|x| (x, 200))) + }; + let (full, quarter) = (ramp(400, 100, 100.0), ramp(1600, 400, 25.0)); + assert!(full > 60, "the out-of-focus edge should be soft: {full}"); + assert!(full.abs_diff(quarter) <= 2, "same on-screen layer, same depth: {full} px at 100% vs {quarter} px at 25%"); +} + +/// The same frame at Full and Half resolution shows the same blur (in comp pixels). The radius +/// (~100 px) is large so that any bound that depends on the resolution would show. +#[test] +fn dof_blur_matches_across_render_resolutions() { + let (mut p, cid, comp) = setup_sized(800, 400); + let zoom = 800.0 * 50.0 / 36.0; + let mut cam = camera(&mut p, &comp); + set(&mut cam, "cameraOptions/dof", Value::Bool(true)); + set(&mut cam, "cameraOptions/aperture", Value::Scalar(200.0)); + set(&mut cam, "cameraOptions/focusDistance", Value::Scalar(2.0 * zoom)); + let s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 400, 100, [400.0, 200.0, 0.0]); + push(&mut p, cid, cam); + push(&mut p, cid, s); + let ramp = |scale: f64| { + let img = render_frame(&p, cid, Tick::ZERO, scale); + let (w, h) = (img.width as i64, img.height as i64); + soft_count(&img, (0..w / 2).map(|x| (x, h / 2))) as f64 / scale + }; + let (full, half) = (ramp(1.0), ramp(0.5)); + assert!(full > 150.0, "a ~100 px radius must not be clamped: {full} comp px"); + assert!((full - half).abs() <= 4.0, "Full {full} vs Half {half} comp px"); +} + +/// `Dof::coc` treats a non-finite focus or depth as in focus. +#[test] +fn dof_coc_is_zero_for_non_finite_inputs() { + let d = |focus: f64| super::camera::Dof { focus, aperture: 50.0, blur_level: 1.0, iris: Default::default(), highlight: Default::default() }; + for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + assert_eq!(d(bad).coc(500.0), 0.0, "focus {bad}"); + assert_eq!(d(400.0).coc(bad), 0.0, "depth {bad}"); + } + assert!(d(400.0).coc(500.0) > 0.0); +} + +/// Pixels among `pts` whose alpha is strictly between 0.05 and 0.95. +fn soft_count(img: &effectcraft_raster::Image, pts: impl Iterator) -> usize { + pts.filter(|&(x, y)| (0.05..0.95).contains(&img.get(x, y)[3])).count() +} + /// Pixels in column `x` whose alpha is strictly between 0.05 and 0.95. fn soft_px(img: &effectcraft_raster::Image, x: i64) -> usize { - (0..img.height as i64).filter(|&y| (0.05..0.95).contains(&img.get(x, y)[3])).count() + soft_count(img, (0..img.height as i64).map(|y| (x, y))) } #[test] diff --git a/crates/script/src/matchnames.rs b/crates/script/src/matchnames.rs index b64aaab37..5837f77b8 100644 --- a/crates/script/src/matchnames.rs +++ b/crates/script/src/matchnames.rs @@ -90,6 +90,44 @@ pub fn stroke_dash(name: &str) -> Option<&'static str> { const TEXT_ANIMATOR: &[(&str, &str)] = &[("selectors", "ADBE Text Selectors"), ("properties", "ADBE Text Animator Properties")]; +/// Animator property ids and their stable After Effects scripting names. Shared by lookup, +/// `.matchName`, and `addProperty` so the same property has one name in every direction. +pub const TEXT_ANIMATOR_PROPERTIES: &[(&str, &str)] = &[ + ("anchor", "ADBE Text Anchor Point 3D"), + ("position", "ADBE Text Position 3D"), + ("scale", "ADBE Text Scale 3D"), + ("skew", "ADBE Text Skew"), + ("rotation", "ADBE Text Rotation"), + ("opacity", "ADBE Text Opacity"), + ("fillColor", "ADBE Text Fill Color"), + ("fillHue", "ADBE Text Fill Hue"), + ("fillSaturation", "ADBE Text Fill Saturation"), + ("fillBrightness", "ADBE Text Fill Brightness"), + ("fillOpacity", "ADBE Text Fill Opacity"), + ("strokeColor", "ADBE Text Stroke Color"), + ("strokeHue", "ADBE Text Stroke Hue"), + ("strokeSaturation", "ADBE Text Stroke Saturation"), + ("strokeBrightness", "ADBE Text Stroke Brightness"), + ("strokeOpacity", "ADBE Text Stroke Opacity"), + ("strokeWidth", "ADBE Text Stroke Width"), + ("tracking", "ADBE Text Tracking Amount"), + ("lineAnchor", "ADBE Text Line Anchor"), + ("lineSpacing", "ADBE Text Line Spacing"), + ("characterOffset", "ADBE Text Character Offset"), + ("characterValue", "ADBE Text Character Replace"), + ("blur", "ADBE Text Blur"), +]; + +/// Companion properties that exist below an animator but are not kinds `addProperty` accepts: +/// they come with Tracking (Track Type), Skew (Skew Axis) and per-character 3D Rotation +/// (X and Y Rotation). Lookup and `.matchName` know them; `addProperty` does not. +pub const TEXT_ANIMATOR_COMPANIONS: &[(&str, &str)] = &[ + ("trackingType", "ADBE Text Track Type"), + ("skewAxis", "ADBE Text Skew Axis"), + ("rotationX", "ADBE Text Rotation X"), + ("rotationY", "ADBE Text Rotation Y"), +]; + const TEXT_SELECTORS: &[(&str, &str)] = &[("rangeSelector", "ADBE Text Selector"), ("wigglySelector", "ADBE Text Wiggly Selector"), ("expressionSelector", "ADBE Text Expressible Selector")]; @@ -147,6 +185,7 @@ pub const SHAPE_ITEMS: &[(&str, &str)] = &[ ("trim", "ADBE Vector Filter - Trim"), ("twist", "ADBE Vector Filter - Twist"), ("wiggle", "ADBE Vector Filter - Roughen"), + ("wiggler", "ADBE Vector Filter - Wiggler"), ("zigzag", "ADBE Vector Filter - Zigzag"), ]; @@ -208,6 +247,12 @@ const SHAPE_PROPS: &[(&str, &str, &str)] = &[ ("pucker", "amount", "ADBE Vector PuckerBloat Amount"), ("twist", "angle", "ADBE Vector Twist Angle"), ("twist", "center", "ADBE Vector Twist Center"), + ("wiggler", "speed", "ADBE Vector Xform Temporal Freq"), + ("wiggler", "correlation", "ADBE Vector Correlation"), + ("wiggler", "phase", "ADBE Vector Temporal Phase"), + ("wiggler", "spatialPhase", "ADBE Vector Spatial Phase"), + ("wiggler", "seed", "ADBE Vector Random Seed"), + ("wiggler", "transform", "ADBE Vector Wiggler Transform"), ("merge", "mode", "ADBE Vector Merge Type"), ("wiggle", "size", "ADBE Vector Roughen Size"), ("wiggle", "detail", "ADBE Vector Roughen Detail"), @@ -401,6 +446,7 @@ pub fn shape_kind(name: &str) -> Option<&'static str> { "twist" => "twist", "zigzag" => "zigzag", "wigglepaths" => "wiggle", + "wiggletransform" => "wiggler", "mergepaths" => "merge", _ => return None, }) @@ -487,6 +533,9 @@ pub fn node(chain: &[&Node]) -> String { "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 pid == "properties" && chain.len() == 5 && chain.get(1).is_some_and(|n| n.match_id() == "animators") => { + find(TEXT_ANIMATOR_PROPERTIES, id).or_else(|| find(TEXT_ANIMATOR_COMPANIONS, 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), @@ -537,6 +586,30 @@ mod tests { assert_eq!(shape_kind("nope"), None); } + #[test] + fn text_animator_property_names_are_canonical_and_scoped() { + let n = |m: &str| Node::from(effectcraft_project::PropGroup::new(1, m, m)); + let (text, animators, animator, properties) = (n("text"), n("animators"), n("animator"), n("properties")); + for (id, name) in TEXT_ANIMATOR_PROPERTIES { + let prop = n(id); + assert_eq!(node(&[&text, &animators, &animator, &properties, &prop]), *name); + assert_eq!(node(&[&properties, &prop]), *id, "an unrelated Properties group keeps its own ids"); + } + } + + /// #1063: the companions an animator property brings (Track Type, Skew Axis, X / Y Rotation) + /// have their After Effects names too, though `addProperty` does not accept them. + #[test] + fn text_animator_companions_have_after_effects_names() { + let n = |m: &str| Node::from(effectcraft_project::PropGroup::new(1, m, m)); + let (text, animators, animator, properties) = (n("text"), n("animators"), n("animator"), n("properties")); + for (id, name) in TEXT_ANIMATOR_COMPANIONS { + let prop = n(id); + assert_eq!(node(&[&text, &animators, &animator, &properties, &prop]), *name); + assert!(!TEXT_ANIMATOR_PROPERTIES.iter().any(|(k, m)| k == id || m == name), "{id} is not an addProperty kind"); + } + } + #[test] fn text_range_selector_names() { let n = |m: &str| Node::from(effectcraft_project::PropGroup::new(1, m, m)); diff --git a/crates/script/src/model.rs b/crates/script/src/model.rs index 23356c064..258a6de2d 100644 --- a/crates/script/src/model.rs +++ b/crates/script/src/model.rs @@ -743,36 +743,12 @@ fn resolve_add(s: &Session, a: &J) -> R { /// A text animator property (`layer.addTextAnimatorProperty` key) from an After Effects match /// name or a display name. fn text_animator_property(name: &str) -> Option<&'static str> { - const AE: &[(&str, &str)] = &[ - ("ADBE Text Anchor Point 3D", "anchor"), - ("ADBE Text Position 3D", "position"), - ("ADBE Text Scale 3D", "scale"), - ("ADBE Text Skew", "skew"), - ("ADBE Text Rotation", "rotation"), - ("ADBE Text Opacity", "opacity"), - ("ADBE Text Fill Color", "fillColor"), - ("ADBE Text Fill Hue", "fillHue"), - ("ADBE Text Fill Saturation", "fillSaturation"), - ("ADBE Text Fill Brightness", "fillBrightness"), - ("ADBE Text Fill Opacity", "fillOpacity"), - ("ADBE Text Stroke Color", "strokeColor"), - ("ADBE Text Stroke Hue", "strokeHue"), - ("ADBE Text Stroke Saturation", "strokeSaturation"), - ("ADBE Text Stroke Brightness", "strokeBrightness"), - ("ADBE Text Stroke Opacity", "strokeOpacity"), - ("ADBE Text Stroke Width", "strokeWidth"), - ("ADBE Text Tracking Amount", "tracking"), - ("ADBE Text Line Anchor", "lineAnchor"), - ("ADBE Text Line Spacing", "lineSpacing"), - ("ADBE Text Character Offset", "characterOffset"), - ("ADBE Text Character Replace", "characterValue"), - ("ADBE Text Blur", "blur"), - ]; - if let Some((_, k)) = AE.iter().find(|(m, _)| m.eq_ignore_ascii_case(name)) { + let table = matchnames::TEXT_ANIMATOR_PROPERTIES; + if let Some((k, _)) = table.iter().find(|(_, m)| m.eq_ignore_ascii_case(name)) { return Some(k); } let n = loose(name); - AE.iter().map(|(_, k)| *k).find(|k| loose(k) == n || (n == "anchorpoint" && *k == "anchor") || (n == "trackingamount" && *k == "tracking")) + table.iter().map(|(k, _)| *k).find(|k| loose(k) == n || (n == "anchorpoint" && *k == "anchor") || (n == "trackingamount" && *k == "tracking")) } /// `app.findMenuCommandId(name)`: 1-based index of the command whose label matches (menu items diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index f012ce128..b9108b2a1 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -734,9 +734,8 @@ PropertyGroup.prototype.addProperty = function (name) { } else if (r.kind === "animator") { uid = __call("layer.addTextAnimator", { comp: L.__comp, layer: L.__id, properties: [] }).animator; } else if (r.kind === "animatorProperty") { - var before = this.numProperties; - __call("layer.addTextAnimatorProperty", { comp: L.__comp, layer: L.__id, animator: r.animator, property: r.property }); - return this.property(before + 1); + var added = __call("layer.addTextAnimatorProperty", { comp: L.__comp, layer: L.__id, animator: r.animator, property: r.property }); + return this.property(added.index); } else if (r.kind === "selector") { var n0 = this.numProperties; __call("layer.addTextSelector", { comp: L.__comp, layer: L.__id, animator: r.animator, kind: r.selector }); @@ -1707,7 +1706,8 @@ Object.defineProperty(FootageItem.prototype, "mainSource", { return this.__mainSource; }, }); -FootageItem.prototype.replace = function (f) { __call("file.replaceFootage", { item: this.__id, path: __str(f) }); }; +// replace() swaps in one file, even a numbered still (replaceWithSequence brings a run), as in After Effects. +FootageItem.prototype.replace = function (f) { __call("file.replaceFootage", { item: this.__id, path: __str(f), sequence: false }); }; FootageItem.prototype.replaceWithSequence = function (f, forceAlphabetical) { __call("file.replaceFootage", { item: this.__id, path: __str(f), sequence: true, alphabetical: !!forceAlphabetical }); }; diff --git a/crates/script/src/runtime.rs b/crates/script/src/runtime.rs index 608c83d0e..2c18039e8 100644 --- a/crates/script/src/runtime.rs +++ b/crates/script/src/runtime.rs @@ -126,7 +126,7 @@ pub(crate) fn js_str(s: &str) -> JsValue { fn native_exec(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let id = arg_string(args, 0, ctx)?; let mut params = arg_json(args, 1, ctx)?; - if id == "script.run" || id == "file.runScript" { + if effectcraft_engine::commands::scripts::RUNS_SCRIPTS.contains(&id.as_str()) { return Err(throw("scripts can't run scripts")); } let keys = params.as_object_mut().and_then(|o| o.remove("__keys")); diff --git a/crates/script/src/scriptui.js b/crates/script/src/scriptui.js index 4760d058b..178bd8be1 100644 --- a/crates/script/src/scriptui.js +++ b/crates/script/src/scriptui.js @@ -745,7 +745,8 @@ function __uiMouseWanted(c, inherited) { } // A ScriptUI MouseEvent. `v`: {x, y (relative to the target control), button (0 left, 1 middle, -// 2 right), detail (click count), screenX, screenY, altKey, ctrlKey, metaKey, shiftKey}. +// 2 right), buttons (held: 1 left, 2 right, 4 middle), detail (click count), screenX, screenY, +// altKey, ctrlKey, metaKey, shiftKey}. function __uiMouseEvent(type, target, v) { var num = function (x, d) { x = Number(x); @@ -765,6 +766,7 @@ function __uiMouseEvent(type, target, v) { timeStamp: new Date(), detail: num(v.detail, type === "mousemove" || type === "mouseover" || type === "mouseout" ? 0 : 1), button: num(v.button, 0), + buttons: num(v.buttons, 0), clientX: num(v.x, 0), clientY: num(v.y, 0), screenX: num(v.screenX, 0), diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index a2f674d0a..0af09841a 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -17,6 +17,7 @@ mod include; mod panels; #[cfg(not(windows))] mod services; +mod startup; mod tasks; mod ui; mod ui_more; @@ -371,6 +372,31 @@ fn match_names_and_lookups() { assert_eq!(sh.props.prop("contents/#1/contents/fill/color").unwrap().value, KV::Color([1.0, 0.0, 0.0, 1.0])); } +/// Scripts add a Wiggle Transform by its match name or display name and reach its properties. +#[test] +fn wiggle_transform_by_match_name() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var c = app.project.items.addComp("W", 200, 200, 1, 2, 30); + var sh = c.layers.addShape(); + var root = sh.property("ADBE Root Vectors Group"); + var w = root.addProperty("ADBE Vector Filter - Wiggler"); + var byName = root.addProperty("Wiggle Transform"); + w.property("ADBE Vector Wiggler Transform").property("Position").setValue([20, 10]); + [ + w.matchName, w.name, byName.matchName, w.property("ADBE Vector Xform Temporal Freq").value, + w.property("ADBE Vector Spatial Phase").name, w.property("ADBE Vector Wiggler Transform").name + ] + "#, + ); + assert_eq!(o.result, json!(["ADBE Vector Filter - Wiggler", "Wiggle Transform 1", "ADBE Vector Filter - Wiggler", 2, "Spatial Phase", "Transform"])); + let (_, c) = comp_named(&s, "W"); + let sh = c.layers.iter().find(|l| l.props.sub("contents").is_some()).unwrap(); + assert_eq!(sh.props.prop("contents/wiggler/transform/position").unwrap().value, KV::Vec2([20.0, 10.0])); +} + #[test] fn keyframes_eases_and_interpolation() { let mut s = session(); @@ -1062,6 +1088,11 @@ fn footage_proxies_sequences_and_conform_rate() { let paths: Vec = s.project.items.values().filter_map(|i| if let ItemKind::Footage(f) = &i.kind { Some(f.path.replace('\\', "/")) } else { None }).collect(); assert!(paths.iter().any(|f| f.contains("take_")), "replaced with the take sequence: {paths:?}"); + // replace() swaps in the one file, even a numbered still, as in After Effects. + ok(&mut s, &format!("app.project.item(1).replace(new File('{}'))", p("shot_0002.png"))); + let kinds: Vec<_> = + s.project.items.values().filter_map(|i| if let ItemKind::Footage(f) = &i.kind { Some((f.kind, f.path.replace('\\', "/"))) } else { None }).collect(); + assert!(kinds.iter().any(|(k, f)| *k == effectcraft_engine::project::FootageKind::Still && f.ends_with("shot_0002.png")), "{kinds:?}"); let _ = std::fs::remove_dir_all(&dir); } @@ -1719,6 +1750,50 @@ fn layer_property_resolves_transform_and_source_text_shortcuts() { assert_eq!(layer(c, "s").props.prop("transform/opacity").unwrap().value, KV::Scalar(25.0)); } +/// A text animator property added by its After Effects name is the one asked for, not its Tracking +/// Type companion, and it can be found again by that name (#1063). +#[test] +fn scripted_text_animator_property_is_the_one_asked_for() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var c = app.project.items.addComp("Track", 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"); + var props = a.property("ADBE Text Animator Properties"); + var added = props.addProperty("ADBE Text Tracking Amount"); + added.setValue(20); + [added.name, props.property("ADBE Text Tracking Amount").value, props.property("ADBE Text Tracking Amount").name, + added.matchName, props.property(added.matchName).propertyIndex] + "#, + ); + assert_eq!(o.result, json!(["Tracking Amount", 20, "Tracking Amount", "ADBE Text Tracking Amount", 2])); +} + +/// The companion properties added with Tracking and Skew are found by their After Effects names, +/// but cannot be added on their own (#1063). +#[test] +fn text_animator_companion_properties_resolve_by_name() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var c = app.project.items.addComp("Track", 960, 300, 1, 3, 30); + var t = c.layers.addText("MOVARA"); + var props = t.property("ADBE Text Properties").property("ADBE Text Animators").addProperty("ADBE Text Animator").property("ADBE Text Animator Properties"); + props.addProperty("ADBE Text Tracking Amount"); + props.addProperty("ADBE Text Skew"); + var type = props.property("ADBE Text Track Type"); + var axis = props.property("ADBE Text Skew Axis"); + var added = ""; + try { props.addProperty("ADBE Text Track Type"); added = "added"; } catch (e) { added = "refused"; } + [type ? type.matchName : null, axis ? axis.matchName : null, added] + "#, + ); + assert_eq!(o.result, json!(["ADBE Text Track Type", "ADBE Text Skew Axis", "refused"])); +} + /// #1936: an array whose length doesn't match the property's dimension is an error in After /// Effects, not a value to truncate or broadcast. Two values for a 3-D property stay legal. #[test] diff --git a/crates/script/src/tests/ease_presets.rs b/crates/script/src/tests/ease_presets.rs index 009cc59c6..f075250b2 100644 --- a/crates/script/src/tests/ease_presets.rs +++ b/crates/script/src/tests/ease_presets.rs @@ -608,3 +608,37 @@ fn presets_saved_by_the_old_core_panel_carry_over() { let win = open_panel(&mut s); assert_eq!(control(&mut s, win, "presets")["items"].as_array().unwrap().len(), 12); } + +/// #2465: the Linear preset sets Linear interpolation on every side it applies to, including the +/// unused first-in and last-out sides; editing its speeds makes a Bezier curve. +#[test] +fn the_linear_preset_sets_linear_interpolation_and_edited_speeds_give_bezier() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session(&store); + let l = setup(&mut s); + animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (3.0, json!(20))]); + s.execute("prop.select", json!({"layer": l, "path": "transform/opacity"})).unwrap(); + let win = open_panel(&mut s); + // Start from Bezier keys so Linear has something to restore. + ok( + &mut s, + r#"var p = app.project.activeItem.layer(1).transform.opacity, K = KeyframeInterpolationType; + for (var i = 1; i <= p.numKeys; i++) p.setInterpolationTypeAtKey(i, K.BEZIER, K.BEZIER);"#, + ); + let op = prop(&s, l, "transform/opacity"); + for (i, k) in op.keys.iter().enumerate() { + assert_eq!((k.in_interp, k.out_interp), (Interp::Bezier, Interp::Bezier), "initial key {i}"); + } + let steps = s.history.undo.len(); + set(&mut s, win, "presets", json!("Linear")); + assert_eq!(s.history.undo.len(), steps + 1, "one undo step"); + let op = prop(&s, l, "transform/opacity"); + for (i, k) in op.keys.iter().enumerate() { + assert_eq!((k.in_interp, k.out_interp), (Interp::Linear, Interp::Linear), "key {i}"); + } + // Edited speeds make a custom Bezier curve even though Linear is still the selected preset. + select_keys(&mut s, l, &[("transform/opacity", 1.0)]); + apply_typed(&mut s, win, "In-Out", [33.0, 2.0, 33.0, 2.0]); + let k = &prop(&s, l, "transform/opacity").keys[1]; + assert_eq!((k.in_interp, k.out_interp), (Interp::Bezier, Interp::Bezier)); +} diff --git a/crates/script/src/tests/startup.rs b/crates/script/src/tests/startup.rs new file mode 100644 index 000000000..67da833c5 --- /dev/null +++ b/crates/script/src/tests/startup.rs @@ -0,0 +1,290 @@ +//! Startup and Shutdown scripts, as in After Effects: the scripts in the user's `Scripts/Startup` +//! and `Scripts/Shutdown` folders (in the settings folder) run in alphabetical order at launch and +//! as the app quits, after the project is closed. + +use std::sync::Arc; + +use effectcraft_engine::Session; +use effectcraft_engine::commands::scripts::{OPEN_PANELS_FILE, PANELS_DIR, SCRIPTS_DIR, SHUTDOWN_DIR, STARTUP_DIR}; +use effectcraft_engine::config::{ConfigStore, MemoryConfig}; +use effectcraft_engine::scriptui::WindowKind; +use serde_json::json; + +fn session(store: &Arc) -> Session { + let mut s = Session::default(); + crate::install(&mut s); + s.config = Some(store.clone()); + s +} + +/// A script that appends `mark` to the `Startup Test` / `order` script setting. +fn mark(mark: &str) -> String { + format!( + r#"var k = app.settings.haveSetting("Startup Test", "order") ? app.settings.getSetting("Startup Test", "order") : ""; +app.settings.saveSetting("Startup Test", "order", k + "{mark}");"# + ) +} + +fn setting(s: &mut Session, key: &str) -> serde_json::Value { + s.execute("script.settings.get", json!({"section": "Startup Test", "key": key})).unwrap()["value"].clone() +} + +#[test] +fn startup_scripts_run_in_alphabetical_order_and_a_failing_one_does_not_stop_the_rest() { + let store = Arc::new(MemoryConfig::default()); + store.write(&format!("{STARTUP_DIR}/b.jsx"), &mark("b")).unwrap(); + store.write(&format!("{STARTUP_DIR}/A.jsx"), &mark("A")).unwrap(); + store.write(&format!("{STARTUP_DIR}/B2.jsx"), &mark("B")).unwrap(); + store.write(&format!("{STARTUP_DIR}/C.js"), "throw new Error('no');").unwrap(); + store.write(&format!("{STARTUP_DIR}/d.jsx"), &mark("d")).unwrap(); + // Not scripts, or not in the Startup folder: not run. + store.write(&format!("{STARTUP_DIR}/notes.txt"), &mark("x")).unwrap(); + store.write(&format!("{SCRIPTS_DIR}/Menu Script.jsx"), &mark("m")).unwrap(); + store.write(&format!("{SHUTDOWN_DIR}/Quit.jsx"), &mark("q")).unwrap(); + let mut s = session(&store); + let r = s.execute("file.runStartupScripts", json!({})).unwrap(); + assert_eq!(r["ran"], json!(["A.jsx", "b.jsx", "B2.jsx", "d.jsx"]), "{r}"); + let failed: Vec<&str> = r["failed"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap()).collect(); + assert_eq!(failed, ["C.js"]); + assert_eq!(setting(&mut s, "order"), "AbBd"); + // The failing script is reported in a toast. + let events = s.drain_events(); + assert!(events.iter().any(|e| matches!(e, effectcraft_engine::Event::Toast { message, error: true } if message.starts_with("C.js"))), "{events:?}"); + // The Startup folder's scripts are not offered in File ▸ Scripts. + let list = s.execute("file.scripts.list", json!({})).unwrap(); + assert!(list.as_array().unwrap().iter().all(|e| e["name"] != "b.jsx"), "{list}"); +} + +#[test] +fn shutdown_scripts_run_after_the_project_is_closed() { + let store = Arc::new(MemoryConfig::default()); + let probe = r#"app.settings.saveSetting("Startup Test", "items", String(app.project.numItems));"#; + store.write(&format!("{SHUTDOWN_DIR}/Save State.jsx"), probe).unwrap(); + let mut s = session(&store); + s.execute("script.run", json!({"code": r#"app.project.items.addComp("Main", 64, 64, 1, 1, 24);"#})).unwrap(); + assert!(!s.project.items.is_empty()); + let r = s.execute("file.runShutdownScripts", json!({})).unwrap(); + assert_eq!(r, json!({"ran": ["Save State.jsx"], "waiting": [], "failed": []})); + assert_eq!(setting(&mut s, "items"), "0"); + assert!(s.project.items.is_empty()); +} + +#[test] +fn a_startup_dialog_stays_open_and_its_script_goes_on_when_it_closes() { + let store = Arc::new(MemoryConfig::default()); + store.write(&format!("{STARTUP_DIR}/A Dialog.jsx"), &format!("new Window('dialog', 'Welcome').show();\n{}", mark("A"))).unwrap(); + store.write(&format!("{STARTUP_DIR}/B.jsx"), &mark("B")).unwrap(); + let mut s = session(&store); + let r = s.execute("file.runStartupScripts", json!({})).unwrap(); + assert_eq!(r, json!({"ran": ["B.jsx"], "waiting": ["A Dialog.jsx"], "failed": []})); + assert_eq!(setting(&mut s, "order"), "B"); + assert!(s.script_ui.windows.iter().any(|w| w.modal && w.title == "Welcome")); + s.execute("scriptui.close", json!({})).unwrap(); + assert_eq!(setting(&mut s, "order"), "BA"); +} + +#[test] +fn startup_scripts_tasks_go_on_after_a_project_opens() { + use web_time::{Duration, Instant}; + let store = Arc::new(MemoryConfig::default()); + store.write(&format!("{STARTUP_DIR}/A Poll.jsx"), "app.scheduleTask('writeLn(\"poll\")', 100, true);").unwrap(); + // A task scheduled after the script's dialog closes. + store.write(&format!("{STARTUP_DIR}/B Ask.jsx"), "new Window('dialog', 'Ask').show();\napp.scheduleTask('writeLn(\"ask\")', 100, true);").unwrap(); + let mut s = session(&store); + s.execute("file.runStartupScripts", json!({})).unwrap(); + s.execute("scriptui.close", json!({})).unwrap(); + // The launch opens a project after the Startup scripts ran. + s.execute("file.closeProject", json!({})).unwrap(); + let r = crate::tasks::run_tasks_at(&mut s, Instant::now() + Duration::from_millis(500)); + assert_eq!(r["ran"], json!(2), "{r}"); + // A script run from File ▸ Scripts still stops when the project closes. + s.execute("script.run", json!({"code": "app.scheduleTask('writeLn(\"menu\")', 100, true);", "name": "Menu.jsx"})).unwrap(); + s.execute("file.closeProject", json!({})).unwrap(); + let r = crate::tasks::run_tasks_at(&mut s, Instant::now() + Duration::from_millis(5000)); + assert_eq!(r["ran"], json!(2), "{r}"); +} + +#[test] +fn shutdown_dialogs_close_as_if_canceled() { + let store = Arc::new(MemoryConfig::default()); + let code = r#"var a = new Window("dialog", "One").show(); +var b = new Window("dialog", "Two").show(); +app.settings.saveSetting("Startup Test", "shown", a + "," + b);"#; + store.write(&format!("{SHUTDOWN_DIR}/Ask.jsx"), code).unwrap(); + // An error after the dialog closes is a failure. + store.write(&format!("{SHUTDOWN_DIR}/Broken.jsx"), "new Window('dialog', 'Three').show();\nthrow new Error('late');").unwrap(); + let mut s = session(&store); + let r = s.execute("file.runShutdownScripts", json!({})).unwrap(); + assert_eq!((&r["ran"], &r["waiting"]), (&json!(["Ask.jsx"]), &json!([])), "{r}"); + assert_eq!(r["failed"][0]["name"], "Broken.jsx", "{r}"); + assert!(r["failed"][0]["error"].as_str().is_some_and(|e| e.contains("late")), "{r}"); + assert_eq!(setting(&mut s, "shown"), "2,2"); + assert!(s.script_ui.windows.is_empty()); +} + +#[test] +fn shutdown_closes_only_its_own_scripts_dialogs() { + let store = Arc::new(MemoryConfig::default()); + store.write(&format!("{STARTUP_DIR}/Same.jsx"), &format!("new Window('dialog', 'Startup').show();\n{}", mark("S"))).unwrap(); + store.write(&format!("{SHUTDOWN_DIR}/Same.jsx"), &format!("new Window('dialog', 'Shutdown').show();\n{}", mark("Q"))).unwrap(); + let mut s = session(&store); + assert_eq!(s.execute("file.runStartupScripts", json!({})).unwrap()["waiting"], json!(["Same.jsx"])); + let r = s.execute("file.runShutdownScripts", json!({})).unwrap(); + assert_eq!(r, json!({"ran": ["Same.jsx"], "waiting": [], "failed": []})); + // The Startup script's dialog is still open, and its script has not gone on. + assert_eq!(setting(&mut s, "order"), "Q"); + let titles: Vec<&str> = s.script_ui.windows.iter().map(|w| w.title.as_str()).collect(); + assert_eq!(titles, ["Startup"]); +} + +#[test] +fn oversized_scripts_are_not_run() { + let store = Arc::new(MemoryConfig::default()); + let big = format!("{}\n{}", "//".repeat(8 << 20), mark("x")); + store.write(&format!("{STARTUP_DIR}/Big.jsx"), &big).unwrap(); + let mut s = session(&store); + let r = s.execute("file.runStartupScripts", json!({})).unwrap(); + assert_eq!(r["ran"], json!([])); + assert_eq!(r["failed"][0]["name"], "Big.jsx"); + let events = s.drain_events(); + assert!(events.iter().any(|e| matches!(e, effectcraft_engine::Event::Toast { message, error: true } if message.starts_with("Big.jsx"))), "{events:?}"); + assert_eq!(setting(&mut s, "order"), ""); +} + +#[test] +fn scripts_in_a_settings_folder_on_disk_include_files_beside_them() { + use effectcraft_engine::config::DirConfig; + let root = std::env::temp_dir().join(format!("ec-startup-include-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + for (file, code) in [ + (format!("{STARTUP_DIR}/lib/helper.jsx"), mark("i")), + (format!("{STARTUP_DIR}/Main.jsx"), format!("#include \"lib/helper.jsx\"\n{}", mark("m"))), + (format!("{SHUTDOWN_DIR}/Ask.jsx"), format!("new Window('dialog', 'Bye').show();\n{}", mark("q"))), + ] { + let path = root.join(file); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, code).unwrap(); + } + let mut s = Session::default(); + crate::install(&mut s); + s.config = Some(Arc::new(DirConfig::new(&root))); + let r = s.execute("file.runStartupScripts", json!({})).unwrap(); + assert_eq!(r, json!({"ran": ["Main.jsx"], "waiting": [], "failed": []})); + assert_eq!(setting(&mut s, "order"), "im"); + // A Shutdown script run as its file still has its dialog closed. + let r = s.execute("file.runShutdownScripts", json!({})).unwrap(); + assert_eq!(r, json!({"ran": ["Ask.jsx"], "waiting": [], "failed": []})); + assert_eq!(setting(&mut s, "order"), "imq"); + let _ = std::fs::remove_dir_all(&root); +} + +/// A session with one comp, for checking that a refused command leaves the project open. +fn session_with_comp(store: &Arc) -> Session { + let mut s = session(store); + s.execute("script.run", json!({"code": r#"app.project.items.addComp("Main", 64, 64, 1, 1, 24);"#})).unwrap(); + assert!(!s.project.items.is_empty()); + s +} + +const FOLDER_COMMANDS: [&str; 2] = ["file.runStartupScripts", "file.runShutdownScripts"]; + +#[test] +fn scripts_cannot_run_the_startup_or_shutdown_folders() { + let store = Arc::new(MemoryConfig::default()); + for id in FOLDER_COMMANDS { + let mut s = session_with_comp(&store); + let r = s.execute("script.run", json!({"code": format!("app.run('{id}', {{}})")})).unwrap(); + assert!(r["error"]["message"].as_str().is_some_and(|m| m.contains("scripts can't run scripts")), "{id}: {r}"); + assert!(!s.project.items.is_empty(), "{id}"); + } +} + +#[test] +fn command_scripts_cannot_run_the_startup_or_shutdown_folders() { + let store = Arc::new(MemoryConfig::default()); + for id in FOLDER_COMMANDS { + let mut s = session_with_comp(&store); + let e = s.execute("file.runScript", json!({"steps": [{"command": id}]})).unwrap_err().to_string(); + assert!(e.contains("scripts can't run scripts"), "{id}: {e}"); + assert!(!s.project.items.is_empty(), "{id}"); + } +} + +#[test] +fn batches_cannot_run_the_startup_or_shutdown_folders() { + let store = Arc::new(MemoryConfig::default()); + for id in FOLDER_COMMANDS { + let mut s = session_with_comp(&store); + let e = s.execute("engine.batch", json!({"steps": [{"command": id}]})).unwrap_err().to_string(); + assert!(e.contains("can't run inside a batch"), "{id}: {e}"); + assert!(!s.project.items.is_empty(), "{id}"); + } +} + +/// A ScriptUI panel whose script appends `p` to the `Startup Test` / `order` setting each time it +/// runs, remembered as open at the last quit. +fn session_with_panel(store: &Arc) -> Session { + store.write(&format!("{PANELS_DIR}/Mark Panel.jsx"), &format!("this.add('statictext', undefined, 'Mark');\n{}", mark("p"))).unwrap(); + store.write(OPEN_PANELS_FILE, &json!({"open": ["Mark Panel.jsx"]}).to_string()).unwrap(); + session(store) +} + +fn panel_open(s: &Session) -> bool { + s.script_ui.windows.iter().any(|w| w.kind == WindowKind::Panel && w.script == "Mark Panel.jsx") +} + +/// The commands that open ScriptUI panels, which run the panel's script. +fn panel_commands() -> [(&'static str, serde_json::Value); 2] { + [("window.scriptPanel", json!({"name": "Mark Panel.jsx"})), ("window.restoreScriptPanels", json!({}))] +} + +#[test] +fn scripts_cannot_open_script_panels() { + for (id, params) in panel_commands() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session_with_panel(&store); + // Opened by the user: a script's call would only bring it forward. + s.execute("window.scriptPanel", json!({"name": "Mark Panel.jsx"})).unwrap(); + let r = s.execute("script.run", json!({"code": format!("app.run('{id}', {params})")})).unwrap(); + assert!(r["error"]["message"].as_str().is_some_and(|m| m.contains("scripts can't run scripts")), "{id}: {r}"); + assert_eq!(setting(&mut s, "order"), "p", "{id}"); + assert_eq!(store.read(OPEN_PANELS_FILE).unwrap_or_default(), json!({"open": ["Mark Panel.jsx"]}).to_string(), "{id}"); + } +} + +#[test] +fn command_scripts_and_batches_cannot_open_script_panels() { + for (id, params) in panel_commands() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session_with_panel(&store); + let e = s.execute("file.runScript", json!({"steps": [{"command": id, "params": params}]})).unwrap_err().to_string(); + assert!(e.contains("scripts can't run scripts"), "{id}: {e}"); + let e = s.execute("engine.batch", json!({"steps": [{"command": id, "params": params}]})).unwrap_err().to_string(); + assert!(e.contains("can't run inside a batch"), "{id}: {e}"); + assert!(!panel_open(&s), "{id}"); + assert_eq!(setting(&mut s, "order"), "", "{id}"); + } +} + +#[test] +fn the_app_still_reopens_script_panels_at_launch() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session_with_panel(&store); + // As the desktop and web apps call it at launch. + let r = s.execute("window.restoreScriptPanels", json!({})).unwrap(); + assert_eq!(r, json!({"opened": ["Mark Panel.jsx"], "failed": []})); + assert!(panel_open(&s)); + assert_eq!(setting(&mut s, "order"), "p"); +} + +#[test] +fn empty_or_missing_folders_run_nothing() { + let none = json!({"ran": [], "waiting": [], "failed": []}); + let store = Arc::new(MemoryConfig::default()); + let mut s = session(&store); + assert_eq!(s.execute("file.runStartupScripts", json!({})).unwrap(), none); + // A session without a settings folder. + let mut s = Session::default(); + crate::install(&mut s); + assert_eq!(s.execute("file.runShutdownScripts", json!({})).unwrap(), none); +} diff --git a/crates/script/src/tests/ui_more.rs b/crates/script/src/tests/ui_more.rs index 228a58016..483d686ef 100644 --- a/crates/script/src/tests/ui_more.rs +++ b/crates/script/src/tests/ui_more.rs @@ -222,7 +222,16 @@ fn position_now(s: &Session, layer: u64) -> [f64; 2] { let cid = s.active_comp_id().unwrap(); let comp = s.project.comp(cid).unwrap(); let l = comp.layer(effectcraft_engine::project::LayerId(layer)).unwrap(); - let ctx = effectcraft_engine::render::EvalCtx { project: &s.project, comp_id: cid, comp, time: s.time(), expr: s.expr.as_deref(), footage: None }; + let ctx = effectcraft_engine::render::EvalCtx { + project: &s.project, + comp_id: cid, + comp, + time: s.time(), + expr: s.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let v = ctx.value(l, l.props.prop("transform/position").unwrap()).components(); [v[0], v[1]] } diff --git a/crates/script/src/ui.rs b/crates/script/src/ui.rs index 082a16cac..ab6f69846 100644 --- a/crates/script/src/ui.rs +++ b/crates/script/src/ui.rs @@ -404,8 +404,14 @@ fn dispatch_inline(session: &mut Session, host: u32, name: &str, ev: &ScriptUiEv // ---------------------------------------------------------------- scheduled tasks +/// Whether `host`'s tasks stop now that the project it ran with was closed: it has no window +/// open and is not a Startup script's. +fn ends_with_project(session: &Session, host: u32) -> bool { + !session.script_ui.windows.iter().any(|w| w.host == host) && !session.script_ui.startup_hosts.contains(&host) +} + /// Run the due tasks of the script-host threads; hosts without windows whose project was closed -/// end. +/// end, except Startup scripts' hosts. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn tick_threaded(session: &mut Session, now: Instant, report: &mut crate::tasks::Report) { let epoch = session.script_ui.project_epoch; @@ -413,8 +419,7 @@ pub(crate) fn tick_threaded(session: &mut Session, now: Instant, report: &mut cr if let Ok(mut h) = hosts().lock() { for host in session.script_ui.task_hosts.clone() { let Some(end) = h.get(&host) else { continue }; - let windowless = !session.script_ui.windows.iter().any(|w| w.host == host); - if windowless && end.epoch != epoch { + if end.epoch != epoch && ends_with_project(session, host) { // Dropping its end lets the host thread finish. stale.extend(h.remove(&host)); } else if end.next_due.is_some_and(|d| d <= now) { @@ -441,7 +446,7 @@ pub(crate) fn tick_threaded(session: &mut Session, now: Instant, report: &mut cr } /// Run the due tasks of the inline hosts on this thread (the Script Console, web runs); those -/// without windows whose project was closed stop. +/// without windows whose project was closed stop, except Startup scripts' hosts. pub(crate) fn tick_inline(session: &mut Session, now: Instant, report: &mut crate::tasks::Report) { for (host, name, epoch) in crate::tasks::hosts() { if !session.script_ui.task_hosts.contains(&host) { @@ -449,7 +454,7 @@ pub(crate) fn tick_inline(session: &mut Session, now: Instant, report: &mut crat } let windowless = !session.script_ui.windows.iter().any(|w| w.host == host); let alive = host == CONSOLE_HOST || INLINE.with(|m| m.borrow().contains_key(&host)); - if !alive || (windowless && epoch != session.script_ui.project_epoch) { + if !alive || (epoch != session.script_ui.project_epoch && ends_with_project(session, host)) { crate::tasks::drop_host(host); if host != CONSOLE_HOST && windowless { drop_inline(host); diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index a973335f4..5388629cf 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -385,7 +385,8 @@ pub fn scan_system() -> usize { } /// Scan the system font folders again, adding fonts installed since the last scan (faces already -/// known are kept, so font ids stay valid). Returns the number of faces added. +/// known are kept, so font ids stay valid). Returns the number of faces added. This walks every +/// fontconfig directory, so the UI calls [`start_rescan`] instead of running it on the frame thread. pub fn rescan_system() -> usize { let _scan = SCAN.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let n = if cfg!(target_arch = "wasm32") { 0 } else { add_source(&DirectorySource::system()) }; @@ -393,6 +394,60 @@ pub fn rescan_system() -> usize { n } +/// A rescan started by [`start_rescan`], if one is in flight or finished and not yet polled. +#[cfg(not(target_arch = "wasm32"))] +static RESCAN: std::sync::Mutex>> = std::sync::Mutex::new(None); + +/// Start [`rescan_system`] on a background thread. Returns false when a rescan is already +/// running, the thread could not be spawned, or this build has no system font folders (wasm). +pub fn start_rescan() -> bool { + #[cfg(target_arch = "wasm32")] + { + false + } + #[cfg(not(target_arch = "wasm32"))] + { + let mut slot = RESCAN.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if slot.as_ref().is_some_and(|h| !h.is_finished()) { + return false; + } + match std::thread::Builder::new().name("font-rescan".into()).spawn(rescan_system) { + Ok(handle) => { + *slot = Some(handle); + true + } + Err(_) => false, + } + } +} + +/// Whether a background rescan is still walking the font folders. +pub fn rescan_busy() -> bool { + #[cfg(target_arch = "wasm32")] + { + false + } + #[cfg(not(target_arch = "wasm32"))] + { + RESCAN.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref().is_some_and(|h| !h.is_finished()) + } +} + +/// Faces added by the latest background rescan, once that thread has finished. `None` while it +/// is still running or when nothing was started. +pub fn poll_rescan() -> Option { + #[cfg(target_arch = "wasm32")] + { + None + } + #[cfg(not(target_arch = "wasm32"))] + { + let mut slot = RESCAN.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let finished = slot.as_ref().is_some_and(std::thread::JoinHandle::is_finished); + if finished { slot.take().and_then(|h| h.join().ok()) } else { None } + } +} + /// Start the system font scan on a background thread, so the font menus are complete by the /// time someone opens them without the first open waiting for the scan. Native only; on wasm /// (no threads, no font folders) this does nothing. @@ -707,14 +762,28 @@ const JAPANESE_FAMILIES: &[&str] = &[ ]; /// Han ideograph families for Simplified Chinese, in the order the platforms' own fallback tables /// prefer them (Windows, macOS, then the Linux/Noto names). Missing ones cost one name lookup each. -const SIMPLIFIED_CHINESE_FAMILIES: &[&str] = - &["Microsoft YaHei", "SimSun", "PingFang SC", "Noto Sans CJK SC", "Noto Sans SC", "Source Han Sans SC", "WenQuanYi Micro Hei"]; +/// `Hiragino Sans GB`, `Heiti SC` and `Songti SC` are the Simplified faces a macOS install without +/// the on-demand PingFang has (macOS 27 on an English install ships them, not PingFang): without +/// them Chinese Han text fell through to the Japanese face and drew Japanese glyph forms (#453). +const SIMPLIFIED_CHINESE_FAMILIES: &[&str] = &[ + "Microsoft YaHei", + "SimSun", + "PingFang SC", + "Hiragino Sans GB", + "Heiti SC", + "Songti SC", + "STSong", + "Noto Sans CJK SC", + "Noto Sans SC", + "Source Han Sans SC", + "WenQuanYi Micro Hei", +]; /// The same for Traditional Chinese. The two scripts share almost all of their code points but not /// the glyph forms (`东` against `東`'s neighbours, `直`, `骨`), so one list for both drew a /// Traditional interface with Simplified forms - the faces do not differ in *coverage*, which is /// what the fallback search checks, so the script has to pick the list. const TRADITIONAL_CHINESE_FAMILIES: &[&str] = - &["Microsoft JhengHei", "MingLiU", "PingFang TC", "Heiti TC", "Hiragino Sans CNS", "Noto Sans CJK TC", "Noto Sans TC", "Source Han Sans TC"]; + &["Microsoft JhengHei", "MingLiU", "PingFang TC", "Heiti TC", "Hiragino Sans CNS", "Songti TC", "Noto Sans CJK TC", "Noto Sans TC", "Source Han Sans TC"]; const KOREAN_FAMILIES: &[&str] = &["Malgun Gothic", "Gulim", "Apple SD Gothic Neo", "Noto Sans CJK KR", "Noto Sans KR", "Source Han Sans KR", "NanumGothic"]; /// The platforms' plain text families for the other scripts the bundled fonts lack, in the same /// order (Windows, macOS, then the Linux/Noto names). diff --git a/crates/text/src/fonts/tests.rs b/crates/text/src/fonts/tests.rs index 079a001e0..1f7e0d46f 100644 --- a/crates/text/src/fonts/tests.rs +++ b/crates/text/src/fonts/tests.rs @@ -1,5 +1,14 @@ use super::*; +/// Run `test` again in a child process with `variable` set. The fixture faces below go into the +/// process-global font database and its fallback caches, so only a process that runs one such test +/// on its own can use them. +#[cfg(not(target_arch = "wasm32"))] +fn run_in_child(variable: &str, test: &str) { + let output = std::process::Command::new(std::env::current_exe().unwrap()).args(["--exact", test, "--nocapture"]).env(variable, "1").output().unwrap(); + assert!(output.status.success(), "{}\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr)); +} + /// Concurrent language requests must neither choose another context's font nor reuse its /// cached family order. Synthetic fonts in a child process keep this independent of the host /// font installation and of other tests that exercise the process-global database/locale. @@ -8,12 +17,7 @@ use super::*; fn cjk_requests_keep_their_language_and_cache_entries() { const CHILD: &str = "EFFECTCRAFT_TEST_ISOLATED_CJK"; if std::env::var_os(CHILD).is_none() { - let output = std::process::Command::new(std::env::current_exe().unwrap()) - .args(["--exact", "fonts::tests::cjk_requests_keep_their_language_and_cache_entries", "--nocapture"]) - .env(CHILD, "1") - .output() - .unwrap(); - assert!(output.status.success(), "{}\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr)); + run_in_child(CHILD, "fonts::tests::cjk_requests_keep_their_language_and_cache_entries"); return; } let families = [("ja", "Yu Gothic"), ("zh-hans", "Microsoft YaHei"), ("zh-hant", "Microsoft JhengHei"), ("ko", "Malgun Gothic")]; @@ -71,6 +75,52 @@ fn cjk_requests_keep_their_language_and_cache_entries() { } } +/// #453: with a Chinese interface, a Han ideograph that both a Chinese and a Japanese face cover +/// must reach the installed Simplified Chinese family first, or Chinese text in a composition is +/// drawn with Japanese glyph forms. Fixture faces named after the macOS families keep this +/// independent of the host's fonts and of rendering. Before the fix the Simplified list named none +/// of the macOS faces a stock install has, so the walk below reached the Japanese face - the +/// regression this guards. +#[test] +#[cfg(not(target_arch = "wasm32"))] +fn zh_hans_han_fallback_puts_the_chinese_family_first() { + const CHILD: &str = "EFFECTCRAFT_TEST_ZH_HANS_ORDER"; + if std::env::var_os(CHILD).is_none() { + run_in_child(CHILD, "fonts::tests::zh_hans_han_fallback_puts_the_chinese_family_first"); + return; + } + let mut ids = Vec::new(); + { + let mut d = db().write().unwrap(); + d.scanned = true; // This test process uses only bundled and synthetic fonts. + // The Japanese face is added first, so scan order cannot be what picks the Chinese one. + for family in ["Hiragino Sans", "Hiragino Sans GB"] { + let mut data = test_font(family); + let table = data[12..12 + 8 * 16].as_chunks::<16>().0.iter().find(|t| &t[..4] == b"cmap").unwrap(); + let cmap = u32::from_be_bytes(table[8..12].try_into().unwrap()) as usize + 12; + // Map 页 to the fixture's triangle glyph instead of its private-use range. + for (offset, value) in [(16, '页' as u16), (24, '页' as u16), (30, 2u16.wrapping_sub('页' as u16))] { + data[cmap + offset..cmap + offset + 2].copy_from_slice(&value.to_be_bytes()); + } + let data = Arc::new(data); + let names = read_faces_bytes(&data).into_iter().next().unwrap(); + ids.push(push(&mut d, info_from(names, FaceData::Shared(data), "test"))); + } + } + let &[japanese, chinese] = ids.as_slice() else { panic!("two fixture faces expected: {ids:?}") }; + let base = resolve("Inter", "Regular").face; + assert!(face(japanese).has_char('页') && face(chinese).has_char('页')); + // The fallback list itself, before any coverage check: the Chinese family comes first for + // zh-hans and second for ja, whether or not either face can draw the probe. + let zh_hans: Vec = family_faces(FallbackScript::Han, &face(base), CjkLocale::Simplified).to_vec(); + assert_eq!(zh_hans, [chinese, japanese], "zh-hans tries the Chinese family first"); + let ja: Vec = family_faces(FallbackScript::Han, &face(base), CjkLocale::Japanese).to_vec(); + assert_eq!(ja, [japanese, chinese], "ja keeps the Japanese family first"); + // ...and the face a layout actually gets for the shared ideograph. + assert_eq!(face(fallback_for_locale('页', base, "zh-hans")).info.family, "Hiragino Sans GB"); + assert_eq!(face(fallback_for_locale('页', base, "ja")).info.family, "Hiragino Sans"); +} + #[test] fn extra_font_dirs_come_from_a_path_list() { assert!(extra_dirs(None).is_empty()); @@ -93,24 +143,47 @@ fn bundled_families_and_resolution() { assert!(!b.synth_bold); let faces = all_faces(); assert!(faces.iter().any(|f| f.info.family == "Noto Serif" && f.info.style == "Regular" && f.info.origin == "bundled")); - // A real installed Bold Italic takes priority. Partial installed families can instead - // supply the nearest weight/slant, so neither synthesis flag is fixed by the OS. + // A real installed Bold Italic — the face's own style, or a named instance of a variable + // face — takes priority. Partial installed families can instead supply the nearest + // weight/slant, so neither synthesis flag is fixed by the OS. for family in ["Noto Serif", "Inter"] { let resolved = resolve(family, "Bold Italic"); let selected = face(resolved.face); assert!(selected.info.family.eq_ignore_ascii_case(family)); assert!(!resolved.missing); let candidates: Vec<_> = faces.iter().filter(|f| f.info.family.eq_ignore_ascii_case(family)).collect(); - if let Some(exact) = candidates.iter().find(|f| f.info.style.eq_ignore_ascii_case("Bold Italic")) { + let exact = candidates + .iter() + .find(|f| f.info.style.eq_ignore_ascii_case("Bold Italic")) + .or_else(|| candidates.iter().find(|f| f.info.instances.iter().any(|i| i.style.eq_ignore_ascii_case("Bold Italic")))); + if let Some(exact) = exact { assert_eq!(resolved.face, exact.id, "real style wins for {family}"); + let expected_instance = if exact.info.style.eq_ignore_ascii_case("Bold Italic") { + None + } else { + exact.info.instances.iter().position(|i| i.style.eq_ignore_ascii_case("Bold Italic")).and_then(|i| u16::try_from(i).ok()) + }; + assert_eq!(resolved.instance, expected_instance, "exact style instance for {family}"); assert!(!resolved.synth_bold && !resolved.synth_italic); } else { - // Italic mismatch dominates any supported font weight distance. - let distance = |f: &Face| u32::from(f.info.weight.abs_diff(700)) + if f.info.italic { 0 } else { 1_000 }; - let nearest = candidates.iter().map(|f| distance(f)).min().unwrap(); - assert_eq!(distance(&selected), nearest, "closest available style for {family}"); - assert_eq!(resolved.synth_bold, selected.info.weight <= 500); - assert_eq!(resolved.synth_italic, !selected.info.italic); + // Italic mismatch dominates any supported font weight distance. Named instances + // resolve at their effective weight and slant, not the face's defaults. + let selected_instance = resolved.instance.and_then(|i| selected.info.instances.get(usize::from(i))); + let selected_weight = selected_instance.map_or(selected.info.weight, |i| i.weight(selected.info.weight)); + let selected_italic = selected_instance.map_or(selected.info.italic, |i| i.italic(selected.info.italic)); + let distance = |weight: u16, italic: bool| u32::from(weight.abs_diff(700)) + if italic { 0 } else { 1_000 }; + let nearest = candidates + .iter() + .flat_map(|f| { + std::iter::once((f.info.weight, f.info.italic)) + .chain(f.info.instances.iter().map(move |i| (i.weight(f.info.weight), i.italic(f.info.italic)))) + }) + .map(|(weight, italic)| distance(weight, italic)) + .min() + .unwrap(); + assert_eq!(distance(selected_weight, selected_italic), nearest, "closest available style for {family}"); + assert_eq!(resolved.synth_bold, selected_weight <= 500); + assert_eq!(resolved.synth_italic, !selected_italic); } } } @@ -130,6 +203,8 @@ fn system_scan_finds_fonts() { let n = all_faces().iter().filter(|f| f.info.origin == "system").count(); eprintln!("system fonts: {n} faces in {:?}", t.elapsed()); assert_eq!(scan_system(), 0, "scans once"); + // A minimum face count on Linux belongs with the native-fonts gate in #1591. A container + // that has fontconfig and only a handful of faces must still pass this portable test. if cfg!(target_os = "macos") { assert!(n > 20, "{n}"); let r = resolve("Helvetica", "Bold"); @@ -164,6 +239,7 @@ fn variable_named_instances_resolve_and_list() { ("Regular", 400.0, Some("EffectCraftInstanceTest-Regular")), ("Black", 900.0, Some("EffectCraftInstanceTest-Black")), ("Extra Bold", 800.0, None), + ("Bold Italic", 700.0, Some("EffectCraftInstanceTest-BoldItalic")), ]; assert_eq!(add_font_data(crate::sfnt::test_font::variable(FAMILY, &instances, true)).len(), 1); let wght = |r: &Resolved| instance_variations(r); @@ -185,10 +261,15 @@ fn variable_named_instances_resolve_and_list() { assert_eq!(wght(&ps), vec![("wght".to_string(), 900.0)]); assert_eq!(wght(&resolve("EffectCraftInstanceTest-ExtraBold", "")), vec![("wght".to_string(), 800.0)]); assert_eq!(wght(&resolve("effectcraft instance test extra bold", "")), vec![("wght".to_string(), 800.0)]); + // A named instance keeps its own index, so layout applies its axis values. + let bold_italic = resolve(FAMILY, "Bold Italic"); + assert!(!bold_italic.missing && !bold_italic.synth_bold && !bold_italic.synth_italic); + assert_eq!((bold_italic.face, bold_italic.instance), (black.face, Some(4))); + assert_eq!(wght(&bold_italic), vec![("wght".to_string(), 700.0)]); // The font menu lists every instance, in weight order. let fams = families(); let styles = &fams.iter().find(|(f, _)| f == FAMILY).unwrap().1; - assert_eq!(styles, &["Light", "Regular", "Extra Bold", "Black"]); + assert_eq!(styles, &["Light", "Regular", "Extra Bold", "Black", "Bold Italic"]); // Static fonts are untouched. assert_eq!(resolve("Inter", "Bold").instance, None); } diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 179478517..4c0b5a436 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -565,6 +565,34 @@ fn space_item(glyphs: &mut [ShapedGlyph], style: &TextStyle, text: &str, para_st } } +/// After Effects' first-baseline offset of paragraph (box) text for a face at nominal size +/// `px`: the height of its lowercase `d`, in whole thousandths of an em. Measured in After +/// Effects 2026 (case 818): the first baseline sits 69.50 px below the box top for Times New +/// Roman at 100 px (its `d` is 1422/2048 em, 694.3 thousandths, rounded 695) and 71.60 px for +/// Arial (1466/2048, 715.8, rounded 716), at 50 px half of that; superscript text keeps the +/// line where normal text puts it and only raises its glyphs. `None` without a `d` outline. +fn first_ascent(face: FaceId, px: f32) -> Option { + let f = fonts::face(face); + let gid = f.glyph('d')?; + let units = crate::outline_units(face, gid)?; + let top = kurbo::Shape::bounding_box(&*units).y1 as f32; + let em = (top / f.units_per_em() * 1000.0).round() / 1000.0; + plausible_first(em, px, f.metrics(px).ascent) +} + +/// A `d` at least this fraction of the font ascent is a Latin lowercase ascender; a shorter one +/// (a symbol or icon font's `d`) would put the first baseline almost at the box top, so the font +/// ascent is used instead (the rule before the `d` height). +const MIN_D_OF_ASCENT: f32 = 0.3; + +/// The first baseline `em * px` below the box top, if `em` (the `d` height in em) is plausible: +/// finite, positive, under 4 em and at least [`MIN_D_OF_ASCENT`] of the font `ascent` (px). +fn plausible_first(em: f32, px: f32, ascent: f32) -> Option { + let h = em * px; + let tall_enough = !(ascent.is_finite() && ascent > 0.0) || h >= MIN_D_OF_ASCENT * ascent; + (h.is_finite() && h > 0.0 && em < 4.0 && tall_enough).then_some(h) +} + struct ParaLine { range: Range, glyphs: Vec, @@ -575,6 +603,9 @@ struct ParaLine { hang: (f32, f32), ascent: f32, descent: f32, + /// Where a box's first line puts its baseline below the box top ([`first_ascent`] of the + /// line's styles at their nominal size; the font ascent where a face has no `d`). + first: f32, /// Line advance (largest leading on the line). advance: f32, align: Align, @@ -598,20 +629,25 @@ fn run_at(runs: &[(Range, TextStyle)], b: usize) -> usize { /// Lay out one paragraph (no newlines) whose text starts at byte `base` of the whole string. /// Margins (left, right) of a paragraph's first or later lines. -fn margins_for(para: &ParagraphStyle, first: bool) -> (f32, f32) { +/// `rtl` is the resolved paragraph direction (an unset direction follows the text). +fn margins_for(para: &ParagraphStyle, rtl: bool, first: bool) -> (f32, f32) { let fi = if first { para.indent_first } else { 0.0 }; - if para.rtl == Some(true) { (para.indent_end, para.indent_start + fi) } else { (para.indent_start + fi, para.indent_end) } + if rtl { (para.indent_end, para.indent_start + fi) } else { (para.indent_start + fi, para.indent_end) } } /// The width a line may fill (`None` for point text). -fn avail_for(para: &ParagraphStyle, width: Option, first: bool) -> Option { - let (l, r) = margins_for(para, first); +fn avail_for(para: &ParagraphStyle, rtl: bool, width: Option, first: bool) -> Option { + let (l, r) = margins_for(para, rtl, first); width.map(|w| (w - l - r).max(1.0)) } /// The alignment of a line, justified lines that cannot stretch falling back to `justify_last`. -fn align_for(para: &ParagraphStyle, width: Option, last: bool) -> Align { - if (last || width.is_none()) && para.align == Align::Justify { +fn align_for(para: &ParagraphStyle, rtl: bool, width: Option, last: bool) -> Align { + if rtl && width.is_none() && para.align == Align::Justify { + // Right-to-left point text with a justified paragraph sits flush at its start, the + // right (After Effects 2026, every justify code; left-to-right not measured). + Align::Right + } else if (last || width.is_none()) && para.align == Align::Justify { if width.is_none() && para.justify_last == Align::Justify { Align::Left } else { para.justify_last } } else { para.align @@ -622,6 +658,12 @@ fn style_metrics(primaries: &[Resolved], runs: &[(Range, TextStyle)], si: fonts::face(primaries[si].face).metrics(runs[si].1.glyph_size()) } +/// The baseline of a box's first line for style run `si`: the `d` height, else the font ascent. +fn style_first(primaries: &[Resolved], runs: &[(Range, TextStyle)], si: usize) -> f32 { + let st = &runs[si].1; + first_ascent(primaries[si].face, st.size).unwrap_or_else(|| fonts::face(primaries[si].face).metrics(st.size).ascent) +} + /// A char's shaping inputs: (byte, char, shaped char, face, rtl, form, run). type CharInfo = (usize, char, char, FaceId, bool, Form, usize); @@ -719,7 +761,7 @@ impl<'a> Para<'a> { } fn line_avail(&self, line: usize) -> f32 { - avail_for(self.para, self.width, line == 0).unwrap_or(f32::MAX) + avail_for(self.para, self.base_rtl, self.width, line == 0).unwrap_or(f32::MAX) } /// The byte ranges of the lines, for the wrap width. @@ -736,8 +778,11 @@ impl<'a> Para<'a> { ranges } - /// Minimises the summed squared slack of every line but the last. + /// Minimises the summed squared slack of every line but the last; with Justify All the last + /// line is stretched too, so its slack counts (After Effects then balances the lines: 4 / 4 / 4 + /// words where the other modes break 6 / 6 / 1). fn optimal_breaks(&self, opps: &[usize]) -> Vec> { + let last_counts = self.para.align == Align::Justify && self.para.justify_last == Align::Justify; let nodes: Vec = std::iter::once(0).chain(opps.iter().copied()).collect(); let m = nodes.len(); let mut best = vec![(f32::INFINITY, 0usize); m]; @@ -757,7 +802,7 @@ impl<'a> Para<'a> { let slack = maxw - w; let cost = if over { 1e12 + w - } else if j == m - 1 { + } else if j == m - 1 && !last_counts { 0.0 } else { slack * slack @@ -802,8 +847,8 @@ impl<'a> Para<'a> { /// The line over byte range `r`: the `li`-th of the paragraph, `last` if it is the final one. fn place_line(&self, li: usize, r: Range, last: bool) -> ParaLine { - let align = align_for(self.para, self.width, last); - let avail = avail_for(self.para, self.width, li == 0); + let align = align_for(self.para, self.base_rtl, self.width, last); + let avail = avail_for(self.para, self.base_rtl, self.width, li == 0); let (ce, tw) = self.trailing_ws(&r); let hang = self.hang_of(&r, ce); let content = self.range_w(r.start..ce); @@ -819,7 +864,7 @@ impl<'a> Para<'a> { } let placed = self.place_glyphs(&r, space_extra, ce); let carets = self.carets(&r, &placed.extents, placed.pen); - let (asc, desc, adv) = self.line_metrics(&r, &placed.glyphs); + let (asc, desc, adv, first) = self.line_metrics(&r, &placed.glyphs); let content_w = if space_extra > 0.0 { avail.map_or(content, |a| a + hang.0 + hang.1) } else { content }; ParaLine { range: self.base + r.start..self.base + r.end, @@ -830,10 +875,11 @@ impl<'a> Para<'a> { hang, ascent: asc, descent: desc, + first, advance: adv, align, avail, - margins: margins_for(self.para, li == 0), + margins: margins_for(self.para, self.base_rtl, li == 0), underline: placed.underline, } } @@ -938,8 +984,8 @@ impl<'a> Para<'a> { } /// Ascent, descent and advance of the line: the tallest face and the largest leading on it. - fn line_metrics(&self, r: &Range, glyphs: &[Glyph]) -> (f32, f32, f32) { - let (mut asc, mut desc, mut adv) = (0.0f32, 0.0f32, 0.0f32); + fn line_metrics(&self, r: &Range, glyphs: &[Glyph]) -> (f32, f32, f32, f32) { + let (mut asc, mut desc, mut adv, mut first) = (0.0f32, 0.0f32, 0.0f32, 0.0f32); let char_start = self.chars.partition_point(|c| c.0 < r.start); let char_end = self.chars.partition_point(|c| c.0 < r.end); let mut seen: Vec = self.chars[char_start..char_end].iter().map(|c| c.6).collect(); @@ -954,6 +1000,7 @@ impl<'a> Para<'a> { asc = asc.max(m.ascent + rise.max(0.0)); desc = desc.max(m.descent - rise.min(0.0)); adv = adv.max(self.runs[si].1.line_advance()); + first = first.max(style_first(self.primaries, self.runs, si)); } for g in glyphs { if g.face != self.primaries[g.run].face { @@ -962,7 +1009,7 @@ impl<'a> Para<'a> { desc = desc.max(m.descent); } } - (asc, desc, adv) + (asc, desc, adv, first) } } @@ -1037,6 +1084,8 @@ fn paragraph( if text.is_empty() { let si = run_at(runs, base); let pm = style_metrics(primaries, runs, si); + // No text to resolve a direction from: the explicit one. + let rtl = para.rtl == Some(true); return vec![ParaLine { range: base..base, glyphs: vec![], @@ -1047,9 +1096,10 @@ fn paragraph( ascent: pm.ascent, descent: pm.descent, advance: runs[si].1.line_advance(), - align: align_for(para, width, true), - avail: avail_for(para, width, true), - margins: margins_for(para, true), + first: style_first(primaries, runs, si), + align: align_for(para, rtl, width, true), + avail: avail_for(para, rtl, width, true), + margins: margins_for(para, rtl, true), underline: None, }]; } @@ -1120,7 +1170,10 @@ pub fn layout_rich_uncached(text: &str, runs: &[(Range, TextStyle)], para for (i, (pi, pl)) in plines.into_iter().enumerate() { let para = para_at(pi); if i == 0 { - baseline = if width.is_some() { pl.ascent } else { 0.0 }; + // Vertical type keeps its first column at the font ascent (not measured in AE). + let vertical = runs.get(run_at(runs, pl.range.start)).is_some_and(|r| r.1.vertical) + || runs.iter().any(|r| r.1.vertical && r.0.start < pl.range.end && r.0.end > pl.range.start); + baseline = if width.is_some() { if vertical { pl.ascent } else { pl.first } } else { 0.0 }; } else { let prev_para = lay.lines.last().map_or(pi, |l| l.para); let mut adv = pl.advance + para.leading; diff --git a/crates/text/src/layout/tests.rs b/crates/text/src/layout/tests.rs index 9079a1225..5792c1c16 100644 --- a/crates/text/src/layout/tests.rs +++ b/crates/text/src/layout/tests.rs @@ -357,3 +357,140 @@ fn named_instance_sets_the_base_variations() { let inter = layout_uncached("Hi", &TextStyle::default(), &ParagraphStyle::default()); assert!(inter.glyphs.iter().all(|g| g.variations == 0)); } + +/// After Effects 2026 (case 818): box text's first baseline is the `d` height (in whole +/// thousandths of an em) below the box top, at the nominal size also for superscript, whose +/// glyphs are then raised. EffectCraft used the font ascent of the drawn size (plus the +/// superscript rise): 89.1 px instead of 69.5 for Times New Roman at 100 px, and the +/// superscript glyphs 15.8 px low. +#[test] +fn box_text_first_baseline_is_the_d_height_as_after_effects() { + let boxed = ParagraphStyle { width: Some(600.0), ..Default::default() }; + // The rule on a bundled face (works everywhere). + for (family, size) in [("Inter", 100.0f32), ("Noto Serif", 50.0)] { + let s = TextStyle { family: family.into(), ..st(size) }; + let r = fonts::resolve(family, "Regular"); + let f = fonts::face(r.face); + let d = crate::outline_units(r.face, f.glyph('d').unwrap()).unwrap(); + let em = (kurbo::Shape::bounding_box(&*d).y1 as f32 / f.units_per_em() * 1000.0).round() / 1000.0; + let l = layout("Hxg", &s, &boxed); + assert!((l.lines[0].baseline - em * size).abs() < 1e-3, "{family}: {} vs {}", l.lines[0].baseline, em * size); + assert!(l.lines[0].baseline < l.lines[0].ascent, "{family}: below the full ascent"); + } + // After Effects' numbers, where the real fonts are installed. + for (family, ae_normal) in [("Times New Roman", 69.5008f32), ("Arial", 71.5988)] { + let r = fonts::resolve(family, "Regular"); + if fonts::face(r.face).info.family != family { + continue; + } + for (size, script, glyph_baseline) in + [(100.0, Script::Normal, ae_normal), (50.0, Script::Normal, ae_normal / 2.0), (100.0, Script::Super, ae_normal - 100.0 * SCRIPT_OFFSET)] + { + let s = TextStyle { family: family.into(), script, ..st(size) }; + let l = layout("Hxg", &s, &boxed); + let drawn = l.glyphs[0].y; + assert!((drawn - glyph_baseline).abs() < 0.15, "{family} {size} {script:?}: {drawn} vs AE {glyph_baseline}"); + } + } +} + +/// CodeRabbit on #1875: a face whose `d` is very short (a symbol or icon font) would put the +/// first baseline almost at the box top; below 0.3 of the ascent the font ascent is used. +#[test] +fn a_short_d_falls_back_to_the_font_ascent() { + // Latin faces: the `d` is about 0.7 em, the ascent about 0.9 em. + let near = |v: Option, want: f32| v.is_some_and(|v| (v - want).abs() < 1e-3); + assert!(near(plausible_first(0.7, 100.0, 90.0), 70.0)); + // A `d` a tenth of the ascent: not a lowercase ascender. + assert_eq!(plausible_first(0.09, 100.0, 90.0), None); + assert_eq!(plausible_first(0.26, 100.0, 90.0), None); + assert!(near(plausible_first(0.28, 100.0, 90.0), 28.0)); + // Broken values stay rejected; an unusable ascent does not reject a sane `d`. + for (em, px) in [(0.0, 100.0), (-0.5, 100.0), (f32::NAN, 100.0), (5.0, 100.0), (0.7, f32::INFINITY)] { + assert_eq!(plausible_first(em, px, 90.0), None, "{em} {px}"); + } + assert!(near(plausible_first(0.7, 100.0, f32::NAN), 70.0)); + // The bundled faces keep the `d` rule. + for family in ["Inter", "Noto Serif"] { + let r = fonts::resolve(family, "Regular"); + assert!(first_ascent(r.face, 100.0).is_some(), "{family}"); + } +} + +/// After Effects 2026 (EXPORTER t6): with Justify All and the every-line composer, a box that +/// the other justifications break into lines of 6, 6 and 1 words breaks 4 / 4 / 4, so the +/// stretched last line is not a lone word; word positions then match AE to 1 px. +#[test] +fn justify_all_balances_the_lines_with_the_every_line_composer() { + let text = "one two three four five six seven eight nine ten eleven twelve"; + let s = st(30.0); + let words = |l: &Layout| -> Vec { l.lines.iter().map(|ln| text[ln.range.clone()].split_whitespace().count()).collect() }; + let p = |last: Align| ParagraphStyle { width: Some(450.0), align: Align::Justify, justify_last: last, every_line: true, ..Default::default() }; + let left = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p(Align::Left)]); + let all = layout_rich_uncached(text, &[(0..text.len(), s)], &[p(Align::Justify)]); + let (wl, wa) = (words(&left), words(&all)); + assert!(wl.last().copied().unwrap_or(0) < *wl.first().unwrap_or(&0), "{wl:?}"); + let (lo, hi) = (wa.iter().min().copied().unwrap_or(0), wa.iter().max().copied().unwrap_or(0)); + assert!(hi - lo <= 1, "Justify All balances the lines: {wa:?} (left-justified: {wl:?})"); +} + +/// After Effects 2026 (EXPORTER read-back t7): right-to-left point text with any justified +/// code (justify last left / centre / right, justify all) sits flush right at its anchor, as +/// right-aligned text does. +#[test] +fn rtl_point_text_with_a_justified_paragraph_is_flush_at_its_start() { + let text = "Right to left"; + let s = st(30.0); + let at = |align: Align, last: Align| { + let p = ParagraphStyle { align, justify_last: last, rtl: Some(true), ..Default::default() }; + let l = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p]); + (l.lines[0].x, l.lines[0].x + l.lines[0].width) + }; + let right = at(Align::Right, Align::Left); + for last in [Align::Left, Align::Center, Align::Right, Align::Justify] { + let got = at(Align::Justify, last); + assert!((got.0 - right.0).abs() < 1e-3 && (got.1 - right.1).abs() < 1e-3, "{last:?}: {got:?} vs right-aligned {right:?}"); + } +} + +/// The same holds when the direction is unset and the text itself resolves right to left. +#[test] +fn detected_rtl_point_text_with_a_justified_paragraph_is_flush_at_its_start() { + let text = "\u{5d0}\u{5d1}\u{5d2} \u{5d3}\u{5d4}"; + let s = st(30.0); + let at = |align: Align| { + let p = ParagraphStyle { align, justify_last: Align::Left, rtl: None, ..Default::default() }; + let l = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p]); + (l.lines[0].x, l.lines[0].x + l.lines[0].width) + }; + let (right, got) = (at(Align::Right), at(Align::Justify)); + assert!((got.0 - right.0).abs() < 1e-3 && (got.1 - right.1).abs() < 1e-3, "{got:?} vs right-aligned {right:?}"); +} + +/// Indents follow the resolved direction too: detected right-to-left text puts its start +/// indent on the right, as an explicit right-to-left paragraph does. +#[test] +fn detected_rtl_text_takes_its_indents_on_the_rtl_sides() { + let text = "\u{5d0}\u{5d1}\u{5d2} \u{5d3}\u{5d4}"; + let s = st(30.0); + let at = |rtl: Option| { + let p = ParagraphStyle { align: Align::Justify, justify_last: Align::Left, indent_start: 20.0, rtl, ..Default::default() }; + let l = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p]); + (l.lines[0].x, l.lines[0].x + l.lines[0].width) + }; + let (explicit, detected) = (at(Some(true)), at(None)); + assert!((detected.0 - explicit.0).abs() < 1e-3 && (detected.1 - explicit.1).abs() < 1e-3, "{detected:?} vs {explicit:?}"); +} + +/// Only the first line's own runs decide whether box text starts at the font ascent: a +/// vertical run further down leaves a horizontal first line on its `d`-height baseline. +#[test] +fn a_later_vertical_run_keeps_the_first_line_baseline() { + let text = "Top\nside"; + let horiz = st(40.0); + let vert = TextStyle { vertical: true, ..horiz.clone() }; + let p = ParagraphStyle { width: Some(400.0), ..Default::default() }; + let plain = layout_rich_uncached(text, &[(0..text.len(), horiz.clone())], std::slice::from_ref(&p)); + let mixed = layout_rich_uncached(text, &[(0..4, horiz), (4..text.len(), vert)], &[p]); + assert!((mixed.lines[0].baseline - plain.lines[0].baseline).abs() < 1e-3, "{} vs {}", mixed.lines[0].baseline, plain.lines[0].baseline); +} diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 35e7ecc06..8cc8e3264 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -659,7 +659,9 @@ mod tests { let doc = TextDoc { text: "ab cd".into(), size: 20.0, box_size: Some([200.0, 100.0]), box_pos: [-100.0, -50.0], ..Default::default() }; let l = layout_doc(&doc); let (a, _) = l.caret(0); - assert!((a.x + 100.0).abs() < 1e-6 && (a.y + 50.0).abs() < 1.0, "{a:?}"); + // The first baseline sits the `d` height below the box top (After Effects), so the + // caret, which spans the font's full ascent, starts a little above the box. + assert!((a.x + 100.0).abs() < 1e-6 && a.y + 50.0 < 0.0 && a.y + 50.0 > -0.35 * 20.0, "{a:?}"); // Vertical: characters run down a column, carets are horizontal. let v = layout_doc(&TextDoc { text: "abc\nde".into(), size: 20.0, vertical: true, ..Default::default() }); let ys: Vec = v.glyphs.iter().filter(|g| g.line_index == 0).map(|g| g.origin.y).collect(); diff --git a/crates/text/src/selectors.rs b/crates/text/src/selectors.rs index e3a422460..fcecfd814 100644 --- a/crates/text/src/selectors.rs +++ b/crates/text/src/selectors.rs @@ -71,6 +71,15 @@ impl Mode { pub fn combine(self, acc: f64, v: f64) -> f64 { let r = match self { Mode::Add => acc + v, + // Subtracting more than is selected leaves nothing, not a negative selection (After + // Effects 2026: a Subtract selector over unselected characters leaves their + // Tracking at 0, EXPORTER read-back t8). + // Intended for every animator property, not only Tracking: selectors combine into one + // selection per character for the whole animator (`render::text::animator_selection`), + // which then scales each of its properties, as in After Effects, where selectors + // belong to the animator. The read-back observed that selection through Tracking; + // Opacity, Position and the rest were not measured separately. + Mode::Subtract if acc >= 0.0 => (acc - v).max(0.0), Mode::Subtract => acc - v, Mode::Intersect => acc * v, Mode::Min => acc.min(v), @@ -352,6 +361,9 @@ mod tests { // A lone Subtract inverts. assert_eq!(Mode::Subtract.combine(Mode::Subtract.initial(), 1.0), 0.0); assert_eq!(Mode::Subtract.combine(Mode::Subtract.initial(), 0.0), 1.0); + // Subtracting from what is not selected leaves 0, not a negative selection (AE t8). + assert_eq!(Mode::Subtract.combine(0.0, 1.0), 0.0); + assert_eq!(Mode::Subtract.combine(0.25, 1.0), 0.0); assert_eq!(Mode::from_index(5), Mode::Difference); } diff --git a/crates/track/src/camtrack/bundle.rs b/crates/track/src/camtrack/bundle.rs index b8421f144..a4412dbce 100644 --- a/crates/track/src/camtrack/bundle.rs +++ b/crates/track/src/camtrack/bundle.rs @@ -11,7 +11,7 @@ use rayon::prelude::*; use super::geometry::Pose; -use super::linalg::{M3, V3, add, cholesky_solve, dot, inv3, mm, mtv, mv, rodrigues, scale, skew, sub}; +use super::linalg::{M3, V3, add, cholesky_solve, conjugate_gradient, dot, inv3, mm, mtv, mv, rodrigues, scale, skew, sub}; use super::{Distortion, SolveError}; /// A camera in the adjustment. @@ -287,6 +287,101 @@ fn cam_jac(l: &Layout, ci: usize, c: &BaCam, xc: V3, a: &[[f64; 3]; 2], p: [f64; } } +struct PtSys { + v: M3, + bp: V3, + w: Vec<(usize, V3)>, +} + +struct PreconditionerBlock { + start: usize, + dim: usize, + inverse: Vec, +} + +fn camera_preconditioner(u: &[f64], l: &Layout) -> Option> { + let mut ranges = Vec::new(); + let pose_dim = if l.rot_only { 3 } else { 6 }; + for (&pose, &focal) in l.start.iter().zip(&l.fidx) { + if pose != usize::MAX { + ranges.push((pose, pose_dim + usize::from(focal != usize::MAX))); + } else if focal != usize::MAX { + ranges.push((focal, 1)); + } + } + if let Some(start) = l.shared.or(l.dist) { + ranges.push((start, l.n - start)); + } + ranges + .into_iter() + .map(|(start, dim)| { + let mut matrix = Vec::with_capacity(dim * dim); + for i in 0..dim { + for j in 0..dim { + matrix.push(*u.get((start + i) * l.n + start + j)?); + } + } + let mut inverse = vec![0.0; dim * dim]; + for col in 0..dim { + let mut a = matrix.clone(); + let mut b = vec![0.0; dim]; + *b.get_mut(col)? = 1.0; + if !cholesky_solve(&mut a, dim, &mut b) { + return None; + } + for (row, value) in b.into_iter().enumerate() { + inverse[row * dim + col] = value; + } + } + Some(PreconditionerBlock { start, dim, inverse }) + }) + .collect() +} + +/// Apply the point-eliminated system without materialising W V^-1 W^T. +fn iterative_schur(u: &[f64], rhs: &[f64], systems: &[PtSys], vinv: &[Option], l: &Layout) -> Option> { + if l.n == 0 || l.n.checked_mul(l.n) != Some(u.len()) || rhs.len() != l.n || systems.len() != vinv.len() { + return None; + } + let blocks = camera_preconditioner(u, l)?; + // U contains camera blocks, global parameters and the focal prior. Store its nonzeros + // once; all subsequent products are linear in these and the point-camera connections. + let rows: Vec> = + u.chunks_exact(l.n).map(|row| row.iter().enumerate().filter(|(_, v)| **v != 0.0).map(|(i, v)| (i, *v)).collect()).collect(); + let mut reduced_rhs = rhs.to_vec(); + for (ps, vi) in systems.iter().zip(vinv) { + let Some(vi) = vi else { continue }; + let vb = mv(vi, ps.bp); + for (i, w) in &ps.w { + *reduced_rhs.get_mut(*i)? -= dot(*w, vb); + } + } + conjugate_gradient( + &reduced_rhs, + |x, y| { + for (i, row) in rows.iter().enumerate() { + y[i] = row.iter().map(|(j, v)| v * x[*j]).sum(); + } + for (ps, vi) in systems.iter().zip(vinv) { + let Some(vi) = vi else { continue }; + let q = ps.w.iter().fold([0.0; 3], |q, (i, w)| add(q, scale(*w, x[*i]))); + let q = mv(vi, q); + for (i, w) in &ps.w { + y[*i] -= dot(*w, q); + } + } + }, + |r, z| { + z.fill(0.0); + for block in &blocks { + for i in 0..block.dim { + z[block.start + i] = (0..block.dim).map(|j| block.inverse[i * block.dim + j] * r[block.start + j]).sum(); + } + } + }, + ) +} + /// Smooth per-camera log focal lengths in the normal equations. fn add_focal_prior(cams: &[BaCam], o: &BaOpts, l: &Layout, s_mat: &mut [f64], bc: &mut [f64]) { let n = l.n; @@ -310,10 +405,56 @@ fn add_focal_prior(cams: &[BaCam], o: &BaOpts, l: &Layout, s_mat: &mut [f64], bc } } +/// Eliminate the points into the camera system explicitly (`S -= W V^-1 W^T`, `rhs -= W V^-1 bp`). +fn reduce_points(systems: &[PtSys], vinv: &[Option], n: usize, s: &mut [f64], rhs: &mut [f64]) { + let npts = systems.len(); + let reduce = |range: std::ops::Range, s: &mut [f64], rhs: &mut [f64]| { + for j in range { + let (Some(vi), ps) = (&vinv[j], &systems[j]) else { continue }; + let vb = mv(vi, ps.bp); + let wv: Vec = ps.w.iter().map(|(_, w)| mv(vi, *w)).collect(); + for (a, (ia, wa)) in ps.w.iter().enumerate() { + rhs[*ia] -= dot(*wa, vb); + for (ib, wb) in &ps.w[a..] { + let d = dot(wv[a], *wb); + s[ia * n + ib] -= d; + if ia != ib { + s[ib * n + ia] -= d; + } + } + } + } + }; + let chunks = rayon::current_num_threads().clamp(1, 8); + if n <= 1200 && npts >= 64 && chunks > 1 { + let per = npts.div_ceil(chunks); + let parts: Vec<(Vec, Vec)> = (0..chunks) + .into_par_iter() + .map(|t| { + let (mut ds, mut dr) = (vec![0.0; n * n], vec![0.0; n]); + reduce(t * per..((t + 1) * per).min(npts), &mut ds, &mut dr); + (ds, dr) + }) + .collect(); + for (ds, dr) in parts { + s.par_iter_mut().zip(ds.par_iter()).for_each(|(x, y)| *x += y); + for (x, y) in rhs.iter_mut().zip(dr) { + *x += y; + } + } + } else { + reduce(0..npts, s, rhs); + } +} + /// Run Levenberg–Marquardt. `cams` and `pts` are updated in place. /// Invalid inputs or workspaces exceeding 256 MiB return an error before mutation. pub fn adjust(cams: &mut [BaCam], pts: &mut [V3], obs: &[BaObs], o: &BaOpts) -> Result { validate(cams, pts, obs, o)?; + adjust_impl(cams, pts, obs, o, true) +} + +fn adjust_impl(cams: &mut [BaCam], pts: &mut [V3], obs: &[BaObs], o: &BaOpts, allow_iterative: bool) -> Result { let l = layout(cams.len(), o); let matrix_len = matrix_len(l.n, pts.len(), obs.len())?; let npts = pts.len(); @@ -341,11 +482,6 @@ pub fn adjust(cams: &mut [BaCam], pts: &mut [V3], obs: &[BaObs], o: &BaOpts) -> iters += 1; // ---- Normal equations (Gauss–Newton with IRLS weights). // Per point: V (3×3), bp (3), W entries (cam-side index → 3). - struct PtSys { - v: [[f64; 3]; 3], - bp: V3, - w: Vec<(usize, V3)>, - } let build_point = |j: usize, s_acc: &mut Vec, bc: &mut Vec| -> PtSys { let mut ps = PtSys { v: [[0.0; 3]; 3], bp: [0.0; 3], w: vec![] }; let mut jc = CameraJacobian::new(); @@ -445,6 +581,10 @@ pub fn adjust(cams: &mut [BaCam], pts: &mut [V3], obs: &[BaObs], o: &BaOpts) -> let base_s = s_mat.clone(); let base_bc = bc.clone(); let mean_diag = ((0..n).map(|i| base_s[i * n + i]).sum::() / n.max(1) as f64).max(1e-12); + let schur_pairs = systems.iter().fold(0usize, |sum, ps| sum.saturating_add(ps.w.len().saturating_mul(ps.w.len().saturating_add(1)) / 2)); + // Reserve the iterative solve for expensive long-track reductions; small systems + // retain the direct path and its rounding. Failed solves use the direct path too. + let mut use_iterative = allow_iterative && !o.fix_points && n >= 120 && schur_pairs > 4_000_000; // ---- Try steps with increasing damping. let mut accepted = false; for _try in 0..12 { @@ -472,50 +612,26 @@ pub fn adjust(cams: &mut [BaCam], pts: &mut [V3], obs: &[BaObs], o: &BaOpts) -> }) .collect() }; - if !o.fix_points { - let reduce = |range: std::ops::Range, s: &mut [f64], rhs: &mut [f64]| { - for j in range { - let (Some(vi), ps) = (&vinv[j], &systems[j]) else { continue }; - let vb = mv(vi, ps.bp); - let wv: Vec = ps.w.iter().map(|(_, w)| mv(vi, *w)).collect(); - for (a, (ia, wa)) in ps.w.iter().enumerate() { - rhs[*ia] -= dot(*wa, vb); - for (ib, wb) in &ps.w[a..] { - let d = dot(wv[a], *wb); - s[ia * n + ib] -= d; - if ia != ib { - s[ib * n + ia] -= d; - } - } - } - } - }; - let chunks = rayon::current_num_threads().clamp(1, 8); - if n <= 1200 && npts >= 64 && chunks > 1 { - let per = npts.div_ceil(chunks); - let parts: Vec<(Vec, Vec)> = (0..chunks) - .into_par_iter() - .map(|t| { - let (mut ds, mut dr) = (vec![0.0; matrix_len], vec![0.0; n]); - reduce(t * per..((t + 1) * per).min(npts), &mut ds, &mut dr); - (ds, dr) - }) - .collect(); - for (ds, dr) in parts { - s.par_iter_mut().zip(ds.par_iter()).for_each(|(x, y)| *x += y); - for (x, y) in rhs.iter_mut().zip(dr) { - *x += y; - } - } - } else { - reduce(0..npts, &mut s, &mut rhs); + let iterative_step = if use_iterative { iterative_schur(&s, &rhs, &systems, &vinv, &l) } else { None }; + if dbg && use_iterative { + eprintln!("ba iterative n={n} pairs={schur_pairs} solved={}", iterative_step.is_some()); + } + if use_iterative && iterative_step.is_none() { + use_iterative = false; + } + let dc = if let Some(dc) = iterative_step { + dc + } else { + if !o.fix_points { + reduce_points(&systems, &vinv, n, &mut s, &mut rhs); } - } - let mut dc = rhs; - if n > 0 && !cholesky_solve(&mut s, n, &mut dc) { - lambda *= 10.0; - continue; - } + let mut dc = rhs; + if n > 0 && !cholesky_solve(&mut s, n, &mut dc) { + lambda *= 10.0; + continue; + } + dc + }; // Apply. let mut nc: Vec = cams.to_vec(); for (i, c) in nc.iter_mut().enumerate() { @@ -593,6 +709,112 @@ pub fn adjust(cams: &mut [BaCam], pts: &mut [V3], obs: &[BaObs], o: &BaOpts) -> mod tests { use super::*; use crate::camtrack::geometry::Rng; + use crate::camtrack::linalg::{I3, log_rot, mt, norm}; + + #[test] + fn long_track_adjustment_matches_direct_reprojections_and_outliers() { + rayon::ThreadPoolBuilder::new().num_threads(1).build().unwrap().install(|| { + for (focal, fix_centers, distortion) in + [(FocalMode::Fixed, false, false), (FocalMode::Shared, false, true), (FocalMode::PerCamera(30.0), false, true), (FocalMode::Shared, true, true)] + { + let mut rng = Rng(71); + let mut noise = || (rng.next_u64() % 100_000) as f64 / 100_000.0 - 0.5; + let truth: Vec = (0..320).map(|_| [noise() * 8.0, noise() * 5.0, 10.0 + noise() * 5.0]).collect(); + let true_cams: Vec<_> = (0..60) + .map(|i| BaCam { + pose: Pose { + r: rodrigues([0.001 * i as f64, -0.003 * i as f64, 0.0]), + c: if fix_centers { [0.0; 3] } else { [0.03 * i as f64, 0.01 * (i as f64).sin(), 0.0] }, + }, + f: if matches!(focal, FocalMode::PerCamera(_)) { 900.0 * (1.0 + 0.05 * (i as f64 * 0.07).sin()) } else { 900.0 }, + dist: if distortion { Distortion { k1: 0.015, k2: -0.005, radius: 1100.0 } } else { Distortion::NONE }, + }) + .collect(); + let mut obs = Vec::new(); + for (ci, c) in true_cams.iter().enumerate() { + for (pi, p) in truth.iter().enumerate() { + let mut uv = residual(c, *p, [0.0; 2]).unwrap(); + uv[0] += noise() * 0.05; + uv[1] += noise() * 0.05; + if (ci * truth.len() + pi).is_multiple_of(37) { + uv[0] += 25.0; + uv[1] -= 30.0; + } + obs.push(BaObs { cam: ci as u32, pt: pi as u32, uv }); + } + } + let mut cams = true_cams.clone(); + for c in cams.iter_mut().skip(2) { + c.pose.r = mm(&rodrigues([0.001, -0.001, 0.0005]), &c.pose.r); + if !fix_centers { + c.pose.c = add(c.pose.c, [0.002, -0.003, 0.001]); + } + } + let mut pts: Vec<_> = truth.iter().map(|p| add(*p, [0.01, -0.01, 0.005])).collect(); + let (mut direct_cams, mut direct_pts) = (cams.clone(), pts.clone()); + let opts = BaOpts { focal, fix_centers, distortion, fixed: (0..60).map(|i| i < 2).collect(), max_iter: 10, ..Default::default() }; + let report = adjust(&mut cams, &mut pts, &obs, &opts).unwrap(); + let direct = adjust_impl(&mut direct_cams, &mut direct_pts, &obs, &opts, false).unwrap(); + assert!((report.cost - direct.cost).abs() < 1e-5 * direct.cost.max(1.0)); + for ob in &obs { + let a = residual(&cams[ob.cam as usize], pts[ob.pt as usize], ob.uv).unwrap(); + let b = residual(&direct_cams[ob.cam as usize], direct_pts[ob.pt as usize], ob.uv).unwrap(); + assert!((a[0] - b[0]).hypot(a[1] - b[1]) < 0.001); + assert_eq!(a[0].hypot(a[1]) <= 4.0, b[0].hypot(b[1]) <= 4.0); + } + for (a, b) in cams.iter().zip(direct_cams) { + assert!(norm(sub(a.pose.c, b.pose.c)) < 0.001); + assert!(norm(log_rot(&mm(&a.pose.r, &mt(&b.pose.r)))) < 1e-4); + assert!((a.f - b.f).abs() < 1e-4 * b.f); + assert!((a.dist.k1 - b.dist.k1).abs() < 1e-4 && (a.dist.k2 - b.dist.k2).abs() < 1e-4); + } + } + }); + } + + #[test] + fn matrix_free_schur_matches_explicit_system_and_rejects_bad_preconditioner() { + let l = layout(20, &BaOpts::default()); + let n = l.n; + let mut u = vec![0.0; n * n]; + for i in 0..n { + u[i * n + i] = 10.0; + } + let systems: Vec<_> = (0..80) + .map(|j| PtSys { + v: I3, + bp: [0.01, -0.02, 0.03], + w: (0..n).map(|i| (i, [((j * 13 + i * 7) % 19) as f64 * 0.001 - 0.009, (i % 7) as f64 * 0.001, (j % 11) as f64 * 0.001])).collect(), + }) + .collect(); + let vinv = vec![Some(I3); systems.len()]; + let rhs: Vec<_> = (0..n).map(|i| (i % 7) as f64 - 3.0).collect(); + let x = iterative_schur(&u, &rhs, &systems, &vinv, &l).unwrap(); + let mut explicit = u.clone(); + let mut reduced_rhs = rhs.clone(); + for ps in &systems { + for (i, a) in &ps.w { + reduced_rhs[*i] -= dot(*a, ps.bp); + for (j, b) in &ps.w { + explicit[i * n + j] -= dot(*a, *b); + } + } + } + let mut direct = reduced_rhs.clone(); + let mut factor = explicit.clone(); + assert!(cholesky_solve(&mut factor, n, &mut direct)); + for (a, b) in x.iter().zip(direct) { + assert!((a - b).abs() < 1e-9); + } + let mut error2 = 0.0; + for (i, row) in explicit.chunks_exact(n).enumerate() { + let ax: f64 = row.iter().zip(&x).map(|(a, x)| a * x).sum(); + error2 += (ax - reduced_rhs[i]).powi(2); + } + assert!(error2 < 1e-18 * reduced_rhs.iter().map(|v| v * v).sum::()); + u[0] = -1.0; + assert!(iterative_schur(&u, &rhs, &systems, &vinv, &l).is_none()); + } fn test_camera() -> BaCam { BaCam { pose: Pose::IDENTITY, f: 900.0, dist: Distortion::NONE } diff --git a/crates/track/src/camtrack/linalg.rs b/crates/track/src/camtrack/linalg.rs index 692eef34e..71e88e109 100644 --- a/crates/track/src/camtrack/linalg.rs +++ b/crates/track/src/camtrack/linalg.rs @@ -398,6 +398,60 @@ pub fn cholesky_solve(a: &mut [f64], n: usize, b: &mut [f64]) -> bool { b.iter().all(|v| v.is_finite()) } +/// Preconditioned conjugate gradients with an independently recomputed residual check. +/// Failure to converge (or non-finite/non-positive curvature) requests the direct fallback. +pub(crate) fn conjugate_gradient(rhs: &[f64], apply: impl Fn(&[f64], &mut [f64]), precondition: impl Fn(&[f64], &mut [f64])) -> Option> { + let n = rhs.len(); + let norm2 = dot_n(rhs, rhs); + if !norm2.is_finite() { + return None; + } + if rhs.iter().all(|v| *v == 0.0) { + return Some(vec![0.0; n]); + } + let tolerance2 = norm2 * 1e-22; + if tolerance2 == 0.0 { + return None; + } + let mut x = vec![0.0; n]; + let mut r = rhs.to_vec(); + let mut z = vec![0.0; n]; + let mut ap = vec![0.0; n]; + precondition(&r, &mut z); + let mut p = z.clone(); + let mut rz = dot_n(&r, &z); + for _ in 0..n.saturating_mul(2).min(512) { + if !rz.is_finite() || rz <= 0.0 { + return None; + } + apply(&p, &mut ap); + let curvature = dot_n(&p, &ap); + if !curvature.is_finite() || curvature <= 0.0 { + return None; + } + let alpha = rz / curvature; + for i in 0..n { + x[i] += alpha * p[i]; + r[i] -= alpha * ap[i]; + } + if dot_n(&r, &r) <= tolerance2 { + apply(&x, &mut ap); + for i in 0..n { + ap[i] = rhs[i] - ap[i]; + } + return (x.iter().all(|v| v.is_finite()) && dot_n(&ap, &ap) <= 100.0 * tolerance2).then_some(x); + } + precondition(&r, &mut z); + let next_rz = dot_n(&r, &z); + let beta = next_rz / rz; + for i in 0..n { + p[i] = z[i] + beta * p[i]; + } + rz = next_rz; + } + None +} + /// A similarity transform `x ↦ s R x + t`. #[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub struct Similarity { @@ -586,6 +640,30 @@ mod tests { assert!(cholesky_solve(&mut [], 0, &mut [])); } + #[test] + fn conjugate_gradient_checks_residual_and_bad_curvature() { + let n = 64; + let rhs: Vec = (0..n).map(|i| (i % 11) as f64 - 5.0).collect(); + let apply = |x: &[f64], y: &mut [f64]| { + for i in 0..n { + y[i] = 4.0 * x[i] + if i > 0 { x[i - 1] } else { 0.0 } + if i + 1 < n { x[i + 1] } else { 0.0 }; + } + }; + let x = conjugate_gradient(&rhs, apply, |r, z| { + for (r, z) in r.iter().zip(z) { + *z = *r / 4.0; + } + }) + .unwrap(); + let mut product = vec![0.0; n]; + apply(&x, &mut product); + let residual: f64 = rhs.iter().zip(product).map(|(b, ax)| (b - ax).powi(2)).sum(); + assert!(residual < 1e-18 * dot_n(&rhs, &rhs)); + assert!(conjugate_gradient(&[1.0], |x, y| y[0] = -x[0], |r, z| z.copy_from_slice(r)).is_none()); + assert!(conjugate_gradient(&[f64::NAN], |_, _| {}, |_, _| {}).is_none()); + assert_eq!(conjugate_gradient(&[0.0], |_, _| {}, |_, _| {}).unwrap(), vec![0.0]); + } + #[test] fn svd_reconstructs() { let a = [3.0, 1.0, 2.0, -1.0, 4.0, 0.5, 2.0, 2.0, 7.0, 0.1, -3.0, 1.0]; diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index a3263a677..2cb103dcc 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -144,6 +144,9 @@ impl EffectcraftApp { // commands handled here use the right catalog already. let language = crate::i18n::language(self); crate::i18n::set_current(language); + // #1089: a font-menu rescan runs off the UI thread. Poll it on the logic tick so the + // status and the menu update even when the window is not painting. + crate::panels::text_panels::poll_font_rescan(self, ctx); if self.styled_language != language { self.want_paint = true; } @@ -384,6 +387,11 @@ impl EffectcraftApp { let full = ui.max_rect(); if self.dialog.is_some() { ui.disable(); + // A dialog that edits what is behind it live (Preview) leaves the window at full + // brightness: clicks still don't reach it, but the canvas is not greyed out. + if self.dialog == Some(Dialog::GradientEditor) || panels::live_preview::active(self) { + ui.set_opacity(1.0); + } } ui.painter().rect_filled(full, 0.0, t.app_bg); let mut top = full.min.y; diff --git a/crates/ui-egui/src/app/prefetch.rs b/crates/ui-egui/src/app/prefetch.rs index e1b953d7b..d351ca411 100644 --- a/crates/ui-egui/src/app/prefetch.rs +++ b/crates/ui-egui/src/app/prefetch.rs @@ -406,7 +406,8 @@ impl EffectcraftApp { } let cur = comp.frame_rate.frame_at(self.session.time()).clamp(wa, wb); let fit = self.frames_that_fit(comp, series.scale as f64 / 1000.0).min(n); - let slots = (self.frames_parallelism() / 2).max(1); + // One thread each (`Frames::request_background`), so every frame thread takes one. + let slots = self.frames_parallelism().max(1); let mut queued = self.frames.inflight(); let scale = series.scale as f64 / 1000.0; let mut pending = false; @@ -419,7 +420,7 @@ impl EffectcraftApp { if queued >= slots { break; } - self.request_frame(ItemId(series.comp), f, scale); + self.request_frame_background(ItemId(series.comp), f, scale); queued += 1; } if pending { diff --git a/crates/ui-egui/src/app/viewer_frames.rs b/crates/ui-egui/src/app/viewer_frames.rs index 3c8632cea..c878fafa4 100644 --- a/crates/ui-egui/src/app/viewer_frames.rs +++ b/crates/ui-egui/src/app/viewer_frames.rs @@ -126,12 +126,18 @@ impl EffectcraftApp { /// Request a prefetch frame render (no-op if cached/in flight). pub fn request_frame(&self, comp: ItemId, frame: i64, scale: f64) { - self.request_frame_with(comp, frame, scale, false); + self.request_frame_with(comp, frame, scale, frames::Frames::request); } /// Request the frame on screen: rendered before any queued prefetch. pub fn request_frame_urgent(&self, comp: ItemId, frame: i64, scale: f64) { - self.request_frame_with(comp, frame, scale, true); + self.request_frame_with(comp, frame, scale, frames::Frames::request_urgent); + } + + /// Request a frame nobody waits for (Cache Frames When Idle): rendered on one thread + /// (`Frames::request_background`). + pub fn request_frame_background(&self, comp: ItemId, frame: i64, scale: f64) { + self.request_frame_with(comp, frame, scale, frames::Frames::request_background); } /// Render options of viewer frames of `comp` at `scale`. @@ -152,15 +158,17 @@ impl EffectcraftApp { } } - fn request_frame_with(&self, comp: ItemId, frame: i64, scale: f64, urgent: bool) { + fn request_frame_with( + &self, + comp: ItemId, + frame: i64, + scale: f64, + request: fn(&frames::Frames, &frames::RenderSource, FrameKey, ItemId, Tick, RenderOpts), + ) { let Some(c) = self.session.project.comp(comp) else { return }; let key = self.frame_key(comp, frame, scale); let t = c.frame_rate.tick_of(frame); let opts = self.frame_opts(comp, scale); - if urgent { - self.frames.request_urgent(&self.render_source(), key, comp, t, opts); - } else { - self.frames.request(&self.render_source(), key, comp, t, opts); - } + request(&self.frames, &self.render_source(), key, comp, t, opts); } } diff --git a/crates/ui-egui/src/app/window.rs b/crates/ui-egui/src/app/window.rs index 10a53f1d2..7326b9f32 100644 --- a/crates/ui-egui/src/app/window.rs +++ b/crates/ui-egui/src/app/window.rs @@ -56,6 +56,18 @@ impl EffectcraftApp { &mut self.dialog_state.gradient } + /// The open settings dialog's draft and Preview state (`panels::dialogs`): Solid, Camera, + /// Light and Composition Settings keep what OK would apply here. + pub fn dialog_state(&self) -> &panels::dialogs::DialogState { + &self.dialog_state + } + + /// The open settings dialog's draft, to edit (tests and automation; the dialog's own fields + /// do the same). + pub fn dialog_state_mut(&mut self) -> &mut panels::dialogs::DialogState { + &mut self.dialog_state + } + /// Show panel `p` (opening it in its usual place if it is closed), bring it to the front and /// give it the focus: Window ▸ ``. pub fn show_panel(&mut self, p: PanelKind) { diff --git a/crates/ui-egui/src/audio.rs b/crates/ui-egui/src/audio.rs index 7972e6942..fbfe4b8af 100644 --- a/crates/ui-egui/src/audio.rs +++ b/crates/ui-egui/src/audio.rs @@ -532,7 +532,7 @@ mod tests { let item = p.add_item("long.flac", Label::SeaFoam, None, ItemKind::Footage(footage)); let mut comp = Comp::new(64, 36, FrameRate::new(30, 1), Tick::from_seconds_f64(10.0)); let layer = build::layer(&mut p, &comp, "long.flac", LayerSource::Footage { item }, (0, 0), None); - comp.layers = vec![layer]; + comp.layers = vec![layer].into(); let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.into())); let src = RenderSource { project: Arc::new(p), diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index e535cc723..f67591bee 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -9,7 +9,7 @@ //! - `ui.inspect`: UI state + summary; `ui.elements {prefix?}`: registered widgets (id, label, rect) //! - `ui.set {tool?, workspace?, theme?, focused?, viewer?: {zoom, res, pan, grid, …}, timeline?: {pps, start, graphEditor, showModes}}` //! - `ui.panel.show {panel}` / `ui.panel.close {panel}` -//! - `ui.click {id | x,y, button?, count?, modifiers?}` / `ui.move {x,y}` / `ui.scroll {x,y,dx,dy}` +//! - `ui.click {id | x,y, button?, count?, modifiers?}` / `ui.move {x,y}` / `ui.scroll {x,y,dx,dy,unit?,source?,pinch?}` //! - `ui.drag {from:{id|x,y}, to:{id|x,y}, steps?, modifiers?}`: synthetic press-move-release //! - `ui.key {key, command?, shift?, alt?, ctrl?}` / `ui.type {text}` //! - `ui.viewer.locate {x, y}`: screen point of a comp pixel; `ui.viewer.hit {x, y}`: comp pixel at a screen point @@ -500,8 +500,9 @@ fn drag(app: &mut EffectcraftApp, p: &Value) -> Outcome { Outcome::AfterInput } -/// Queues a move to the target point, a wheel event of `dx`, `dy` points with the request's -/// modifiers, and a released Extra1 button. Replies [`Outcome::AfterInput`]. +/// Queues a move to the target point, a wheel event of `dx`, `dy` (`unit`: points or lines) with +/// the request's modifiers, a pinch's zoom factor, and a released Extra1 button. Replies +/// [`Outcome::AfterInput`]. fn scroll(app: &mut EffectcraftApp, p: &Value) -> Outcome { let pos = match point(app, p) { Ok(p) => p, @@ -509,14 +510,23 @@ fn scroll(app: &mut EffectcraftApp, p: &Value) -> Outcome { }; let dx = p.get("dx").and_then(Value::as_f64).unwrap_or(0.0) as f32; let dy = p.get("dy").and_then(Value::as_f64).unwrap_or(0.0) as f32; + let line = p.get("unit").and_then(Value::as_str) == Some("line"); + let unit = if line { egui::MouseWheelUnit::Line } else { egui::MouseWheelUnit::Point }; + // Without a `source`, a scroll in lines is a mouse wheel and one in points comes from an + // unnamed device, as on Windows, Linux and the web. + let source = match p.get("source").and_then(Value::as_str) { + Some("trackpad") => egui::MouseWheelSource::Trackpad, + Some("wheel") => egui::MouseWheelSource::Wheel, + _ if line => egui::MouseWheelSource::Wheel, + _ => egui::MouseWheelSource::Unknown, + }; app.synthetic.push(egui::Event::PointerMoved(pos)); - app.synthetic.push(egui::Event::MouseWheel { - unit: egui::MouseWheelUnit::Point, - delta: egui::vec2(dx, dy), - modifiers: modifiers(p), - phase: egui::TouchPhase::Move, - source: egui::MouseWheelSource::Wheel, - }); + if dx != 0.0 || dy != 0.0 { + app.synthetic.push(egui::Event::MouseWheel { unit, delta: egui::vec2(dx, dy), modifiers: modifiers(p), phase: egui::TouchPhase::Move, source }); + } + if let Some(pinch) = p.get("pinch").and_then(Value::as_f64).filter(|z| z.is_finite() && *z > 0.0) { + app.synthetic.push(egui::Event::Zoom(pinch as f32)); + } app.synthetic.push(egui::Event::PointerButton { pos: egui::pos2(-100.0, -100.0), button: egui::PointerButton::Extra1, diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs index 6704017b9..f2dc3975d 100644 --- a/crates/ui-egui/src/credits.rs +++ b/crates/ui-egui/src/credits.rs @@ -246,7 +246,7 @@ pub fn contributors_ui(ui: &mut egui::Ui, auto: &mut Registry) { ui.horizontal_wrapped(|ui| { ui.label(tr("Show")); for m in NameMode::ALL { - let r = ui.selectable_label(v.names == m, m.label()); + let r = ui.selectable_label(v.names == m, tr(m.label())); auto.add(&format!("about.credits.show.{}", m.id()), r.rect, m.label()); if r.clicked() { v.names = m; @@ -254,9 +254,9 @@ pub fn contributors_ui(ui: &mut egui::Ui, auto: &mut Registry) { } ui.separator(); ui.label(tr("Sort")); - let combo = craft_ui::ComboBox::from_id_salt("credits_sort").selected_text(v.key.label().0).show_ui(ui, |ui| { + let combo = craft_ui::ComboBox::from_id_salt("credits_sort").selected_text(tr(v.key.label().0)).show_ui(ui, |ui| { for k in SortKey::ALL { - let r = ui.selectable_label(v.key == k, k.label().0); + let r = ui.selectable_label(v.key == k, tr(k.label().0)); auto.add(&format!("about.credits.sort.{}", k.id()), r.rect, k.label().0); if r.clicked() { v.key = k; @@ -309,7 +309,7 @@ fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View, auto: &mut Regi egui::Grid::new("credits_table").striped(true).num_columns(SortKey::ALL.len()).show(ui, |ui| { for k in SortKey::ALL { let arrow = if v.key == k { if v.ascending { " ▲" } else { " ▼" } } else { "" }; - let r = ui.button(RichText::new(format!("{}{arrow}", k.label().1)).strong()).on_hover_text(k.label().0); + let r = ui.button(RichText::new(format!("{}{arrow}", k.label().1)).strong()).on_hover_text(tr(k.label().0)); auto.add(&format!("about.credits.header.{}", k.id()), r.rect, k.label().0); if r.clicked() { if v.key == k { diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 054aa3598..8e8fd5eca 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -531,7 +531,8 @@ pub enum DocAction { const TAB_PADDING: f32 = 24.0; /// Width of the » button that lists the comp tabs with no room in the strip. const DOC_OVERFLOW: f32 = 22.0; -/// Room for the × after the shown comp's name. +/// Room for the × after a comp's name. Every comp tab keeps it, so a tab keeps its width when it +/// is shown or hovered. const DOC_CLOSE: f32 = 16.0; /// A comp tab's text: the label swatch, then the name, as the panel tabs with a swatch show it. @@ -547,24 +548,46 @@ fn text_width(ctx: &egui::Context, job: egui::text::LayoutJob) -> f32 { ctx.fonts_mut(|f| f.layout_job(job)).size().x } -/// Whether comp tab `doc` carries the ×: the comp the Timeline shows, while it is the group's -/// shown panel. -fn has_close(doc: &DocTab, selected: bool) -> bool { - selected && doc.active +/// The width each comp tab asks for in the Timeline group's tab row. +pub fn doc_widths(ctx: &egui::Context, docs: &[DocTab], font: &egui::FontId) -> Vec { + docs.iter().map(|d| text_width(ctx, doc_job(d, font, egui::Color32::WHITE)) + TAB_PADDING + DOC_CLOSE).collect() } -/// The width each comp tab asks for in the Timeline group's tab row. -pub fn doc_widths(ctx: &egui::Context, docs: &[DocTab], selected: bool, font: &egui::FontId) -> Vec { - docs.iter().map(|d| text_width(ctx, doc_job(d, font, egui::Color32::WHITE)) + TAB_PADDING + if has_close(d, selected) { DOC_CLOSE } else { 0.0 }).collect() +/// The × of comp tab `doc` at `rect`, clipped to `clip`; true when clicked. `shown` is the comp +/// the Timeline shows while it is the group's shown panel. Automation: +/// `panel.tab.Timeline..close`, and `panel.tab.Timeline.close` for the shown comp's. +fn doc_close( + ui: &mut egui::Ui, + rect: Rect, + clip: Rect, + doc: &DocTab, + shown: bool, + style: &craft_ui::docking::DockStyle, + reg: &mut crate::automation::Registry, +) -> bool { + let timeline = PanelKind::Timeline.id(); + let hit = ui.interact(rect.expand(2.0), egui::Id::unique(("comp-tab-close", doc.id)), Sense::click()); + hit.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, hit.enabled(), "Close")); + let cc = if hit.hovered() { style.text } else { style.inactive_text }; + let k = 3.5; + let painter = ui.painter().with_clip_rect(clip); + painter.line_segment([rect.center() + vec2(-k, -k), rect.center() + vec2(k, k)], Stroke::new(1.2, cc)); + painter.line_segment([rect.center() + vec2(-k, k), rect.center() + vec2(k, -k)], Stroke::new(1.2, cc)); + reg.add_response(&format!("panel.tab.{timeline}.{}.close", doc.id), &hit, "Close"); + if shown { + reg.add_response(&format!("panel.tab.{timeline}.close"), &hit, "Close"); + } + hit.clicked() } /// Draw the Timeline's comp tabs into `strip`, the part of the Timeline group's tab row after its -/// panel tabs. Each is a tab like the panel tabs: the comp's label swatch and name, and × on the -/// shown comp while the Timeline is the group's shown panel (`selected`). Tabs that do not fit go -/// to a » menu at the end; the shown comp's tab always stays in the row. Shift+, and Shift+. step -/// through every open comp. Automation: `panel.tab.Timeline.`, `panel.tab.Timeline.close` -/// for the shown comp's ×, `panel.tab.Timeline.more` for », and `panel.tab.Timeline.more.` -/// for its items. +/// panel tabs. Each is a tab like the panel tabs: the comp's label swatch and name, then a × +/// that closes the comp's Timeline: always on the shown comp while the Timeline is the group's +/// shown panel (`selected`), on the other comp tabs while the pointer is over them. Tabs that do +/// not fit go to a » menu at the end; the shown comp's tab always stays in the row. Shift+, and +/// Shift+. step through every open comp. Automation: `panel.tab.Timeline.`, the ×s (see +/// [`doc_close`]), `panel.tab.Timeline.more` for », and `panel.tab.Timeline.more.` for its +/// items. pub fn doc_tabs( ui: &mut egui::Ui, strip: Rect, @@ -575,7 +598,7 @@ pub fn doc_tabs( ) -> Vec { let mut out = Vec::new(); let timeline = PanelKind::Timeline.id(); - let widths = doc_widths(ui.ctx(), docs, selected, &style.font); + let widths = doc_widths(ui.ctx(), docs, &style.font); let active = docs.iter().position(|d| d.active).unwrap_or(0); let fit = craft_ui::tabs::fit_whole(&widths, active, strip.width(), DOC_OVERFLOW); let clip = strip.intersect(ui.clip_rect()); @@ -587,10 +610,9 @@ pub fn doc_tabs( x += width; let on = selected && doc.active; let color = if on { style.text } else { style.inactive_text }; - let close = has_close(doc, selected); let mut job = doc_job(doc, &style.font, color); job.wrap = egui::text::TextWrapping { - max_width: (tab.width() - TAB_PADDING - if close { DOC_CLOSE } else { 0.0 }).max(0.0), + max_width: (tab.width() - TAB_PADDING - DOC_CLOSE).max(0.0), max_rows: 1, break_anywhere: true, overflow_character: Some('\u{2026}'), @@ -608,17 +630,10 @@ pub fn doc_tabs( ); let resp = if galley.elided { resp.on_hover_text(&doc.title) } else { resp }; reg.add_response(&format!("panel.tab.{timeline}.{}", doc.id), &resp, &doc.title); - if close { + // After the tab, so the × takes the clicks over it. + if on || ui.rect_contains_pointer(tab.intersect(clip)) { let cr = Rect::from_center_size(pos2(label_end + DOC_CLOSE / 2.0, mid), vec2(10.0, 10.0)); - let hit = ui.interact(cr.expand(2.0), egui::Id::unique(("comp-tab-close", doc.id)), Sense::click()); - hit.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, hit.enabled(), "Close")); - let cc = if hit.hovered() { style.text } else { style.inactive_text }; - let k = 3.5; - let painter = ui.painter().with_clip_rect(tab.intersect(clip)); - painter.line_segment([cr.center() + vec2(-k, -k), cr.center() + vec2(k, k)], Stroke::new(1.2, cc)); - painter.line_segment([cr.center() + vec2(-k, k), cr.center() + vec2(k, -k)], Stroke::new(1.2, cc)); - reg.add_response(&format!("panel.tab.{timeline}.close"), &hit, "Close"); - if hit.clicked() { + if doc_close(ui, cr, tab.intersect(clip), doc, on, style, reg) { out.push(DocAction::Close(doc.id)); } } @@ -654,7 +669,8 @@ pub fn doc_tabs( out } -/// A viewer lock button (Composition viewers and the Timeline keep their comp while locked). +/// A viewer lock button (Composition viewers and the Timeline keep their comp while locked): a +/// closed padlock while locked, an open one while unlocked. /// Returns true when clicked. Automation: `panel.tab..lock`. pub fn lock_button(ui: &mut egui::Ui, rect: Rect, panel: PanelKind, locked: bool, t: &Tokens, reg: &mut crate::automation::Registry) -> bool { let resp = ui.interact(rect, egui::Id::unique(("tab-lock", panel.id())), Sense::click()); @@ -665,7 +681,7 @@ pub fn lock_button(ui: &mut egui::Ui, rect: Rect, panel: PanelKind, locked: bool } else { t.text_faint }; - icons::paint(ui.painter(), rect.shrink(2.0), Icon::Lock, color); + icons::paint(ui.painter(), rect.shrink(2.0), if locked { Icon::Lock } else { Icon::LockOpen }, color); reg.add_response(&format!("panel.tab.{}.lock", panel.id()), &resp, if locked { "Unlock" } else { "Lock" }); crate::widgets::focus_outline(ui, &resp, t); resp.clicked() diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index cf180eec0..483dcf4bf 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -799,7 +799,11 @@ impl DockContent for EffectContent<'_> { } fn logical_label(&self, panel: &PanelKind) -> String { - crate::i18n::command_label("window.panel", panel.title()).to_string() + let title = panel.title(); + // The Window menu names most panels; `Markers` is only in the panel catalog, and the two + // panels in neither (`History`, `ScriptUI Panel`) keep their English names. + let listed = crate::i18n::command_label("window.panel", title); + if listed == title { crate::i18n::tr(title).to_string() } else { listed.to_string() } } /// The tab names what the panel shows ("Composition Intro"), after the label swatch of its @@ -843,11 +847,11 @@ impl DockContent for EffectContent<'_> { } } - fn group_extra_tabs_width(&self, panels: &[PanelKind], active: &PanelKind, _floating: bool) -> f32 { + fn group_extra_tabs_width(&self, panels: &[PanelKind], _active: &PanelKind, _floating: bool) -> f32 { if !self.has_doc_tabs(panels) { return 0.0; } - dock::doc_widths(&self.ctx, &self.docs, *active == PanelKind::Timeline, &self.style.font).iter().sum() + dock::doc_widths(&self.ctx, &self.docs, &self.style.font).iter().sum() } /// The Timeline's comp tabs, after its group's panel tabs whichever panel is shown. diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index b524b5959..883cbf172 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -1,7 +1,8 @@ //! Background frame rendering and the RAM preview cache. //! //! Frames render on a dedicated thread pool from immutable `Arc` snapshots, so the UI -//! never waits for the compositor. Results land in a memory-budgeted cache keyed by the comp's +//! never waits for the compositor (a playing preview's frames and idle caching on one thread +//! each, so the frames started first are done first). Results land in a memory-budgeted cache keyed by the comp's //! *content* ([`comp_content`]: the comp and everything it uses), frame, scale, view and render //! options; the timeline draws the cached range as the green cache bar. An edit keeps the frames //! of every comp it doesn't touch, and undo finds the frames of the state it returns to. @@ -456,6 +457,10 @@ pub struct Frames { #[cfg(not(target_arch = "wasm32"))] /// None if the OS refused the threads: jobs then go to rayon's global pool. pool: Option, + #[cfg(not(target_arch = "wasm32"))] + /// One thread each, for a playing preview's frames and idle caching (see [`Queue::idle_lanes`]). Empty if + /// the OS refused the threads: those frames then render on `pool` too. + lanes: Vec, ctx: Option, /// Render time of the last viewer (urgent) frame in ms, for the Info panel / perf readout. pub last_ms: Arc>, @@ -484,10 +489,25 @@ impl Default for Frames { fn default() -> Self { #[cfg(not(target_arch = "wasm32"))] let threads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(2, 16); + #[cfg(not(target_arch = "wasm32"))] + let lanes: Vec = (0..threads) + .map_while(|i| { + rayon::ThreadPoolBuilder::new() + .num_threads(1) + .thread_name(move |_| format!("ec-preview-{i}")) + .panic_handler(|_| log::error!("a frame render panicked; that frame was skipped")) + .build() + .ok() + }) + .collect(); + #[cfg(not(target_arch = "wasm32"))] + let queue = Queue { idle_lanes: (0..lanes.len()).collect(), lanes: lanes.len(), ..Default::default() }; + #[cfg(target_arch = "wasm32")] + let queue = Queue::default(); 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, playhead: None, scale: None })), inflight: Arc::new(Mutex::new(HashSet::new())), - queue: Arc::new(Mutex::new(Queue::default())), + queue: Arc::new(Mutex::new(queue)), #[cfg(not(target_arch = "wasm32"))] // A panicking job would abort the whole process (rayon's default for `spawn`): log it // and lose only that frame (the panic hook has logged the message). @@ -497,6 +517,8 @@ impl Default for Frames { .panic_handler(|_| log::error!("a frame render panicked; that frame was skipped")) .build() .ok(), + #[cfg(not(target_arch = "wasm32"))] + lanes, ctx: None, last_ms: Arc::new(Mutex::new(0.0)), content_keys: Arc::default(), @@ -911,26 +933,77 @@ impl Frames { c.playhead = playhead; } let Ok(mut q) = self.queue.lock() else { return }; + // A preview starting or stopping moves the queued prefetch frames between the lanes and + // the pool (see [`Queue::idle_lanes`]). + #[cfg(not(target_arch = "wasm32"))] + let moved = q.playhead.is_some() != playhead.is_some(); 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); + if let Some(p) = playhead + && let Ok(mut inf) = self.inflight.lock() + { + 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 + }); + } + #[cfg(not(target_arch = "wasm32"))] + if moved { + self.start_lanes(&mut q, usize::MAX); + let pool_jobs = q.jobs.iter().filter(|j| !q.on_lane(j)).count().min(self.parallelism()); + drop(q); + for _ in 0..pool_jobs { + self.start_pool(); } - !gone - }); + } + } + + /// Start up to `n` idle lanes on the queued jobs (see [`Queue::idle_lanes`]). + #[cfg(not(target_arch = "wasm32"))] + fn start_lanes(&self, q: &mut Queue, n: usize) { + if self.remote.is_some() { + return; + } + let jobs = q.jobs.iter().filter(|j| q.on_lane(j)).count(); + for _ in 0..n.min(jobs) { + let Some(lane) = q.idle_lanes.pop() else { break }; + let Some(pool) = self.lanes.get(lane) else { break }; + let w = self.worker(); + pool.spawn(move || w.drain(Some(lane))); + } + } + + /// Have a pool thread render the queued jobs that are the pool's. + #[cfg(not(target_arch = "wasm32"))] + fn start_pool(&self) { + if self.remote.is_some() { + return; + } + let w = self.worker(); + let job = move || w.drain(None); + match &self.pool { + Some(pool) => pool.spawn(job), + None => rayon::spawn(job), + } } /// 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); + self.request_with(src, key, comp, t, opts, Want::Prefetch); + } + + /// Queue a frame nobody waits for (Cache Frames When Idle): as [`Frames::request`], but it + /// renders on a lane, one thread, and leaves the pool to the viewer's frame (see + /// [`Queue::idle_lanes`]). + pub fn request_background(&self, src: &RenderSource, key: FrameKey, comp: ItemId, t: Tick, opts: RenderOpts) { + self.request_with(src, key, comp, t, opts, Want::Background); } /// Queue the frame the viewer is showing: it jumps ahead of every prefetch job. pub fn request_urgent(&self, src: &RenderSource, key: FrameKey, comp: ItemId, t: Tick, opts: RenderOpts) { - self.request_with(src, key, comp, t, opts, true); + self.request_with(src, key, comp, t, opts, Want::Viewer); } /// An urgent (viewer) frame is queued or rendering. @@ -938,7 +1011,8 @@ impl Frames { self.queue.lock().map(|q| q.urgent_running > 0 || q.jobs.iter().any(|j| j.urgent)).unwrap_or(false) } - fn request_with(&self, src: &RenderSource, key: FrameKey, comp: ItemId, t: Tick, opts: RenderOpts, urgent: bool) { + fn request_with(&self, src: &RenderSource, key: FrameKey, comp: ItemId, t: Tick, opts: RenderOpts, want: Want) { + let urgent = want == Want::Viewer; // The frame on screen sets the scale evictions keep: a prefetch or a passive viewer at // another zoom must not evict the active viewer's frames. let cached = self @@ -990,22 +1064,32 @@ impl Frames { } } if !inf.insert(key) { + drop(inf); + drop(q); + // Queued for a lane and now the viewer's frame: it is the pool's. + #[cfg(not(target_arch = "wasm32"))] + if urgent { + self.start_pool(); + } return; } q.seq += 1; let seq = q.seq; - q.jobs.push(Job { key, src: src.clone(), comp, t, opts, urgent, seq, cancel: Arc::default() }); + let job = Job { key, src: src.clone(), comp, t, opts, urgent, background: want == Want::Background, seq, cancel: Arc::default() }; + // A lane's frame: a busy lane takes the next job when it is done, an idle one starts now. + #[cfg(not(target_arch = "wasm32"))] + let on_lane = q.on_lane(&job); + q.jobs.push(job); + #[cfg(not(target_arch = "wasm32"))] + if on_lane { + self.start_lanes(&mut q, 1); + } drop(inf); drop(q); + // One spawn per job; each spawn renders whichever queued jobs matter most right now. #[cfg(not(target_arch = "wasm32"))] - if self.remote.is_none() { - let w = self.worker(); - // One spawn per job; each spawn renders whichever queued jobs matter most right now. - let job = move || w.drain(); - match &self.pool { - Some(pool) => pool.spawn(job), - None => rayon::spawn(job), - } + if !on_lane { + self.start_pool(); } #[cfg(target_arch = "wasm32")] if let Some(ctx) = &self.ctx { @@ -1147,7 +1231,7 @@ impl Frames { } let t0 = web_time::Instant::now(); let w = self.worker(); - while w.run_next() { + while w.run_next(None) { if t0.elapsed() >= budget { break; } @@ -1195,22 +1279,29 @@ impl Worker { /// every UI frame, so renders piled up on every thread until memory ran out (#647). A spawn /// that lands inside a render returns at once instead; the render below it takes the queued /// jobs when it is done, so each thread renders one frame at a time. + /// + /// `lane`: the lane this runs on (see [`Queue::idle_lanes`]), `None` on the pool. #[cfg(not(target_arch = "wasm32"))] - fn drain(&self) { + fn drain(&self, lane: Option) { if DRAINING.get() { return; } DRAINING.set(true); let _reset = Draining; - while self.run_next() {} + while self.run_next(lane) {} } - /// Render the queued job that matters most right now. `false` when the queue was empty. - fn run_next(&self) -> bool { + /// Render the queued job that matters most right now, on lane `lane` (`None`: on + /// the pool, or the UI thread). `false` when the queue had no job for it; the lane is then + /// idle again. + fn run_next(&self, lane: Option) -> bool { let Worker { queue, content_keys, .. } = self; let job = { let Ok(mut q) = queue.lock() else { return false }; - let Some(i) = q.best() else { return false }; + let Some(i) = q.next(lane.is_some()) else { + q.idle_lanes.extend(lane); + return false; + }; let job = q.jobs.swap_remove(i); q.running.insert(job.key); if job.urgent { @@ -1405,6 +1496,14 @@ impl Worker { } } +/// Who asked for a frame (see [`Frames::request_with`]). +#[derive(Clone, Copy, PartialEq, Eq)] +enum Want { + Viewer, + Prefetch, + Background, +} + struct Job { key: FrameKey, src: RenderSource, @@ -1412,6 +1511,8 @@ struct Job { t: Tick, opts: RenderOpts, urgent: bool, + /// Nobody waits for it (see [`Frames::request_background`]). + background: bool, seq: u64, /// Set when the viewer no longer waits for this frame (see [`Frames::request_urgent`]). cancel: Arc, @@ -1434,18 +1535,45 @@ struct Queue { seq: u64, /// The playing preview, if any: its prefetch renders nearest first. playhead: Option, + /// How many lanes there are, and the ones not rendering. A lane is one thread that renders + /// one frame at a time. The frames of a playing preview and the frames nobody waits for + /// (Cache Frames When Idle) render on the lanes; the pool keeps the viewer's frame and the + /// few frames prefetched around it while paused. On the pool a frame's parallel work is + /// shared: a thread that took a piece of one frame and waited inside it started rendering + /// the queued frames on top, and went back to its piece only once the queue was empty. The + /// frames a preview started first, the ones right ahead of the playhead, were done last: + /// the preview stood still on a gap while the frames further ahead turned green. Idle + /// caching left the same gaps, and held up the viewer's frame when it shared a piece. + lanes: usize, + idle_lanes: Vec, } impl Queue { + /// Whether `j` renders on a lane (else on the pool). + fn on_lane(&self, j: &Job) -> bool { + self.lanes > 0 && !j.urgent && (j.background || self.playhead.is_some()) + } + + /// The job a lane (`lane`) or the pool renders next: the [`Queue::best`] of its own. + fn next(&self, lane: bool) -> Option { + self.best_of(|j| self.on_lane(j) == lane) + } + /// 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 { + self.best_of(|_| true) + } + + /// [`Queue::best`] among the jobs `of` accepts. + fn best_of(&self, of: impl Fn(&Job) -> bool) -> Option { let p = self.playhead.as_ref(); self.jobs .iter() .enumerate() + .filter(|(_, j)| of(j)) .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) }) diff --git a/crates/ui-egui/src/frames/tests.rs b/crates/ui-egui/src/frames/tests.rs index a2603dfec..49f77a6cf 100644 --- a/crates/ui-egui/src/frames/tests.rs +++ b/crates/ui-egui/src/frames/tests.rs @@ -437,7 +437,9 @@ fn a_newer_viewer_frame_drops_the_ones_still_waiting() { gpu_display: false, disk: None, }; - let request = |k: FrameKey, urgent: bool| f.request_with(&src, k, ItemId(k.comp), Tick::ZERO, RenderOpts::default(), urgent); + let request = |k: FrameKey, urgent: bool| { + f.request_with(&src, k, ItemId(k.comp), Tick::ZERO, RenderOpts::default(), if urgent { Want::Viewer } else { Want::Prefetch }) + }; let other = FrameKey { comp: 2, ..key(1, 0, 0) }; request(other, true); request(key(1, 20, 0), false); @@ -558,6 +560,131 @@ fn prefetch_renders_the_frames_the_playhead_reaches_first() { assert_eq!(f.inflight(), 6, "frame 5, behind the playhead, is no longer in flight"); } +/// A decoder that works in parallel, as the renderer does, in a comp that shows it. +#[derive(Default)] +struct ParallelClip { + started: std::sync::atomic::AtomicUsize, + /// Frames started by the time frame 1 was done. + started_at_first: std::sync::atomic::AtomicUsize, +} + +impl FootageSource for ParallelClip { + fn frame(&self, _: ItemId, _: &effectcraft_engine::project::Footage, t: Tick) -> Option> { + self.started.fetch_add(1, Ordering::SeqCst); + (0..16).into_par_iter().for_each(|_| (0..4).into_par_iter().for_each(|_| std::thread::sleep(std::time::Duration::from_millis(1)))); + if effectcraft_engine::time::FrameRate::FPS_30.frame_at(t) == 1 { + self.started_at_first.store(self.started.load(Ordering::SeqCst), Ordering::SeqCst); + } + Some(Arc::new(effectcraft_engine::render::Image::new(4, 4))) + } +} + +/// A 10 s comp of a [`ParallelClip`]: the frame cache, the clip, the render source, the comp +/// and the key of its frame 0. +fn parallel_clip() -> (Frames, Arc, RenderSource, ItemId, FrameKey) { + use effectcraft_engine::Session; + use effectcraft_engine::project::{Footage, FootageKind}; + use effectcraft_engine::time::FrameRate; + use serde_json::json; + 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(ParallelClip::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 series = FrameKey { revision: s.revision, content: f.content_of(&s.project, s.revision, cid), comp: cid.0, frame: 0, scale: 1000, view: 0, opts: 0 }; + (f, footage, src, cid, series) +} + +fn wait_for_renders(f: &Frames) { + for _ in 0..8000 { + if f.inflight() == 0 { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert_eq!(f.inflight(), 0, "every queued frame rendered"); +} + +/// A playing preview's frames render side by side, and each works in parallel: a render +/// waiting for its own parallel work took over pieces of the frames started after it, and +/// its frame was done only once theirs were. The frames right ahead of the playhead, started +/// first, came last: the preview stood still while the frames further ahead turned green. +/// Cache Frames When Idle left the same gaps. +#[test] +fn frames_started_first_are_done_first() { + use effectcraft_engine::time::FrameRate; + for playing in [true, false] { + let (f, footage, src, cid, series) = parallel_clip(); + let frames = 60; + if playing { + f.set_playhead(Some(Playhead { series, cur: 0, start: 0, end: 299, step: 1, looping: false })); + } + for frame in 1..=frames { + let (key, t) = (FrameKey { frame, ..series }, FrameRate::FPS_30.tick_of(frame)); + if playing { + f.request(&src, key, cid, t, RenderOpts::default()); + } else { + f.request_background(&src, key, cid, t, RenderOpts::default()); + } + if frame == 1 { + // Its parallel work has started (and idle threads have taken pieces of it). + std::thread::sleep(std::time::Duration::from_millis(3)); + } + } + wait_for_renders(&f); + assert_eq!(f.cached_frames(&series).len(), frames as usize); + let before = footage.started_at_first.load(Ordering::SeqCst); + assert!(before <= 2 * f.parallelism(), "{before} frames were started before the first one was done (playing: {playing})"); + } +} + +/// Prefetch frames queued when a preview starts or stops, or that become the viewer's +/// frame, still render: they change hands between the lanes and the pool. +#[test] +fn queued_frames_render_when_a_preview_starts_or_stops() { + use effectcraft_engine::time::FrameRate; + let (f, _, src, cid, series) = parallel_clip(); + let playhead = Playhead { series, cur: 0, start: 0, end: 299, step: 1, looping: false }; + let request = |frames: std::ops::Range| { + for frame in frames { + f.request(&src, FrameKey { frame, ..series }, cid, FrameRate::FPS_30.tick_of(frame), RenderOpts::default()); + } + }; + // Queued for the pool, then a preview starts. + request(1..40); + f.set_playhead(Some(playhead)); + // Queued for the lanes, then the preview stops. + request(40..80); + f.set_playhead(None); + wait_for_renders(&f); + // Queued for the lanes, then the viewer shows one of them. + for frame in 80..120 { + f.request_background(&src, FrameKey { frame, ..series }, cid, FrameRate::FPS_30.tick_of(frame), RenderOpts::default()); + } + f.request_urgent(&src, FrameKey { frame: 119, ..series }, cid, FrameRate::FPS_30.tick_of(119), RenderOpts::default()); + wait_for_renders(&f); + assert_eq!(f.cached_frames(&series).len(), 119); +} + /// The cache is full. A zoom (or preview-resolution) change left the frames of the old /// size to the plain least-recently-shown order, so the frames of the zoom the viewer is back /// at went before the ones of the abandoned zoom. The other size's frames go first, then the diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index 3eee89453..8710f1f9a 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -12,6 +12,7 @@ use crate::widgets; use crate::{Dialog, EffectcraftApp}; use effectcraft_color::BlendMode; use effectcraft_engine::commands::shape_tool::{PaintKind, ToolPaint}; +use effectcraft_engine::geom::vec2 as gv2; use effectcraft_engine::project::{LayerId, LayerSource, PropGroup}; use effectcraft_engine::viewer as vw; @@ -93,9 +94,11 @@ fn tool_slot(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, si: )); } app.auto.add_response(&format!("tools.{cur:?}"), &resp, cur.label()); + // The drawn tooltip asks the catalog; the automation label above keeps the English name. + let label = crate::i18n::tr(cur.label()); let tip = match cur.shortcut() { - Some(s) => format!("{} ({})", cur.label(), crate::menus::shortcut_text(s)), - None => cur.label().to_string(), + Some(s) => format!("{} ({})", label, crate::menus::shortcut_text(s)), + None => label.to_string(), }; let resp = resp.on_hover_text(tip); let held = ui.data(|d| d.get_temp::(held_id)) == Some(id); @@ -109,6 +112,11 @@ fn tool_slot(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, si: && let Err(e) = crate::menus::invoke(app, ui.ctx(), "layer.centerAnchor", json!({})) { app.ui.status = e; + } else if resp.double_clicked() + && let Some((id, params)) = create_on_double_click(app, cur) + && let Err(e) = crate::menus::invoke(app, ui.ctx(), id, params) + { + app.ui.status = e; } if slot.len() > 1 { slot_flyout(app, ui, si, slot, &resp, held_id); @@ -139,7 +147,7 @@ fn slot_flyout(app: &mut EffectcraftApp, ui: &mut egui::Ui, si: usize, slot: &[T let close_behavior = if held && resp.clicked() { egui::PopupCloseBehavior::IgnoreClicks } else { egui::PopupCloseBehavior::CloseOnClickOutside }; 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()); + let row = ui.selectable_label(app.ui.tool == *tool, crate::i18n::tr(tool.label())); app.auto.add(&format!("tools.select.{tool:?}"), row.rect, tool.label()); if row.clicked() { app.ui.tool = *tool; @@ -374,6 +382,58 @@ fn workspace_tabs(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter } } +/// Double-clicking a creation tool in the Tools bar makes an instance of what it draws, as in +/// After Effects (#1072): a text layer from the Type tools; from the shape tools, a shape that +/// fills the comp — into the selected shape layer's Contents, as a mask on the selected layer +/// (any non-shape layer, or Tool Creates Mask), or as a new shape layer when neither applies. +fn create_on_double_click(app: &EffectcraftApp, tool: Tool) -> Option<(&'static str, serde_json::Value)> { + match tool { + // `invoke` turns empty params into an insertion point in the comp centre that starts + // editing (#866), which is what double-clicking the Type tool should do. + Tool::Type | Tool::TypeVertical => Some(("layer.newText", json!({}))), + Tool::Rectangle | Tool::RoundedRect | Tool::Ellipse | Tool::Polygon | Tool::Star => { + let kind = match tool { + Tool::Rectangle => "rect", + Tool::RoundedRect => "rounded", + Tool::Ellipse => "ellipse", + Tool::Polygon => "polygon", + _ => "star", + }; + // Without a comp the command still runs and reports its error on the status line. + let Some(comp) = app.session.active_comp() else { + return Some(("shape.newShape", json!({"kind": kind}))); + }; + let (cw, ch) = (comp.width as f64, comp.height as f64); + // Rectangles and ellipses fill the comp; a Polystar's Outer Radius is half the comp + // height (Inner Radius: a quarter of it), so it gets a comp-height square. + let (w, h) = if matches!(tool, Tool::Polygon | Tool::Star) { (ch, ch) } else { (cw, ch) }; + // A mask on the selected layer: any non-shape layer (the tool switches to Mask by + // itself), or a shape layer while Tool Creates Mask is on — as dragging does. + let sel = app.session.state.selected_layers.first().copied().and_then(|id| comp.layer(id)); + if let Some(l) = sel.filter(|l| l.source.is_av() && (app.session.state.shape_tool.creates_mask || !matches!(l.source, LayerSource::Shape))) { + // The box in layer space, so a moved, scaled or rotated layer still gets a + // comp-filling mask: all four corners are mapped, and their bounds are the + // smallest axis-aligned box around the comp box. + let ctx = effectcraft_engine::render::EvalCtx::new(&app.session.project, app.session.active_comp_id()?, comp, app.session.time()); + let inv = ctx.layer_to_comp(l).0.inverse().unwrap_or(effectcraft_engine::geom::Mat3::IDENTITY); + let (x0, y0, x1, y1) = (cw / 2.0 - w / 2.0, ch / 2.0 - h / 2.0, cw / 2.0 + w / 2.0, ch / 2.0 + h / 2.0); + let pts = [gv2(x0, y0), gv2(x1, y0), gv2(x1, y1), gv2(x0, y1)].map(|p| inv.apply(p)); + let (mut lo, mut hi) = ([f64::INFINITY; 2], [f64::NEG_INFINITY; 2]); + for p in pts { + lo = [lo[0].min(p.x), lo[1].min(p.y)]; + hi = [hi[0].max(p.x), hi[1].max(p.y)]; + } + let rect = [lo[0], lo[1], hi[0] - lo[0], hi[1] - lo[1]]; + return Some(("layer.addMask", json!({"layer": l.id.0, "shape": kind, "rect": rect}))); + } + // Into the selected shape layer's Contents, else a new shape layer (the engine's own + // target), painted with the Tools bar's Fill and Stroke. + Some(("shape.newShape", json!({"kind": kind, "size": [w, h]}))) + } + _ => None, + } +} + /// The Snapping options menu beside the checkbox: After Effects' Snap Edges Extended and Snap to /// Features in Collapsed Compositions and Text Layers (`view.snappingOptions`). fn snapping_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, r: Rect) { @@ -834,4 +894,254 @@ mod tests { frame(&mut app, &ctx, 2.1, vec![]); assert!(!app.auto.elements.iter().any(|w| w.id == "tools.select.TypeVertical")); } + + /// Double-clicking a creation tool in the Tools bar makes an instance of what it draws + /// (#1072): with nothing selected a shape layer whose shape fills the comp, into the selected + /// shape layer's Contents, a mask on a non-shape layer, a text layer from the Type tools. + /// Tools that create nothing stay quiet. + #[test] + fn double_clicking_creation_tools_makes_instances() { + let mut s = effectcraft_engine::Session::default(); + s.execute("comp.new", json!({"width": 640, "height": 360, "duration": 2})).unwrap(); + let mut app = EffectcraftApp::new(s); + let ctx = egui::Context::default(); + crate::theme::install(&ctx, &app.tokens, crate::i18n::language(&app)); + frame(&mut app, &ctx, 0.0, vec![]); + frame(&mut app, &ctx, 0.1, vec![]); + let pointer = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + // Both click pairs in one frame, as egui counts a double-click. + let double_click = |app: &mut EffectcraftApp, ctx: &egui::Context, t: f64, id: &str| { + let e = app.auto.elements.iter().find(|w| w.id == id).unwrap_or_else(|| panic!("no {id}")); + let at = egui::pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0); + frame(app, ctx, t, vec![egui::Event::PointerMoved(at), pointer(at, true), pointer(at, false), pointer(at, true), pointer(at, false)]); + }; + // Nothing selected: a shape layer with a Rectangle Path 1 the size of the comp. + double_click(&mut app, &ctx, 1.0, "tools.Rectangle"); + { + let comp = app.session.active_comp().unwrap(); + assert_eq!(comp.layers.len(), 1, "the double-click made a layer"); + let l = &comp.layers[0]; + assert!(matches!(l.source, LayerSource::Shape), "a shape layer"); + let size = l.props.prop("contents/Rectangle 1/contents/Rectangle Path 1/size").expect("Rectangle Path 1"); + assert_eq!(size.value.as_vec2(), [640.0, 360.0], "the rectangle fills the comp"); + } + // The shape layer is selected: the Ellipse goes on top of its Contents, no new layer. + app.ui.slot_tools[8] = Tool::Ellipse; + frame(&mut app, &ctx, 1.05, vec![]); + double_click(&mut app, &ctx, 2.0, "tools.Ellipse"); + { + let comp = app.session.active_comp().unwrap(); + assert_eq!(comp.layers.len(), 1, "into the selected shape layer's Contents"); + let l = &comp.layers[0]; + assert!(l.props.prop("contents/Ellipse 1/contents/Ellipse Path 1/size").is_some(), "the new ellipse is on top"); + assert!(l.props.prop("contents/Rectangle 1/contents/Rectangle Path 1/size").is_some(), "the rectangle still stands"); + } + // The Type tool: a text layer above the selection, editing started (an insertion point, #866). + double_click(&mut app, &ctx, 3.0, "tools.Type"); + { + let comp = app.session.active_comp().unwrap(); + assert_eq!(comp.layers.len(), 2); + assert!(matches!(comp.layers[0].source, LayerSource::Text)); + assert!(app.session.state.text_edit.is_some(), "the text layer is being edited"); + } + // A non-shape layer is selected, so the Rectangle becomes a comp-filling mask on it. + app.ui.slot_tools[8] = Tool::Rectangle; + frame(&mut app, &ctx, 3.05, vec![]); + double_click(&mut app, &ctx, 4.0, "tools.Rectangle"); + { + let comp = app.session.active_comp().unwrap(); + assert_eq!(comp.layers.len(), 2, "a mask, not a layer"); + let p = comp.layers[0].props.prop("masks/Mask 1/path").expect("Mask 1"); + let sh = p.value.as_path().expect("a mask path"); + let b = effectcraft_path::bounds(&[effectcraft_path::to_kurbo(sh)]).expect("bounds"); + // The text layer is anchored top-left and centred in the comp: layer = comp - centre. + for (got, want) in [(b.x0, -320.0), (b.y0, -180.0), (b.x1, 320.0), (b.y1, 180.0)] { + assert!((got - want).abs() < 1.0, "the mask fills the comp: {got} vs {want}"); + } + } + // A tool that creates nothing: no layer, no error. + double_click(&mut app, &ctx, 5.0, "tools.Hand"); + assert_eq!(app.session.active_comp().unwrap().layers.len(), 2, "the Hand tool creates nothing"); + assert!(app.ui.status.is_empty(), "{}", app.ui.status); + // Without a comp the command fails with the status line, never a panic. + let mut bare = EffectcraftApp::new(effectcraft_engine::Session::default()); + let ctx = egui::Context::default(); + crate::theme::install(&ctx, &bare.tokens, crate::i18n::language(&bare)); + frame(&mut bare, &ctx, 0.0, vec![]); + frame(&mut bare, &ctx, 0.1, vec![]); + double_click(&mut bare, &ctx, 1.0, "tools.Rectangle"); + assert!(!bare.ui.status.is_empty(), "no comp: a status message, not a crash"); + } + + /// #1765: a double-click on the Ellipse tool with nothing selected makes its own shape layer + /// the size of the comp, as the Rectangle does. + #[test] + fn double_click_ellipse_with_no_selection_makes_a_layer() { + let mut s = effectcraft_engine::Session::default(); + s.execute("comp.new", json!({"width": 640, "height": 360, "duration": 2})).unwrap(); + let mut app = EffectcraftApp::new(s); + let ctx = egui::Context::default(); + crate::theme::install(&ctx, &app.tokens, crate::i18n::language(&app)); + frame(&mut app, &ctx, 0.0, vec![]); + frame(&mut app, &ctx, 0.1, vec![]); + app.ui.slot_tools[8] = Tool::Ellipse; + frame(&mut app, &ctx, 1.0, vec![]); + let pointer = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let e = app.auto.elements.iter().find(|w| w.id == "tools.Ellipse").unwrap_or_else(|| panic!("no tools.Ellipse")); + let at = egui::pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0); + frame(&mut app, &ctx, 1.1, vec![egui::Event::PointerMoved(at), pointer(at, true), pointer(at, false), pointer(at, true), pointer(at, false)]); + let comp = app.session.active_comp().unwrap(); + assert_eq!(comp.layers.len(), 1, "the double-click made a layer"); + let l = &comp.layers[0]; + assert!(matches!(l.source, LayerSource::Shape), "a shape layer"); + let size = l.props.prop("contents/Ellipse 1/contents/Ellipse Path 1/size").expect("Ellipse Path 1"); + assert_eq!(size.value.as_vec2(), [640.0, 360.0], "the ellipse fills the comp"); + assert!(app.ui.status.is_empty(), "{}", app.ui.status); + } + + /// Tool Creates Mask on (#1072): a double-click on a shape tool masks the selected shape + /// layer instead of adding to its Contents. + #[test] + fn double_click_masks_a_shape_layer_while_tool_creates_mask() { + let mut s = effectcraft_engine::Session::default(); + s.execute("comp.new", json!({"width": 640, "height": 360, "duration": 2})).unwrap(); + let mut app = EffectcraftApp::new(s); + let ctx = egui::Context::default(); + crate::theme::install(&ctx, &app.tokens, crate::i18n::language(&app)); + frame(&mut app, &ctx, 0.0, vec![]); + frame(&mut app, &ctx, 0.1, vec![]); + let pointer = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let double_click = |app: &mut EffectcraftApp, ctx: &egui::Context, t: f64, id: &str| { + let e = app.auto.elements.iter().find(|w| w.id == id).unwrap_or_else(|| panic!("no {id}")); + let at = egui::pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0); + frame(app, ctx, t, vec![egui::Event::PointerMoved(at), pointer(at, true), pointer(at, false), pointer(at, true), pointer(at, false)]); + }; + // A shape layer, selected by the double-click that made it. + double_click(&mut app, &ctx, 1.0, "tools.Rectangle"); + assert_eq!(app.session.active_comp().unwrap().layers.len(), 1); + app.session.state.shape_tool.creates_mask = true; + app.ui.slot_tools[8] = Tool::Ellipse; + frame(&mut app, &ctx, 1.05, vec![]); + double_click(&mut app, &ctx, 2.0, "tools.Ellipse"); + { + let comp = app.session.active_comp().unwrap(); + assert_eq!(comp.layers.len(), 1, "a mask, not a second shape layer"); + let l = &comp.layers[0]; + assert!(l.props.prop("masks/Mask 1/path").is_some(), "an ellipse mask"); + assert!(l.props.prop("contents/Ellipse 1").is_none(), "nothing added to the Contents"); + let p = l.props.prop("contents/Rectangle 1/contents/Rectangle Path 1/size").expect("the rectangle stands"); + assert_eq!(p.value.as_vec2(), [640.0, 360.0]); + } + assert!(app.ui.status.is_empty(), "{}", app.ui.status); + } + + /// The shape tools' geometry defaults (#1072): the Rounded Rectangle tool's Roundness is a + /// fixed 20, the Polygon tool a 5-point polygon, the Star tool a 5-point star whose Inner + /// Radius is a quarter of its Outer Radius. + #[test] + fn double_click_shape_tool_geometry_defaults() { + let mut s = effectcraft_engine::Session::default(); + s.execute("comp.new", json!({"width": 640, "height": 360, "duration": 2})).unwrap(); + let mut app = EffectcraftApp::new(s); + let ctx = egui::Context::default(); + crate::theme::install(&ctx, &app.tokens, crate::i18n::language(&app)); + frame(&mut app, &ctx, 0.0, vec![]); + frame(&mut app, &ctx, 0.1, vec![]); + let pointer = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let double_click = |app: &mut EffectcraftApp, ctx: &egui::Context, t: f64, id: &str| { + let e = app.auto.elements.iter().find(|w| w.id == id).unwrap_or_else(|| panic!("no {id}")); + let at = egui::pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0); + frame(app, ctx, t, vec![egui::Event::PointerMoved(at), pointer(at, true), pointer(at, false), pointer(at, true), pointer(at, false)]); + }; + // Each shape on its own layer: deselect so the next double-click makes a new one. + let deselected = |app: &mut EffectcraftApp, ctx: &egui::Context, t: f64| { + app.session.execute("edit.deselectAll", json!({})).unwrap(); + frame(app, ctx, t, vec![]); + }; + // The Rounded Rectangle: Roundness 20, filling the comp. + app.ui.slot_tools[8] = Tool::RoundedRect; + frame(&mut app, &ctx, 1.0, vec![]); + double_click(&mut app, &ctx, 1.1, "tools.RoundedRect"); + { + let comp = app.session.active_comp().unwrap(); + let g = comp.layers[0].props.group("contents/Rectangle 1/contents/Rectangle Path 1").expect("Rectangle Path 1"); + assert_eq!(g.prop("roundness").expect("roundness").value.as_f64(), 20.0, "a fixed roundness"); + assert_eq!(g.prop("size").expect("size").value.as_vec2(), [640.0, 360.0]); + } + // The Star: 5 points, Outer Radius half the comp height, Inner Radius a quarter. + deselected(&mut app, &ctx, 2.0); + app.ui.slot_tools[8] = Tool::Star; + frame(&mut app, &ctx, 2.05, vec![]); + double_click(&mut app, &ctx, 2.1, "tools.Star"); + { + let comp = app.session.active_comp().unwrap(); + let g = comp.layers[0].props.group("contents/Polystar 1/contents/Polystar Path 1").expect("Polystar Path 1"); + assert_eq!(g.prop("type").expect("type").value.as_enum(), 0, "Star"); + assert_eq!(g.prop("points").expect("points").value.as_f64(), 5.0, "5 points"); + assert_eq!(g.prop("outerRadius").expect("outer").value.as_f64(), 180.0, "half the comp height"); + assert_eq!(g.prop("innerRadius").expect("inner").value.as_f64(), 90.0, "a quarter of the comp height"); + } + // The Polygon: 5 points, Outer Radius half the comp height, no inner radius. + deselected(&mut app, &ctx, 3.0); + app.ui.slot_tools[8] = Tool::Polygon; + frame(&mut app, &ctx, 3.05, vec![]); + double_click(&mut app, &ctx, 3.1, "tools.Polygon"); + { + let comp = app.session.active_comp().unwrap(); + let g = comp.layers[0].props.group("contents/Polystar 1/contents/Polystar Path 1").expect("Polystar Path 1"); + assert_eq!(g.prop("type").expect("type").value.as_enum(), 1, "Polygon"); + assert_eq!(g.prop("points").expect("points").value.as_f64(), 5.0, "5 points"); + assert_eq!(g.prop("outerRadius").expect("outer").value.as_f64(), 180.0, "half the comp height"); + assert!(g.prop("innerRadius").is_none(), "a polygon has no inner radius"); + } + assert!(app.ui.status.is_empty(), "{}", app.ui.status); + } + + /// A double-clicked shape is painted with the Tools bar's Fill and Stroke (#1072): the + /// colours and stroke width the shape tool is set to — and no outline at all when the tool + /// has its Stroke off. + #[test] + fn double_click_paints_with_the_shape_tools_fill_and_stroke() { + let mut s = effectcraft_engine::Session::default(); + s.execute("comp.new", json!({"width": 640, "height": 360, "duration": 2})).unwrap(); + let mut app = EffectcraftApp::new(s); + let ctx = egui::Context::default(); + crate::theme::install(&ctx, &app.tokens, crate::i18n::language(&app)); + frame(&mut app, &ctx, 0.0, vec![]); + frame(&mut app, &ctx, 0.1, vec![]); + let pointer = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let double_click = |app: &mut EffectcraftApp, ctx: &egui::Context, t: f64, id: &str| { + let e = app.auto.elements.iter().find(|w| w.id == id).unwrap_or_else(|| panic!("no {id}")); + let at = egui::pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0); + frame(app, ctx, t, vec![egui::Event::PointerMoved(at), pointer(at, true), pointer(at, false), pointer(at, true), pointer(at, false)]); + }; + // The tool's own Fill and Stroke go onto the created shape. + app.session.state.shape_tool.fill.color = [1.0, 0.0, 0.0]; + app.session.state.shape_tool.stroke.color = [0.0, 1.0, 0.0]; + app.session.state.shape_tool.stroke_width = 5.0; + double_click(&mut app, &ctx, 1.0, "tools.Rectangle"); + { + let comp = app.session.active_comp().unwrap(); + let g = comp.layers[0].props.group("contents/Rectangle 1/contents").expect("the contents"); + let fill = g.sub("fill").expect("the fill"); + assert_eq!(fill.get("color").expect("color").value.as_color(), [1.0, 0.0, 0.0, 1.0], "the tool's Fill"); + let stroke = g.sub("stroke").expect("the stroke"); + assert_eq!(stroke.get("color").expect("color").value.as_color(), [0.0, 1.0, 0.0, 1.0], "the tool's Stroke"); + assert_eq!(stroke.get("width").expect("width").value.as_f64(), 5.0, "the tool's Stroke Width"); + } + // Stroke off ("Keine"): the next shape gets no outline at all, but keeps the Fill. + app.session.execute("shape.toolOptions", json!({"stroke": false})).unwrap(); + app.session.execute("edit.deselectAll", json!({})).unwrap(); + app.ui.slot_tools[8] = Tool::Ellipse; + frame(&mut app, &ctx, 1.05, vec![]); + double_click(&mut app, &ctx, 2.0, "tools.Ellipse"); + { + let comp = app.session.active_comp().unwrap(); + assert_eq!(comp.layers.len(), 2, "a new shape layer"); + let g = comp.layers[0].props.group("contents/Ellipse 1/contents").expect("the ellipse"); + assert!(g.sub("stroke").is_none(), "no outline when the tool's Stroke is off"); + assert_eq!(g.sub("fill").expect("the fill").get("color").expect("color").value.as_color(), [1.0, 0.0, 0.0, 1.0]); + } + assert!(app.ui.status.is_empty(), "{}", app.ui.status); + } } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 254eec49c..14afe59cc 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -15,7 +15,7 @@ pub(crate) use ui::catalog_characters; pub(crate) use ui::{set_current, tr, tr_args}; /// Every language with a catalog, in the order Settings ▸ General ▸ Language lists them. -const CATALOG_CODES: [&str; 11] = ["cs", "de", "es", "fr", "pt-br", "ru", "uk", "ka", "ja", "zh-hans", "zh-hant"]; +const CATALOG_CODES: [&str; 12] = ["cs", "de", "es", "fr", "pl", "pt-br", "ru", "uk", "ka", "ja", "zh-hans", "zh-hant"]; /// The active interface language: the stored preference when it names a catalog, otherwise `en` /// (English is the source of every label and needs no lookup). @@ -66,6 +66,11 @@ pub(crate) fn czech(app: &EffectcraftApp) -> bool { language(app) == "cs" } +/// The UI is in Polish. +pub(crate) fn polish(app: &EffectcraftApp) -> bool { + language(app) == "pl" +} + /// The UI is in Georgian. pub(crate) fn georgian(app: &EffectcraftApp) -> bool { language(app) == "ka" @@ -134,6 +139,7 @@ fn supported(locale: Option<&str>) -> &'static str { "uk" => "uk", "es" => "es", "cs" => "cs", + "pl" => "pl", "ka" => "ka", "ru" => "ru", "de" => "de", @@ -173,6 +179,13 @@ pub(crate) fn label<'a>(app: &EffectcraftApp, command: &str, source: &'a str) -> _ => {} } } + if language == "pl" && matches!(command, "file.revealInFinder" | "layer.revealInFinder") { + match source { + "Reveal in Explorer" => return "Pokaż w Eksploratorze", + "Reveal in File Manager" => return "Pokaż w menedżerze plików", + _ => {} + } + } if language == "ka" && matches!(command, "file.revealInFinder" | "layer.revealInFinder") { match source { "Reveal in Explorer" => return "Explorer-ში ჩვენება", @@ -196,6 +209,7 @@ fn menu_catalog(language: &str) -> Option<&'static [(&'static str, &'static str, "fr" => FRENCH, "de" => GERMAN, "cs" => CZECH, + "pl" => POLISH, "ka" => GEORGIAN, _ => return None, }) @@ -266,6 +280,9 @@ pub(crate) fn entry(app: &EffectcraftApp, e: &MenuEntry, shown: String) -> Strin "es" => Some(("Deshacer", "No se puede deshacer", "Rehacer", "No se puede rehacer")), "pt-br" => Some(("Desfazer", "Não há nada para desfazer", "Refazer", "Não há nada para refazer")), "cs" => Some(("Zpět", "Není co vrátit", "Znovu", "Není co zopakovat")), + "fr" => Some(("Annuler", "Impossible d'annuler", "Rétablir", "Impossible de rétablir")), + "de" => Some(("Rückgängig", "Rückgängig nicht möglich", "Wiederholen", "Wiederholen nicht möglich")), + "pl" => Some(("Cofnij", "Nie można cofnąć", "Ponów", "Nie można ponowić")), // A colon before the history's (untranslated) action name: "დაბრუნება: Comp1". "ka" => Some(("დაბრუნება:", "დაბრუნება შეუძლებელია", "ხელახლა შესრულება:", "ხელახლა შესრულება შეუძლებელია")), // A colon before the history's (untranslated) action name: "Отменить: Comp1". @@ -306,8 +323,23 @@ pub(crate) fn effect_category(children: &[MenuNode], source: &str) -> Option Option { + let named = shown.strip_prefix("Assign Shortcut to “")?; + match source { + "Assign Shortcut to Workspace" => named.strip_suffix("” Workspace").map(|name| format!("Przypisz skrót do obszaru roboczego „{name}”")), + "Assign Shortcut to 3D View" => named.strip_suffix('”').map(|name| format!("Przypisz skrót do widoku 3D „{name}”")), + _ => None, + } +} + pub(crate) fn submenu(app: &EffectcraftApp, source: &str, shown: String) -> String { match language(app) { + "pl" => { + if let Some(label) = polish_assign_shortcut(source, &shown) { + return label; + } + } "cs" => { if source == "Assign Shortcut to Workspace" { if let Some(name) = shown.strip_prefix("Assign Shortcut to “").and_then(|s| s.strip_suffix("” Workspace")) { @@ -836,6 +868,8 @@ const SIMPLIFIED_CHINESE: &[(&str, &str, &str)] = &[ ("layer.applyTextPreset", "Drop In Lines", "行落下"), ("anim.addKeyframe", "Add Keyframe", "添加关键帧"), ("keys.toggleHold", "Toggle Hold Keyframe", "切换定格关键帧"), + ("keys.setMuted", "Mute Keyframes", "静音关键帧"), + ("keys.setMuted", "Unmute Keyframes", "取消静音关键帧"), ("keys.interpolation", "Keyframe Interpolation...", "关键帧插值..."), ("keys.velocity", "Keyframe Velocity...", "关键帧速度..."), ("", "Keyframe Assistant", "关键帧辅助"), @@ -1472,6 +1506,8 @@ const TRADITIONAL_CHINESE: &[(&str, &str, &str)] = &[ ("layer.applyTextPreset", "Drop In Lines", "行落下"), ("anim.addKeyframe", "Add Keyframe", "新增關鍵影格"), ("keys.toggleHold", "Toggle Hold Keyframe", "切換定格關鍵影格"), + ("keys.setMuted", "Mute Keyframes", "靜音關鍵影格"), + ("keys.setMuted", "Unmute Keyframes", "取消靜音關鍵影格"), ("keys.interpolation", "Keyframe Interpolation...", "關鍵影格插補..."), ("keys.velocity", "Keyframe Velocity...", "關鍵影格速度..."), ("", "Keyframe Assistant", "關鍵影格輔助"), @@ -2105,6 +2141,8 @@ const JAPANESE: &[(&str, &str, &str)] = &[ ("layer.applyTextPreset", "Drop In Lines", "行をドロップイン"), ("anim.addKeyframe", "Add Keyframe", "キーフレームを追加"), ("keys.toggleHold", "Toggle Hold Keyframe", "停止したキーフレームの切り替え"), + ("keys.setMuted", "Mute Keyframes", "キーフレームをミュート"), + ("keys.setMuted", "Unmute Keyframes", "キーフレームのミュートを解除"), ("keys.interpolation", "Keyframe Interpolation...", "キーフレーム補間法..."), ("keys.velocity", "Keyframe Velocity...", "キーフレーム速度..."), ("", "Keyframe Assistant", "キーフレーム補助"), @@ -2741,6 +2779,8 @@ const UKRAINIAN: &[(&str, &str, &str)] = &[ ("layer.applyTextPreset", "Drop In Lines", "Поява рядків згори"), ("anim.addKeyframe", "Add Keyframe", "Додати ключовий кадр"), ("keys.toggleHold", "Toggle Hold Keyframe", "Перемкнути утримання ключового кадру"), + ("keys.setMuted", "Mute Keyframes", "Заглушити ключові кадри"), + ("keys.setMuted", "Unmute Keyframes", "Зняти заглушення ключових кадрів"), ("keys.interpolation", "Keyframe Interpolation...", "Інтерполяція ключових кадрів..."), ("keys.velocity", "Keyframe Velocity...", "Швидкість ключових кадрів..."), ("", "Keyframe Assistant", "Помічник ключових кадрів"), @@ -3378,6 +3418,8 @@ const SPANISH: &[(&str, &str, &str)] = &[ ("layer.applyTextPreset", "Drop In Lines", "Entrada de líneas cayendo"), ("anim.addKeyframe", "Add Keyframe", "Añadir fotograma clave"), ("keys.toggleHold", "Toggle Hold Keyframe", "Conmutar mantener fotograma clave"), + ("keys.setMuted", "Mute Keyframes", "Silenciar fotogramas clave"), + ("keys.setMuted", "Unmute Keyframes", "Reactivar fotogramas clave"), ("keys.interpolation", "Keyframe Interpolation...", "Interpolación de fotogramas clave..."), ("keys.velocity", "Keyframe Velocity...", "Velocidad de fotogramas clave..."), ("", "Keyframe Assistant", "Asistente de fotogramas clave"), @@ -4013,6 +4055,8 @@ const PORTUGUESE: &[(&str, &str, &str)] = &[ ("layer.applyTextPreset", "Drop In Lines", "Entrar com queda de linhas"), ("anim.addKeyframe", "Add Keyframe", "Adicionar quadro-chave"), ("keys.toggleHold", "Toggle Hold Keyframe", "Alternar quadro-chave de retenção"), + ("keys.setMuted", "Mute Keyframes", "Silenciar quadros-chave"), + ("keys.setMuted", "Unmute Keyframes", "Reativar quadros-chave"), ("keys.interpolation", "Keyframe Interpolation...", "Interpolação de quadro-chave..."), ("keys.velocity", "Keyframe Velocity...", "Velocidade do quadro-chave..."), ("", "Keyframe Assistant", "Assistente de quadro-chave"), @@ -4655,6 +4699,8 @@ const RUSSIAN: &[(&str, &str, &str)] = &[ ("layer.applyTextPreset", "Drop In Lines", "Появление строк сверху"), ("anim.addKeyframe", "Add Keyframe", "Добавить ключевой кадр"), ("keys.toggleHold", "Toggle Hold Keyframe", "Переключить фиксацию ключевого кадра"), + ("keys.setMuted", "Mute Keyframes", "Отключить ключевые кадры"), + ("keys.setMuted", "Unmute Keyframes", "Включить ключевые кадры"), ("keys.interpolation", "Keyframe Interpolation...", "Интерполяция ключевых кадров..."), ("keys.velocity", "Keyframe Velocity...", "Скорость ключевых кадров..."), ("", "Keyframe Assistant", "Помощник по ключевым кадрам"), @@ -5290,6 +5336,8 @@ const FRENCH: &[(&str, &str, &str)] = &[ ("layer.applyTextPreset", "Drop In Lines", "Chute des lignes depuis le haut"), ("anim.addKeyframe", "Add Keyframe", "Ajouter une image clé"), ("keys.toggleHold", "Toggle Hold Keyframe", "Activer/Désactiver l'image clé figée"), + ("keys.setMuted", "Mute Keyframes", "Désactiver les images clés"), + ("keys.setMuted", "Unmute Keyframes", "Réactiver les images clés"), ("keys.interpolation", "Keyframe Interpolation...", "Interpolation des images clés..."), ("keys.velocity", "Keyframe Velocity...", "Vitesse des images clés..."), ("", "Keyframe Assistant", "Assistant d'images clés"), @@ -5925,6 +5973,8 @@ const GERMAN: &[(&str, &str, &str)] = &[ ("layer.applyTextPreset", "Drop In Lines", "Zeilen fallen ein"), ("anim.addKeyframe", "Add Keyframe", "Keyframe hinzufügen"), ("keys.toggleHold", "Toggle Hold Keyframe", "Halte-Keyframe umschalten"), + ("keys.setMuted", "Mute Keyframes", "Keyframes stummschalten"), + ("keys.setMuted", "Unmute Keyframes", "Keyframes wieder aktivieren"), ("keys.interpolation", "Keyframe Interpolation...", "Keyframe-Interpolation..."), ("keys.velocity", "Keyframe Velocity...", "Keyframe-Geschwindigkeit..."), ("", "Keyframe Assistant", "Keyframe-Assistent"), @@ -6124,6 +6174,640 @@ const GERMAN: &[(&str, &str, &str)] = &[ // <<< generated i18n catalogs +/// Polish (pl): original phrase translations of every fixed menu entry. +const POLISH: &[(&str, &str, &str)] = &[ + ("", "Front", "Przód"), + ("", "Bevel", "Fazowanie"), + ("", "Side", "Bok"), + ("", "Back", "Tył"), + ("layer.addTextAnimator", "Color", "Kolor"), + ("", "EffectCraft", "EffectCraft"), + ("app.about", "About EffectCraft...", "O programie EffectCraft..."), + ("", "Settings...", "Ustawienia..."), + ("app.settings", "General...", "Ogólne..."), + ("app.settings", "Startup & Repair...", "Uruchamianie i naprawa..."), + ("app.settings", "Project...", "Projekt..."), + ("app.settings", "Composition...", "Kompozycja..."), + ("app.settings", "Previews...", "Podglądy..."), + ("app.settings", "Appearance...", "Wygląd..."), + ("app.settings", "Grids & Guides...", "Siatki i linie pomocnicze..."), + ("app.settings", "Labels...", "Etykiety..."), + ("app.settings", "Type...", "Tekst..."), + ("app.settings", "Import...", "Import..."), + ("app.settings", "Export...", "Eksport..."), + ("app.settings", "Audio...", "Audio..."), + ("app.settings", "Disk...", "Dysk..."), + ("app.settings", "Memory & CPU...", "Pamięć i procesor..."), + ("app.settings", "Video...", "Wideo..."), + ("app.settings", "3D...", "3D..."), + ("app.settings", "Scripting & Expressions...", "Skrypty i wyrażenia..."), + ("app.hide", "Hide EffectCraft", "Ukryj EffectCraft"), + ("app.hideOthers", "Hide Others", "Ukryj pozostałe"), + ("app.showAll", "Show All", "Pokaż wszystko"), + ("app.quit", "Quit EffectCraft", "Zakończ EffectCraft"), + ("", "File", "Plik"), + ("", "New", "Nowy"), + ("file.newProject", "New Project", "Nowy projekt"), + ("file.newFromTemplate", "New Project from Template...", "Nowy projekt z szablonu..."), + ("project.newFolder", "New Folder", "Nowy folder"), + ("file.open", "Open Project...", "Otwórz projekt..."), + ("", "Open Recent", "Otwórz ostatnie"), + ("file.clearRecent", "Clear Recent Projects", "Wyczyść ostatnie projekty"), + ("file.close", "Close", "Zamknij"), + ("file.closeProject", "Close Project", "Zamknij projekt"), + ("file.save", "Save", "Zapisz"), + ("", "Save As", "Zapisz jako"), + ("file.saveAs", "Save As...", "Zapisz jako..."), + ("file.saveCopy", "Save a Copy...", "Zapisz kopię..."), + ("file.saveCopyAsXml", "Save a Copy As XML...", "Zapisz kopię jako XML..."), + ("templates.saveAs", "Save as Template...", "Zapisz jako szablon..."), + ("file.incrementAndSave", "Increment and Save", "Zapisz z kolejnym numerem"), + ("file.revert", "Revert", "Przywróć"), + ("", "Import", "Importuj"), + ("file.import", "File...", "Plik..."), + ("file.importMultiple", "Multiple Files...", "Wiele plików..."), + ("file.importTimeline", "Adobe Premiere Pro Project...", "Projekt Adobe Premiere Pro..."), + ("file.importProject", "EffectCraft Project...", "Projekt EffectCraft..."), + ("file.importPlaceholder", "Placeholder...", "Element zastępczy..."), + ("file.importSolid", "Solid...", "Plansza..."), + ("file.importLottie", "Lottie...", "Lottie..."), + ("file.importVanishingPoint", "Vanishing Point (.vpe)...", "Punkt zbiegu (.vpe)..."), + ("essential.importTemplate", "Essential Graphics Template...", "Szablon Essential Graphics..."), + ("", "Import Recent Footage", "Importuj ostatnie materiały"), + ("file.clearRecentFootage", "Clear Recent Footage", "Wyczyść ostatnie materiały"), + ("", "Export", "Eksportuj"), + ("renderQueue.add", "Add to Render Queue", "Dodaj do kolejki renderowania"), + ("file.exportTimeline", "Adobe Premiere Pro Project...", "Projekt Adobe Premiere Pro..."), + ("file.exportLottie", "Lottie JSON...", "Lottie JSON..."), + ("essential.exportTemplate", "Essential Graphics Template...", "Szablon Essential Graphics..."), + ("app.find", "Find", "Znajdź"), + ("layer.addItem", "Add Footage to Comp", "Dodaj materiał do kompozycji"), + ("file.newCompFromSelection", "New Comp from Selection...", "Nowa kompozycja z zaznaczenia..."), + ("", "Dependencies", "Zależności"), + ("file.collectFiles", "Collect Files...", "Zbierz pliki..."), + ("file.consolidateFootage", "Consolidate All Footage", "Scal wszystkie materiały"), + ("file.removeUnusedFootage", "Remove Unused Footage", "Usuń nieużywane materiały"), + ("file.reduceProject", "Reduce Project", "Zredukuj projekt"), + ("file.findMissing", "Find Missing Effects", "Znajdź brakujące efekty"), + ("file.findMissing", "Find Missing Fonts", "Znajdź brakujące czcionki"), + ("file.findMissing", "Find Missing Footage", "Znajdź brakujące materiały"), + ("file.relinkFootage", "Relink Missing Footage...", "Połącz ponownie brakujące materiały..."), + ("file.watchFolder", "Watch Folder...", "Folder obserwowany..."), + ("", "Scripts", "Skrypty"), + ("file.installScript", "Install Script File...", "Zainstaluj plik skryptu..."), + ("file.installScriptUIPanel", "Install ScriptUI Panel...", "Zainstaluj panel ScriptUI..."), + ("file.runScript", "Run Script File...", "Uruchom plik skryptu..."), + ("", "Create Proxy", "Utwórz proxy"), + ("file.createProxy", "Still...", "Klatka..."), + ("file.createProxy", "Movie...", "Film..."), + ("", "Set Proxy", "Ustaw proxy"), + ("file.setProxy", "File...", "Plik..."), + ("file.setProxyNone", "None", "Brak"), + ("", "Interpret Footage", "Interpretuj materiał"), + ("file.interpretFootage", "Main...", "Główny..."), + ("file.interpretProxy", "Proxy...", "Proxy..."), + ("file.rememberInterpretation", "Remember Interpretation", "Zapamiętaj interpretację"), + ("file.applyInterpretation", "Apply Interpretation", "Zastosuj interpretację"), + ("", "Replace Footage", "Zastąp materiał"), + ("file.replaceFootage", "File...", "Plik..."), + ("file.replaceWithPlaceholder", "Placeholder...", "Element zastępczy..."), + ("file.replaceWithSolid", "Solid...", "Plansza..."), + ("file.replaceWithLayeredComp", "With Layered Comp", "Z warstwową kompozycją"), + ("file.reloadFootage", "Reload Footage", "Wczytaj ponownie materiał"), + ("file.revealInFinder", "Reveal in Finder", "Pokaż w Finderze"), + ("file.projectSettings", "Project Settings...", "Ustawienia projektu..."), + ("app.quit", "Exit", "Zakończ"), + ("", "Edit", "Edycja"), + ("edit.undo", "Undo", "Cofnij"), + ("edit.redo", "Redo", "Ponów"), + ("", "History", "Historia"), + ("app.commandPalette", "Quick Apply...", "Szybkie stosowanie..."), + ("edit.cut", "Cut", "Wytnij"), + ("edit.copy", "Copy", "Kopiuj"), + ("edit.copyWithPropertyLinks", "Copy with Property Links", "Kopiuj z łączami właściwości"), + ("edit.copyWithRelativePropertyLinks", "Copy with Relative Property Links", "Kopiuj ze względnymi łączami właściwości"), + ("edit.copyExpressionOnly", "Copy Expression Only", "Kopiuj tylko wyrażenie"), + ("edit.paste", "Paste", "Wklej"), + ("edit.pasteReversedKeyframes", "Paste Reversed Keyframes", "Wklej odwrócone klatki kluczowe"), + ("edit.pasteTextMatchFormatting", "Paste Text and Match Formatting", "Wklej tekst i dopasuj formatowanie"), + ("edit.pasteTextFormattingOnly", "Paste Text Formatting Only", "Wklej tylko formatowanie tekstu"), + ("edit.clear", "Clear", "Wyczyść"), + ("edit.duplicate", "Duplicate", "Powiel"), + ("edit.splitLayer", "Split Layer", "Podziel warstwę"), + ("edit.liftWorkArea", "Lift Work Area", "Wytnij obszar roboczy"), + ("edit.extractWorkArea", "Extract Work Area", "Wyodrębnij obszar roboczy"), + ("edit.selectAll", "Select All", "Zaznacz wszystko"), + ("edit.deselectAll", "Deselect All", "Usuń zaznaczenie"), + ("", "Label", "Etykieta"), + ("edit.selectLabelGroup", "Select Label Group", "Zaznacz grupę etykiet"), + ("edit.label", "None", "Brak"), + ("edit.label", "Red", "Czerwony"), + ("edit.label", "Yellow", "Żółty"), + ("edit.label", "Aqua", "Akwamaryna"), + ("edit.label", "Pink", "Różowy"), + ("edit.label", "Lavender", "Lawendowy"), + ("edit.label", "Peach", "Brzoskwiniowy"), + ("edit.label", "Sea Foam", "Morska piana"), + ("edit.label", "Blue", "Niebieski"), + ("edit.label", "Green", "Zielony"), + ("edit.label", "Purple", "Fioletowy"), + ("edit.label", "Orange", "Pomarańczowy"), + ("edit.label", "Brown", "Brązowy"), + ("edit.label", "Fuchsia", "Fuksja"), + ("edit.label", "Cyan", "Cyjan"), + ("edit.label", "Sandstone", "Piaskowiec"), + ("edit.label", "Dark Green", "Ciemnozielony"), + ("app.settings", "Edit Label Colors...", "Edytuj kolory etykiet..."), + ("", "Select Keyframe Label Group", "Zaznacz grupę etykiet klatek kluczowych"), + ("keys.selectLabelGroup", "On Selected Layers", "Na zaznaczonych warstwach"), + ("keys.selectLabelGroup", "On All Layers", "Na wszystkich warstwach"), + ("keys.selectLabelGroup", "Visible Keyframes on Selected Layers", "Widoczne klatki kluczowe na zaznaczonych warstwach"), + ("keys.selectLabelGroup", "Visible Keyframes on All Layers", "Widoczne klatki kluczowe na wszystkich warstwach"), + ("", "Purge", "Wyczyść pamięć"), + ("edit.purge", "All Cache...", "Całą pamięć podręczną..."), + ("edit.purge", "All Memory & Disk Cache...", "Całą pamięć i pamięć dyskową..."), + ("edit.purge", "All Memory", "Całą pamięć"), + ("edit.purge", "All Disk Cache...", "Całą pamięć dyskową..."), + ("edit.purge", "All 3D Cache...", "Całą pamięć 3D..."), + ("edit.purgeUndo", "Undo", "Cofanie"), + ("edit.purge", "Image Cache Memory", "Pamięć podręczna obrazów"), + ("edit.purge", "Snapshot", "Migawka"), + ("edit.editOriginal", "Edit Original...", "Edytuj oryginał..."), + ("", "Templates", "Szablony"), + ("app.templates", "Render Settings...", "Ustawienia renderowania..."), + ("app.templates", "Output Module...", "Moduł wyjściowy..."), + ("app.keyboardShortcuts", "Keyboard Shortcuts", "Skróty klawiaturowe"), + ("", "Preferences", "Preferencje"), + ("", "Composition", "Kompozycja"), + ("comp.new", "New Composition...", "Nowa kompozycja..."), + ("comp.settings", "Composition Settings...", "Ustawienia kompozycji..."), + ("comp.setPosterTime", "Set Poster Time", "Ustaw czas plakatu"), + ("comp.trimToWorkArea", "Trim Comp to Work Area", "Przytnij kompozycję do obszaru roboczego"), + ("comp.cropToRegionOfInterest", "Crop Comp to Region of Interest", "Kadruj kompozycję do obszaru zainteresowania"), + ("comp.cropToLayerBounds", "Crop Comp to Selected Layer(s) Bounds", "Kadruj kompozycję do granic zaznaczonych warstw (warstwy)"), + ("render.addOutputModule", "Add Output Module", "Dodaj moduł wyjściowy"), + ("", "Preview", "Podgląd"), + ("playback.toggle", "Play Current Preview", "Odtwórz bieżący podgląd"), + ("playback.cacheWhenIdle", "Cache Frames When Idle", "Buforuj klatki w bezczynności"), + ("playback.audio", "Audio", "Audio"), + ("", "Save Frame As", "Zapisz klatkę jako"), + ("comp.saveFrameAs", "File...", "Plik..."), + ("comp.saveFrameAsPsd", "Photoshop Layers...", "Warstwy Photoshopa..."), + ("comp.saveFrameAsExr", "ProEXR...", "ProEXR..."), + ("comp.copyFrame", "Copy Frame to Clipboard", "Kopiuj klatkę do schowka"), + ("render.preRender", "Pre-render...", "Wstępne renderowanie..."), + ("render.saveCurrentPreview", "Save Current Preview...", "Zapisz bieżący podgląd..."), + ("comp.openInEssentialGraphics", "Open in Essential Graphics", "Otwórz w Essential Graphics"), + ("", "Responsive Design — Time", "Projekt responsywny — czas"), + ("comp.responsiveTime", "Create Intro", "Utwórz intro"), + ("comp.responsiveTime", "Create Outro", "Utwórz outro"), + ("comp.responsiveTime", "Create Protected Region from Work Area", "Utwórz chroniony obszar z obszaru roboczego"), + ("comp.flowchart", "Composition Flowchart", "Schemat kompozycji"), + ("comp.miniFlowchart", "Composition Mini-Flowchart", "Miniaturowy schemat kompozycji"), + ("", "VR", "VR"), + ("comp.vr.createEnvironment", "Create VR Environment...", "Utwórz środowisko VR..."), + ("comp.vr.extractCubemap", "Extract Cubemap...", "Wyodrębnij cubemapę..."), + ("", "Layer", "Warstwa"), + ("layer.newText", "Text", "Tekst"), + ("layer.newSolid", "Solid...", "Plansza..."), + ("layer.newLight", "Light...", "Światło..."), + ("layer.newCamera", "Camera...", "Kamera..."), + ("layer.newNull", "Null Object", "Obiekt zerowy"), + ("layer.newShape", "Shape Layer", "Warstwa kształtu"), + ("layer.newAdjustment", "Adjustment Layer", "Warstwa korekcyjna"), + ("layer.newContentAwareFill", "Content-Aware Fill Layer...", "Warstwa wypełnienia z uwzględnieniem zawartości..."), + ("layer.new3dPrimitive", "Cube", "Sześcian"), + ("layer.new3dPrimitive", "Sphere", "Kula"), + ("layer.new3dPrimitive", "Plane", "Płaszczyzna"), + ("layer.new3dPrimitive", "Torus", "Torus"), + ("layer.new3dPrimitive", "Cone", "Stożek"), + ("layer.new3dPrimitive", "Cylinder", "Walec"), + ("layer.settings", "Layer Settings...", "Ustawienia warstwy..."), + ("layer.openLayer", "Open Layer", "Otwórz warstwę"), + ("layer.openSource", "Open Layer Source", "Otwórz źródło warstwy"), + ("layer.revealInFinder", "Reveal in Finder", "Pokaż w Finderze"), + ("", "Mask", "Maska"), + ("layer.addMask", "New Mask", "Nowa maska"), + ("layer.mask.shape", "Mask Shape...", "Kształt maski..."), + ("layer.mask.set", "Mask Feather...", "Wtapianie maski..."), + ("layer.mask.set", "Mask Opacity...", "Krycie maski..."), + ("layer.mask.set", "Mask Expansion...", "Rozszerzenie maski..."), + ("layer.mask.reset", "Reset Mask", "Resetuj maskę"), + ("layer.mask.remove", "Remove Mask", "Usuń maskę"), + ("layer.mask.removeAll", "Remove All Masks", "Usuń wszystkie maski"), + ("track.mask", "Track Mask", "Śledź maskę"), + ("", "Mode", "Tryb"), + ("layer.mask.mode", "None", "Brak"), + ("layer.mask.mode", "Add", "Dodaj"), + ("layer.mask.mode", "Subtract", "Odejmij"), + ("layer.mask.mode", "Intersect", "Część wspólna"), + ("layer.mask.mode", "Lighten", "Rozjaśnij"), + ("layer.mask.mode", "Darken", "Przyciemnij"), + ("layer.mask.mode", "Difference", "Różnica"), + ("layer.mask.invert", "Inverted", "Odwrócona"), + ("layer.mask.lock", "Locked", "Zablokowana"), + ("", "Motion Blur", "Rozmycie ruchu"), + ("layer.mask.motionBlur", "Same As Layer", "Jak warstwa"), + ("layer.mask.motionBlur", "On", "Wł."), + ("layer.mask.motionBlur", "Off", "Wył."), + ("", "Feather Falloff", "Zanikanie wtapiania"), + ("layer.mask.featherFalloff", "Smooth", "Gładkie"), + ("layer.mask.featherFalloff", "Linear", "Liniowe"), + ("layer.mask.unlockAll", "Unlock All Masks", "Odblokuj wszystkie maski"), + ("layer.mask.lockOthers", "Lock Other Masks", "Zablokuj pozostałe maski"), + ("layer.mask.hideLocked", "Hide Locked Masks", "Ukryj zablokowane maski"), + ("", "Mask and Shape Path", "Ścieżka maski i kształtu"), + ("path.rotoBezier", "RotoBezier", "RotoBezier"), + ("mask.setClosed", "Closed", "Zamknięta"), + ("path.convertToBezier", "Convert To Bezier Path", "Konwertuj na ścieżkę Béziera"), + ("path.setFirstVertex", "Set First Vertex", "Ustaw pierwszy wierzchołek"), + ("path.freeTransform", "Free Transform Points", "Swobodne przekształcanie punktów"), + ("", "Quality", "Jakość"), + ("layer.quality", "Best", "Najlepsza"), + ("layer.quality", "Draft", "Robocza"), + ("layer.quality", "Wireframe", "Szkielet"), + ("layer.sampling", "Bilinear", "Dwuliniowa"), + ("layer.sampling", "Bicubic", "Dwusześcienna"), + ("", "Switches", "Przełączniki"), + ("layer.hideOtherVideo", "Hide Other Video", "Ukryj pozostałe wideo"), + ("layer.showAllVideo", "Show All Video", "Pokaż całe wideo"), + ("layer.unlockAll", "Unlock All Layers", "Odblokuj wszystkie warstwy"), + ("layer.expressions", "Enable Expressions", "Włącz wyrażenia"), + ("layer.expressions", "Disable Expressions", "Wyłącz wyrażenia"), + ("layer.setSwitch", "Shy", "Nieśmiała"), + ("layer.setSwitch", "Lock", "Zablokuj"), + ("layer.setSwitch", "Audio", "Audio"), + ("layer.setSwitch", "Video", "Wideo"), + ("layer.setSwitch", "Solo", "Solo"), + ("layer.setSwitch", "Effect", "Efekt"), + ("layer.setSwitch", "Collapse", "Zwiń"), + ("layer.setSwitch", "Motion Blur", "Rozmycie ruchu"), + ("layer.setSwitch", "Adjustment Layer", "Warstwa korekcyjna"), + ("", "Transform", "Przekształć"), + ("layer.transform", "Reset", "Resetuj"), + ("layer.setTransform", "Anchor Point...", "Punkt zakotwiczenia..."), + ("layer.setTransform", "Position...", "Położenie..."), + ("layer.setTransform", "Scale...", "Skala..."), + ("layer.setTransform", "Orientation...", "Orientacja..."), + ("layer.setTransform", "Rotation...", "Obrót..."), + ("layer.setTransform", "Opacity...", "Krycie..."), + ("layer.transform", "Flip Horizontal", "Odbij w poziomie"), + ("layer.transform", "Flip Vertical", "Odbij w pionie"), + ("layer.transform", "Center In View", "Wyśrodkuj w widoku"), + ("layer.centerAnchor", "Center Anchor Point in Layer Content", "Wyśrodkuj punkt zakotwiczenia w zawartości warstwy"), + ("layer.transform", "Fit to Comp", "Dopasuj do kompozycji"), + ("layer.transform", "Fit to Comp Width", "Dopasuj do szerokości kompozycji"), + ("layer.transform", "Fit to Comp Height", "Dopasuj do wysokości kompozycji"), + ("layer.autoOrient", "Auto-Orient...", "Automatyczna orientacja..."), + ("", "Time", "Czas"), + ("layer.enableTimeRemap", "Enable Time Remapping", "Włącz przemapowanie czasu"), + ("layer.timeReverse", "Time-Reverse Layer", "Odwróć czas warstwy"), + ("layer.timeStretch", "Time Stretch...", "Rozciągnięcie czasu..."), + ("layer.freezeFrame", "Freeze Frame", "Zamrożenie klatki"), + ("layer.freezeOnLastFrame", "Freeze On Last Frame", "Zamroź na ostatniej klatce"), + ("layer.alignVideoToData", "Align Video to Data", "Wyrównaj wideo do danych"), + ("", "Frame Blending", "Mieszanie klatek"), + ("layer.frameBlending", "Off", "Wył."), + ("layer.frameBlending", "Frame Mix", "Miks klatek"), + ("layer.frameBlending", "Pixel Motion", "Ruch pikseli"), + ("layer.setSwitch", "3D Layer", "Warstwa 3D"), + ("layer.setSwitch", "Guide Layer", "Warstwa pomocnicza"), + ("layer.environment", "Environment Layer", "Warstwa środowiska"), + ("", "Markers", "Znaczniki"), + ("layer.addMarker", "Add Marker", "Dodaj znacznik"), + ("layer.updateMarkersFromSource", "Update Markers From Source", "Aktualizuj znaczniki ze źródła"), + ("layer.markersLock", "Lock Markers", "Zablokuj znaczniki"), + ("layer.deleteAllMarkers", "Delete All Markers", "Usuń wszystkie znaczniki"), + ("layer.setSwitch", "Preserve Transparency", "Zachowaj przezroczystość"), + ("", "Blending Mode", "Tryb mieszania"), + ("layer.setBlendMode", "Normal", "Normalny"), + ("layer.setBlendMode", "Dissolve", "Rozpuszczenie"), + ("layer.setBlendMode", "Dancing Dissolve", "Tańczące rozpuszczenie"), + ("layer.setBlendMode", "Darken", "Ciemniej"), + ("layer.setBlendMode", "Multiply", "Mnożenie"), + ("layer.setBlendMode", "Color Burn", "Ściemnianie koloru"), + ("layer.setBlendMode", "Classic Color Burn", "Klasyczne ściemnianie koloru"), + ("layer.setBlendMode", "Linear Burn", "Ściemnianie liniowe"), + ("layer.setBlendMode", "Darker Color", "Ciemniejszy kolor"), + ("layer.setBlendMode", "Add", "Dodaj"), + ("layer.setBlendMode", "Lighten", "Jaśniej"), + ("layer.setBlendMode", "Screen", "Mnożenie odwrotne"), + ("layer.setBlendMode", "Color Dodge", "Rozjaśnianie koloru"), + ("layer.setBlendMode", "Classic Color Dodge", "Klasyczne rozjaśnianie koloru"), + ("layer.setBlendMode", "Linear Dodge", "Rozjaśnianie liniowe"), + ("layer.setBlendMode", "Lighter Color", "Jaśniejszy kolor"), + ("layer.setBlendMode", "Overlay", "Nakładka"), + ("layer.setBlendMode", "Soft Light", "Łagodne światło"), + ("layer.setBlendMode", "Hard Light", "Ostre światło"), + ("layer.setBlendMode", "Linear Light", "Światło liniowe"), + ("layer.setBlendMode", "Vivid Light", "Jaskrawe światło"), + ("layer.setBlendMode", "Pin Light", "Światło punktowe"), + ("layer.setBlendMode", "Hard Mix", "Twarde mieszanie"), + ("layer.setBlendMode", "Difference", "Różnica"), + ("layer.setBlendMode", "Classic Difference", "Klasyczna różnica"), + ("layer.setBlendMode", "Exclusion", "Wykluczenie"), + ("layer.setBlendMode", "Subtract", "Odejmowanie"), + ("layer.setBlendMode", "Divide", "Dzielenie"), + ("layer.setBlendMode", "Hue", "Barwa"), + ("layer.setBlendMode", "Saturation", "Nasycenie"), + ("layer.setBlendMode", "Color", "Kolor"), + ("layer.setBlendMode", "Luminosity", "Jasność"), + ("layer.setBlendMode", "Stencil Alpha", "Szablon alfa"), + ("layer.setBlendMode", "Stencil Luma", "Szablon luma"), + ("layer.setBlendMode", "Silhouette Alpha", "Sylweta alfa"), + ("layer.setBlendMode", "Silhouette Luma", "Sylweta luma"), + ("layer.setBlendMode", "Alpha Add", "Dodawanie alfa"), + ("layer.setBlendMode", "Luminescent Premul", "Luminescencja premul"), + ("layer.setBlendMode", "Next Blending Mode", "Następny tryb mieszania"), + ("layer.setBlendMode", "Previous Blending Mode", "Poprzedni tryb mieszania"), + ("", "Track Matte", "Matowa ścieżka"), + ("layer.trackMatte", "No Track Matte", "Bez matowej ścieżki"), + ("layer.trackMatte", "Alpha Matte", "Matowa alfa"), + ("layer.trackMatte", "Alpha Inverted Matte", "Odwrócona matowa alfa"), + ("layer.trackMatte", "Luma Matte", "Matowa luma"), + ("layer.trackMatte", "Luma Inverted Matte", "Odwrócona matowa luma"), + ("layer.trackMatte", "Matte with Layer Above", "Matowa z warstwą powyżej"), + ("layer.trackMatte", "Matte with Layer Below", "Matowa z warstwą poniżej"), + ("", "Layer Styles", "Style warstwy"), + ("layer.style.options", "Layer Style Options...", "Opcje stylu warstwy..."), + ("layer.style.convertToEditable", "Convert to Editable Styles", "Konwertuj na edytowalne style"), + ("layer.style.showAll", "Show All", "Pokaż wszystkie"), + ("layer.style.removeAll", "Remove All", "Usuń wszystkie"), + ("layer.style.dropShadow", "Drop Shadow", "Cień"), + ("layer.style.innerShadow", "Inner Shadow", "Cień wewnętrzny"), + ("layer.style.outerGlow", "Outer Glow", "Blask zewnętrzny"), + ("layer.style.innerGlow", "Inner Glow", "Blask wewnętrzny"), + ("layer.style.bevelEmboss", "Bevel and Emboss", "Faza i płaskorzeźba"), + ("layer.style.satin", "Satin", "Satyna"), + ("layer.style.colorOverlay", "Color Overlay", "Nakładka koloru"), + ("layer.style.gradientOverlay", "Gradient Overlay", "Nakładka gradientu"), + ("layer.style.stroke", "Stroke", "Obrys"), + ("path.groupShapes", "Group Shapes", "Grupuj kształty"), + ("path.ungroupShapes", "Ungroup Shapes", "Rozgrupuj kształty"), + ("", "Arrange", "Kolejność"), + ("layer.arrange", "Bring Layer to Front", "Przesuń warstwę na wierzch"), + ("layer.arrange", "Bring Layer Forward", "Przesuń warstwę do przodu"), + ("layer.arrange", "Send Layer Backward", "Przesuń warstwę do tyłu"), + ("layer.arrange", "Send Layer to Back", "Przesuń warstwę na spód"), + ("", "Reveal", "Pokaż"), + ("layer.revealSource", "Reveal Layer Source in Project", "Pokaż źródło warstwy w projekcie"), + ("comp.flowchart", "Reveal Layer in Project Flowchart", "Pokaż warstwę w schemacie projektu"), + ("comp.revealInProject", "Reveal Composition in Project", "Pokaż kompozycję w projekcie"), + ("layer.revealExpressionErrors", "Reveal Expression Errors", "Pokaż błędy wyrażeń"), + ("", "Create", "Utwórz"), + ("layer.create", "Convert to Editable Text", "Konwertuj na edytowalny tekst"), + ("layer.create", "Create Shapes from Text", "Utwórz kształty z tekstu"), + ("layer.create", "Create Masks from Text", "Utwórz maski z tekstu"), + ("layer.create", "Create Shapes from Vector Layer", "Utwórz kształty z warstwy wektorowej"), + ("layer.create", "Create Keyframes from Data", "Utwórz klatki kluczowe z danych"), + ("layer.create", "Null Controllers for Positional Points", "Kontrolery zerowe dla punktów położenia"), + ("layer.create", "Null Controllers for Path Points", "Kontrolery zerowe dla punktów ścieżki"), + ("layer.create", "Nulls Following Path Points", "Obiekty zerowe podążające za punktami ścieżki"), + ("layer.create", "Null Tracing Along Path", "Obiekt zerowy wzdłuż ścieżki"), + ("layer.create", "Create 3D Layer Instance", "Utwórz instancję warstwy 3D"), + ("", "Camera", "Kamera"), + ("camera.fromView", "Create Camera from 3D View", "Utwórz kamerę z widoku 3D"), + ("camera.stereoRig", "Create Stereo 3D Rig", "Utwórz zestaw stereo 3D"), + ("camera.orbitNull", "Create Orbit Null", "Utwórz obiekt zerowy orbity"), + ("camera.fromModel", "Create Cameras from 3D Model", "Utwórz kamery z modelu 3D"), + ("camera.linkFocusToPoi", "Link Focus Distance to Point of Interest", "Powiąż odległość ostrości z punktem zainteresowania"), + ("camera.linkFocusToLayer", "Link Focus Distance to Layer", "Powiąż odległość ostrości z warstwą"), + ("camera.setFocusToLayer", "Set Focus Distance to Layer", "Ustaw odległość ostrości na warstwę"), + ("layer.cameraSettings", "Camera Settings...", "Ustawienia kamery..."), + ("view.reset3DView", "Reset 3D View", "Resetuj widok 3D"), + ("", "Light", "Światło"), + ("light.fromModel", "Create Lights from 3D Model", "Utwórz światła z modelu 3D"), + ("light.controlWithCamera", "Control Light with Camera", "Steruj światłem za pomocą kamery"), + ("light.environmentBackground", "Create Environment Light Background Layer", "Utwórz warstwę tła światła otoczenia"), + ("", "Material", "Materiał"), + ("material.revealSource", "Reveal Material Source in Project", "Pokaż źródło materiału w projekcie"), + ("material.reset", "Reset Material", "Resetuj materiał"), + ("material.duplicateAssign", "Duplicate and Assign Material", "Powiel i przypisz materiał"), + ("layer.autoTrace", "Auto-trace...", "Automatyczne obrysowanie..."), + ("layer.precompose", "Pre-compose...", "Utwórz kompozycję wstępną..."), + ("layer.sceneEditDetection", "Scene Edit Detection...", "Wykrywanie zmian scen..."), + ("", "Effect", "Efekt"), + ("window.panel", "Effect Controls", "Ustawienia efektów"), + ("effect.applyLast", "Last Effect", "Ostatni efekt"), + ("effect.removeAll", "Remove All", "Usuń wszystkie"), + ("effect.manage", "Manage Effects...", "Zarządzaj efektami..."), + ("effect.plugins.load", "Load Effect Plug-in...", "Wczytaj wtyczkę efektu..."), + ("", "Animation", "Animacja"), + ("anim.savePreset", "Save Animation Preset...", "Zapisz ustawienie animacji..."), + ("anim.applyPreset", "Apply Animation Preset...", "Zastosuj ustawienie animacji..."), + ("", "Recent Animation Presets", "Ostatnie ustawienia animacji"), + ("anim.clearRecentPresets", "Clear Recent Presets", "Wyczyść ostatnie ustawienia"), + ("anim.browsePresets", "Browse Presets...", "Przeglądaj ustawienia..."), + ("", "Text Animation Presets", "Ustawienia animacji tekstu"), + ("layer.applyTextPreset", "Typewriter", "Maszyna do pisania"), + ("layer.applyTextPreset", "Fade Up Characters", "Pojawianie się znaków"), + ("layer.applyTextPreset", "Bounce In Words", "Odbijające się słowa"), + ("layer.applyTextPreset", "Tracking In", "Zbliżanie liter"), + ("layer.applyTextPreset", "Scramble", "Mieszanie znaków"), + ("layer.applyTextPreset", "Blur In", "Wyostrzanie z rozmycia"), + ("layer.applyTextPreset", "Jitter", "Drganie"), + ("layer.applyTextPreset", "Drop In Lines", "Wpadające wiersze"), + ("anim.addKeyframe", "Add Keyframe", "Dodaj klatkę kluczową"), + ("keys.toggleHold", "Toggle Hold Keyframe", "Przełącz klatkę zatrzymania"), + ("keys.interpolation", "Keyframe Interpolation...", "Interpolacja klatek kluczowych..."), + ("keys.velocity", "Keyframe Velocity...", "Prędkość klatek kluczowych..."), + ("", "Keyframe Assistant", "Asystent klatek kluczowych"), + ("keys.audioToKeyframes", "Convert Audio to Keyframes", "Konwertuj audio na klatki kluczowe"), + ("prop.convertExpressionToKeyframes", "Convert Expression to Keyframes", "Konwertuj wyrażenie na klatki kluczowe"), + ("keys.easyEase", "Easy Ease", "Łagodne przejście"), + ("keys.easyEaseIn", "Easy Ease In", "Łagodne wejście"), + ("keys.easyEaseOut", "Easy Ease Out", "Łagodne wyjście"), + ("keys.exponentialScale", "Exponential Scale", "Skalowanie wykładnicze"), + ("keys.rpfCameraImport", "RPF Camera Import", "Import kamery RPF"), + ("layer.sequence", "Sequence Layers...", "Ułóż warstwy w sekwencję..."), + ("keys.timeReverse", "Time-Reverse Keyframes", "Odwróć czas klatek kluczowych"), + ("", "Animate Text", "Animuj tekst"), + ("layer.enablePerChar3D", "Enable Per-character 3D", "Włącz 3D dla każdego znaku"), + ("layer.addTextAnimator", "Anchor Point", "Punkt zakotwiczenia"), + ("layer.addTextAnimator", "Position", "Położenie"), + ("layer.addTextAnimator", "Scale", "Skala"), + ("layer.addTextAnimator", "Skew", "Pochylenie"), + ("layer.addTextAnimator", "Rotation", "Obrót"), + ("layer.addTextAnimator", "Opacity", "Krycie"), + ("layer.addTextAnimator", "All Transform Properties", "Wszystkie właściwości przekształcenia"), + ("", "Fill Color", "Kolor wypełnienia"), + ("layer.addTextAnimator", "RGB", "RGB"), + ("layer.addTextAnimator", "Hue", "Barwa"), + ("layer.addTextAnimator", "Saturation", "Nasycenie"), + ("layer.addTextAnimator", "Brightness", "Jasność"), + ("", "Stroke Color", "Kolor obrysu"), + ("layer.addTextAnimator", "Stroke Width", "Szerokość obrysu"), + ("layer.addTextAnimator", "Tracking", "Światło"), + ("layer.addTextAnimator", "Line Anchor", "Zakotwiczenie wiersza"), + ("layer.addTextAnimator", "Line Spacing", "Odstęp między wierszami"), + ("layer.addTextAnimator", "Character Offset", "Przesunięcie znaku"), + ("layer.addTextAnimator", "Character Value", "Wartość znaku"), + ("layer.addTextAnimator", "Blur", "Rozmycie"), + ("text.animatorFontAxes", "Variable Font Axes", "Osie czcionki zmiennej"), + ("", "Add Text Selector", "Dodaj selektor tekstu"), + ("text.addSelector", "Range", "Zakres"), + ("text.addSelector", "Wiggly", "Falowanie"), + ("text.addSelector", "Expression", "Wyrażenie"), + ("text.removeAllAnimators", "Remove All Text Animators", "Usuń wszystkie animatory tekstu"), + ("prop.setExpression", "Add Expression", "Dodaj wyrażenie"), + ("essential.addProperty", "Add Property to Essential Graphics", "Dodaj właściwość do Essential Graphics"), + ("prop.separateDimensions", "Separate Dimensions", "Rozdziel wymiary"), + ("track.camera", "Track Camera", "Śledź kamerę"), + ("track.warpStabilizer", "Warp Stabilizer VFX", "Warp Stabilizer VFX"), + ("track.motion", "Track Motion", "Śledź ruch"), + ("track.stabilize", "Stabilize Motion", "Stabilizuj ruch"), + ("track.property", "Track this Property", "Śledź tę właściwość"), + ("anim.reveal", "Reveal Properties with Keyframes", "Pokaż właściwości z klatkami kluczowymi"), + ("anim.reveal", "Reveal Properties with Animation", "Pokaż właściwości z animacją"), + ("anim.reveal", "Reveal All Modified Properties", "Pokaż wszystkie zmienione właściwości"), + ("", "View", "Widok"), + ("view.newViewer", "New Viewer", "Nowa przeglądarka"), + ("view.splitLockedViewer", "Split with New Locked Viewer", "Podziel z nową zablokowaną przeglądarką"), + ("view.zoomIn", "Zoom In", "Powiększ"), + ("view.zoomOut", "Zoom Out", "Pomniejsz"), + ("", "Resolution", "Rozdzielczość"), + ("view.res.full", "Full", "Pełna"), + ("view.res.half", "Half", "Połowa"), + ("view.res.third", "Third", "Jedna trzecia"), + ("view.res.quarter", "Quarter", "Jedna czwarta"), + ("view.res.custom", "Custom...", "Niestandardowa..."), + ("view.displayColorManagement", "Use Display Color Management", "Używaj zarządzania kolorem wyświetlacza"), + ("", "Simulate Output", "Symuluj wyjście"), + ("view.simulateOutput", "No Output Simulation", "Bez symulacji wyjścia"), + ("view.simulateOutput", "HDTV (Rec. 709)", "HDTV (Rec. 709)"), + ("view.simulateOutput", "SDTV NTSC", "SDTV NTSC"), + ("view.simulateOutput", "SDTV PAL", "SDTV PAL"), + ("view.simulateOutput", "Legacy Macintosh RGB (Gamma 1.8)", "Starsze RGB komputerów Macintosh (gamma 1,8)"), + ("view.simulateOutput", "Internet Standard RGB (sRGB)", "Standardowe internetowe RGB (sRGB)"), + ("view.simulateOutput", "UHDTV (Rec. 2020)", "UHDTV (Rec. 2020)"), + ("view.simulateOutput", "Display P3", "Display P3"), + ("view.simulateOutput", "Linear (1.0 Gamma)", "Liniowe (gamma 1,0)"), + ("view.customRgb", "My Custom RGB...", "Moje niestandardowe RGB..."), + ("view.simulateOutput", "Custom...", "Niestandardowe..."), + ("view.rulers", "Show Rulers", "Pokaż linijki"), + ("", "Panel Background Color", "Kolor tła panelu"), + ("view.panelBackground", "Black", "Czarny"), + ("view.panelBackground", "Dark Gray", "Ciemnoszary"), + ("view.panelBackground", "Medium Gray (Default)", "Średnioszary (domyślny)"), + ("view.panelBackground", "Light Gray", "Jasnoszary"), + ("view.panelBackground", "White", "Biały"), + ("view.panelBackground", "Custom", "Niestandardowy"), + ("view.panelBackground", "Select Custom Background Color...", "Wybierz niestandardowy kolor tła..."), + ("view.guides", "Show Guides", "Pokaż linie pomocnicze"), + ("view.snapToGuides", "Snap to Guides", "Przyciągaj do linii pomocniczych"), + ("view.lockGuides", "Lock Guides", "Zablokuj linie pomocnicze"), + ("view.addGuide", "Add Guide...", "Dodaj linię pomocniczą..."), + ("view.addPercentGuide", "Add Percentage Guide...", "Dodaj procentową linię pomocniczą..."), + ("view.convertGuides", "Convert Guides to Percentage", "Konwertuj linie pomocnicze na procenty"), + ("view.convertGuides", "Convert Guides to Pixels", "Konwertuj linie pomocnicze na piksele"), + ("view.clearGuides", "Clear Guides", "Wyczyść linie pomocnicze"), + ("view.importGuides", "Import Guides...", "Importuj linie pomocnicze..."), + ("view.exportGuides", "Export Guides...", "Eksportuj linie pomocnicze..."), + ("view.grid", "Show Grid", "Pokaż siatkę"), + ("view.snapToGrid", "Snap to Grid", "Przyciągaj do siatki"), + ("view.snapping", "Snapping", "Przyciąganie"), + ("view.options", "View Options...", "Opcje widoku..."), + ("view.layerControls", "Show Layer Controls", "Pokaż elementy sterujące warstwy"), + ("", "Switch View Layout", "Przełącz układ widoków"), + ("view.layout", "1 View", "1 widok"), + ("view.layout", "2 Views", "2 widoki"), + ("view.layout", "4 Views", "4 widoki"), + ("view.shareViewOptions", "Share View Options", "Wspólne opcje widoku"), + ("", "Switch 3D View", "Przełącz widok 3D"), + ("view.3d.activeCamera", "Active Camera", "Aktywna kamera"), + ("view.3d.default", "Default", "Domyślny"), + ("view.3d.front", "Front", "Przód"), + ("view.3d.left", "Left", "Lewo"), + ("view.3d.top", "Top", "Góra"), + ("view.3d.back", "Back", "Tył"), + ("view.3d.right", "Right", "Prawo"), + ("view.3d.bottom", "Bottom", "Dół"), + ("view.3d.custom1", "Custom View 1", "Widok niestandardowy 1"), + ("view.3d.custom2", "Custom View 2", "Widok niestandardowy 2"), + ("view.3d.custom3", "Custom View 3", "Widok niestandardowy 3"), + ("", "Assign Shortcut to 3D View", "Przypisz skrót do widoku 3D"), + ("view.3d.last", "Switch to Last 3D View", "Przełącz na ostatni widok 3D"), + ("view.lookAtSelected", "Look at Selected Layers", "Patrz na zaznaczone warstwy"), + ("view.lookAtAll", "Look at All Layers", "Patrz na wszystkie warstwy"), + ("time.set", "Go to Time...", "Przejdź do czasu..."), + ("view.fullScreen", "Enter Full Screen", "Pełny ekran"), + ("", "Window", "Okno"), + ("", "Workspace", "Obszar roboczy"), + ("window.workspace", "Default", "Domyślny"), + ("window.workspace", "Review", "Przegląd"), + ("window.workspace", "Learn", "Nauka"), + ("window.workspace", "Small Screen", "Mały ekran"), + ("window.workspace", "Standard", "Standardowy"), + ("window.workspace", "All Panels", "Wszystkie panele"), + ("window.workspace", "Animation", "Animacja"), + ("window.workspace", "Color", "Kolor"), + ("window.workspace", "Effects", "Efekty"), + ("window.workspace", "Essential Graphics", "Essential Graphics"), + ("window.workspace", "Minimal", "Minimalny"), + ("window.workspace", "Motion Tracking", "Śledzenie ruchu"), + ("window.workspace", "Paint", "Malowanie"), + ("window.workspace", "Text", "Tekst"), + ("window.workspace", "Undocked Panels", "Panele odpięte"), + ("window.resetWorkspace", "Reset to Saved Layout", "Przywróć zapisany układ"), + ("window.saveWorkspace", "Save Changes to this Workspace", "Zapisz zmiany w tym obszarze roboczym"), + ("window.saveWorkspaceAs", "Save as New Workspace...", "Zapisz jako nowy obszar roboczy..."), + ("window.editWorkspaces", "Edit Workspaces...", "Edytuj obszary robocze..."), + ("", "Assign Shortcut to Workspace", "Przypisz skrót do obszaru roboczego"), + ("window.panel", "Align", "Wyrównanie"), + ("window.panel", "Audio", "Audio"), + ("window.panel", "Brushes", "Pędzle"), + ("window.panel", "Character", "Znak"), + ("window.panel", "Content-Aware Fill", "Wypełnienie z uwzględnieniem zawartości"), + ("window.panel", "Effects & Presets", "Efekty i ustawienia"), + ("window.panel", "Essential Graphics", "Essential Graphics"), + ("window.panel", "Info", "Informacje"), + ("help.inAppTutorials", "Learn", "Nauka"), + ("window.panel", "Lumetri Scopes", "Zakresy Lumetri"), + ("window.panel", "Mask Interpolation", "Interpolacja maski"), + ("window.panel", "Media Browser", "Przeglądarka multimediów"), + ("window.panel", "Metadata", "Metadane"), + ("window.panel", "Motion Sketch", "Szkic ruchu"), + ("window.panel", "Paint", "Malowanie"), + ("window.panel", "Paragraph", "Akapit"), + ("window.panel", "Preview", "Podgląd"), + ("window.panel", "Progress", "Postęp"), + ("window.panel", "Properties", "Właściwości"), + ("window.panel", "Script Console", "Konsola skryptów"), + ("window.panel", "Smoother", "Wygładzanie"), + ("window.panel", "Tools", "Narzędzia"), + ("window.panel", "Tracker", "Śledzenie"), + ("window.panel", "Wiggler", "Wahanie"), + ("window.panel", "Composition", "Kompozycja"), + ("window.panel", "Flowchart", "Schemat"), + ("window.panel", "Footage", "Materiał"), + ("window.panel", "Layer", "Warstwa"), + ("window.panel", "Project", "Projekt"), + ("window.panel", "Render Queue", "Kolejka renderowania"), + ("window.panel", "Timeline", "Oś czasu"), + ("window.panel", "Create Nulls From Paths", "Utwórz obiekty zerowe ze ścieżek"), + ("window.panel", "VR Comp Editor", "Edytor kompozycji VR"), + ("", "Help", "Pomoc"), + ("help.docs", "EffectCraft Help...", "Pomoc EffectCraft..."), + ("help.docs", "Scripting Help...", "Pomoc dotycząca skryptów..."), + ("help.docs", "Expression Reference...", "Dokumentacja wyrażeń..."), + ("help.docs", "Effect Reference...", "Dokumentacja efektów..."), + ("anim.browsePresets", "Animation Presets...", "Ustawienia animacji..."), + ("app.keyboardShortcuts", "Keyboard Shortcuts...", "Skróty klawiaturowe..."), + ("help.inAppTutorials", "In-App Tutorials...", "Samouczki w aplikacji..."), + ("help.onlineTutorials", "Online Tutorials...", "Samouczki online..."), + ("help.systemReport", "System Compatibility Report...", "Raport zgodności systemu..."), + ("help.enableLogging", "Enable Logging", "Włącz rejestrowanie"), + ("help.revealLogFile", "Reveal Logging File", "Pokaż plik dziennika"), + ("help.discord", "Join the ArtCraft Discord...", "Dołącz do Discorda ArtCraft..."), + ("help.reportIssue", "Provide Feedback...", "Prześlij opinię..."), + ("help.website", "ArtCraft Website", "Witryna ArtCraft"), + ("help.appPage", "EffectCraft Home Page", "Strona główna EffectCraft"), + ("help.github", "EffectCraft on GitHub", "EffectCraft w serwisie GitHub"), + ("file.openDemoProject", "Open Demo Project", "Otwórz projekt demonstracyjny"), + ("", "Recent Script Files", "Ostatnie pliki skryptów"), + ("layer.newPrimitive", "3D Primitive...", "Bryła podstawowa 3D..."), +]; + /// Czech (cs): original phrase translations of every fixed menu entry. const CZECH: &[(&str, &str, &str)] = &[ ("", "Front", "Přední strana"), @@ -6561,6 +7245,8 @@ const CZECH: &[(&str, &str, &str)] = &[ ("layer.applyTextPreset", "Drop In Lines", "Spustit řádky shora"), ("anim.addKeyframe", "Add Keyframe", "Přidat klíčový snímek"), ("keys.toggleHold", "Toggle Hold Keyframe", "Přepnout podržení klíčového snímku"), + ("keys.setMuted", "Mute Keyframes", "Ztlumit klíčové snímky"), + ("keys.setMuted", "Unmute Keyframes", "Obnovit klíčové snímky"), ("keys.interpolation", "Keyframe Interpolation...", "Interpolace klíčových snímků..."), ("keys.velocity", "Keyframe Velocity...", "Rychlost klíčového snímku..."), ("", "Keyframe Assistant", "Pomocník klíčových snímků"), @@ -7192,6 +7878,8 @@ const GEORGIAN: &[(&str, &str, &str)] = &[ ("layer.applyTextPreset", "Drop In Lines", "სტრიქონების ჩამოვარდნა"), ("anim.addKeyframe", "Add Keyframe", "საკვანძო კადრის დამატება"), ("keys.toggleHold", "Toggle Hold Keyframe", "ფიქსირებული საკვანძო კადრის გადართვა"), + ("keys.setMuted", "Mute Keyframes", "საკვანძო კადრების დადუმება"), + ("keys.setMuted", "Unmute Keyframes", "საკვანძო კადრების დადუმების მოხსნა"), ("keys.interpolation", "Keyframe Interpolation...", "საკვანძო კადრის ინტერპოლაცია..."), ("keys.velocity", "Keyframe Velocity...", "საკვანძო კადრის სიჩქარე..."), ("", "Keyframe Assistant", "საკვანძო კადრების ასისტენტი"), @@ -7512,6 +8200,19 @@ mod tests { println!("total: {total}"); } + /// Prints the translated-row counts per language, the numbers `docs/localization-parity.md` + /// quotes. `cargo xtask i18n-report` runs it with `--nocapture`. + #[test] + fn catalog_row_counts_report() { + println!("Localization catalog rows:"); + for code in CATALOG_CODES { + let menus = menu_catalog(code).map_or(0, |catalog| catalog.len()); + let panels = ui::translated_rows(code); + let effect_names = effects::translated_rows(code); + println!("{code}: menus {menus}, panels {panels}, effect names {effect_names}, total {}", menus + panels + effect_names); + } + } + #[test] fn menu_catalogs_have_no_stale_or_duplicate_rows() { let mut actual = BTreeSet::new(); @@ -7550,6 +8251,43 @@ mod tests { app.session.execute("prefs.set", json!({"key":"general.language", "value":"en"})).unwrap(); assert_eq!(label(&app, "", "Composition"), "Composition"); } + /// Match System in the browser also picks the Russian, French and German catalogs, in the + /// order of the browser's preferences (#1700). + #[test] + fn browser_preferences_choose_russian_french_and_german() { + let tags = |t: &[&str]| t.iter().map(|s| s.to_string()).collect::>(); + for (locales, expected) in + [(vec!["de-DE", "es-ES"], "de"), (vec!["xx-XX", "fr-CA"], "fr"), (vec!["ru-RU", "cs"], "ru"), (vec!["en-US", "de-DE"], "en"), (vec!["xx-XX"], "en")] + { + assert_eq!(super::first_supported(None, &tags(&locales), super::web_draws), expected, "{locales:?}"); + } + } + + /// The Edit menu's Undo and Redo name the history's action in the interface language, also in + /// the languages added with #1700. + #[test] + fn undo_and_redo_are_localized_for_russian_french_and_german() { + for (language, undo, no_undo, redo, no_redo) in [ + ("ru", "Отменить:", "Нечего отменять", "Повторить:", "Нечего повторять"), + ("fr", "Annuler", "Impossible d'annuler", "Rétablir", "Impossible de rétablir"), + ("de", "Rückgängig", "Rückgängig nicht möglich", "Wiederholen", "Wiederholen nicht möglich"), + ] { + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + app.session.execute("prefs.set", json!({"key": "general.language", "value": language})).unwrap(); + let entry_of = |app: &EffectcraftApp, command: &str| { + let (_, e) = effectcraft_engine::menus::entries().into_iter().find(|(_, e)| e.command == command).unwrap(); + entry(app, e, e.label.clone()) + }; + assert_eq!(entry_of(&app, "edit.undo"), no_undo, "{language}"); + assert_eq!(entry_of(&app, "edit.redo"), no_redo, "{language}"); + app.session.execute("comp.new", json!({"name": "A", "duration": 1})).unwrap(); + let action = app.session.history.undo.last().map(|u| u.0.clone()).unwrap(); + assert_eq!(entry_of(&app, "edit.undo"), format!("{undo} {action}"), "{language}"); + app.session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(entry_of(&app, "edit.redo"), format!("{redo} {action}"), "{language}"); + } + } + #[test] fn match_system_uses_the_system_language_where_there_is_a_catalog() { for (locale, language) in [ @@ -7579,6 +8317,10 @@ mod tests { (Some("cs-CZ"), "cs"), (Some("cs_CZ.UTF-8"), "cs"), (Some("CS_cz.UTF-8"), "cs"), + (Some("pl"), "pl"), + (Some("pl-PL"), "pl"), + (Some("pl_PL.UTF-8"), "pl"), + (Some("PL_pl.UTF-8"), "pl"), (Some("ka"), "ka"), (Some("ka-GE"), "ka"), (Some("ka_GE.UTF-8"), "ka"), @@ -7606,6 +8348,7 @@ mod tests { assert_eq!(traditional(&app), system_language() == "zh-hant"); assert_eq!(spanish(&app), system_language() == "es"); assert_eq!(czech(&app), system_language() == "cs"); + assert_eq!(polish(&app), system_language() == "pl"); app.session.execute("prefs.set", json!({"key":"general.language", "value":"ja"})).unwrap(); assert!(japanese(&app)); app.session.execute("prefs.set", json!({"key":"general.language", "value":"zh-hans"})).unwrap(); @@ -7632,6 +8375,7 @@ mod tests { ("es", SPANISH), ("pt-br", PORTUGUESE), ("cs", CZECH), + ("pl", POLISH), ("ka", GEORGIAN), ] { for (command, en, translated) in catalog { @@ -7643,6 +8387,35 @@ mod tests { } } + /// The dock's tab titles are `PanelKind::title()`; the panels the Window menu lists are drawn in + /// the interface language through their `(window.panel, title)` rows. `History` and `ScriptUI + /// Panel` are in no menu and keep their English names, which is what this pins. + #[test] + fn the_dock_draws_every_panel_it_can_in_the_interface_language() { + use crate::dock::PanelKind; + const WITHOUT_A_ROW: &[&str] = &["History", "ScriptUI Panel"]; + for (name, catalog, code) in [("ja", JAPANESE, "ja"), ("zh-hans", SIMPLIFIED_CHINESE, "zh-hans"), ("zh-hant", TRADITIONAL_CHINESE, "zh-hant")] { + // The dock asks both catalogs, so a title may be named by either. + ui::set_current(code); + for p in PanelKind::ALL { + let title = p.title(); + let in_menu = catalog.iter().any(|(c, en, _)| *c == "window.panel" && *en == title); + let in_panel = ui::tr(title) != title; + assert_eq!( + in_menu || in_panel, + !WITHOUT_A_ROW.contains(&title), + "{name}: {title} is {}named by a catalog and {}in the exception list", + if in_menu || in_panel { "" } else { "not " }, + if WITHOUT_A_ROW.contains(&title) { "" } else { "not " } + ); + } + } + ui::set_current("en"); + // The dock asks the catalog rather than drawing the English name. + let dock_ui = include_str!("dock_ui.rs"); + assert!(dock_ui.contains(r#"crate::i18n::command_label("window.panel", title)"#), "dock_ui.rs no longer asks the catalog for the panel titles"); + } + #[test] fn ukrainian_catalog_matches_the_settings_registry() { let codes: Vec<_> = effectcraft_engine::prefs::LANGUAGES.iter().map(|(_, code)| *code).filter(|code| !matches!(*code, "system" | "en")).collect(); @@ -7676,7 +8449,7 @@ mod tests { app.session.execute("prefs.set", json!({"key":"general.language", "value":"ru"})).unwrap(); assert_eq!(entry(&app, &undo, "Can't Undo".into()), "Нечего отменять"); app.session.execute("prefs.set", json!({"key":"general.language", "value":"de"})).unwrap(); - assert_eq!(entry(&app, &undo, "Can't Undo".into()), "Can't Undo", "no German undo wording yet: the shown label"); + assert_eq!(entry(&app, &undo, "Can't Undo".into()), "Rückgängig nicht möglich"); app.session.execute("prefs.set", json!({"key":"general.language", "value":"en"})).unwrap(); assert_eq!(entry(&app, &new_comp, new_comp.label.clone()), "New Composition..."); } diff --git a/crates/ui-egui/src/i18n/effects.rs b/crates/ui-egui/src/i18n/effects.rs index 3a98ac40f..bb7e3715a 100644 --- a/crates/ui-egui/src/i18n/effects.rs +++ b/crates/ui-egui/src/i18n/effects.rs @@ -87,6 +87,13 @@ pub(crate) fn name(id: &str, english: &str) -> String { catalog(current()).and_then(|c| c.row(id)).map_or_else(|| english.to_string(), |(_, translated)| translated.to_string()) } +/// The number of effect-name rows `language` translates, for the row-count report; 0 when it has +/// no catalog or when it is English. +#[cfg(test)] +pub(super) fn translated_rows(language: &str) -> usize { + catalog(language).map_or(0, |c| c.rows.len()) +} + /// The name to draw for an effect group the property tree stores as `stored`: the catalog name, /// plus the instance index the engine appends to a repeat (`Gaussian Blur 2` → `高斯模糊 2`). /// @@ -143,6 +150,7 @@ const SIMPLIFIED_CHINESE: &[Row] = &[ ("ec.3d.fog3d", "Fog 3D", "雾 3D"), ("ec.3d.identifier", "IDentifier", "IDentifier"), ("ec.3d.idmatte", "ID Matte", "ID 遮罩"), + ("ec.3d.instance3d", "3D Layer Instance", "3D 图层实例"), ("ec.audio.backwards", "Backwards", "倒放"), ("ec.audio.basstreble", "Bass & Treble", "低音和高音"), ("ec.audio.compressor", "Compressor", "压缩程序"), @@ -772,12 +780,13 @@ const JAPANESE_CATEGORIES: &[(&str, &str)] = &[ ("Color Correction", "カラー補正"), ("Distort", "ディストーション"), ("Expression Controls", "式制御"), + ("Generate", "描画"), ("Immersive Video", "イマーシブビデオ"), ("Keying", "キーイング"), ("Matte", "マット"), ("Noise & Grain", "ノイズ&グレイン"), ("Obsolete", "旧バージョン"), - ("Paint", "描画"), + ("Paint", "ペイント"), ("Perspective", "遠近"), ("Simulation", "シミュレーション"), ("Stylize", "スタイライズ"), @@ -801,6 +810,7 @@ const GEORGIAN: &[Row] = &[ ("ec.3d.fog3d", "Fog 3D", "3D ნისლი"), ("ec.3d.identifier", "IDentifier", "IDentifier"), ("ec.3d.idmatte", "ID Matte", "ID მატი"), + ("ec.3d.instance3d", "3D Layer Instance", "3D Layer Instance"), ("ec.audio.backwards", "Backwards", "უკუღმა"), ("ec.audio.basstreble", "Bass & Treble", "ბასი და ტრებლი"), ("ec.audio.compressor", "Compressor", "კომპრესორი"), @@ -1283,3 +1293,21 @@ mod tests { assert!(!matches("ec.blur.gaussian", "Gaussian Blur", "高斯")); } } + +#[cfg(test)] +mod japanese_category_tests { + use super::*; + use std::collections::HashMap; + + /// Two categories drawing the same Japanese name would make the Effect menu show one name twice and + /// leave the user unable to tell them apart. `描画` was both `Generate` and `Paint` before #2583. + #[test] + fn no_two_categories_share_a_japanese_name() { + let mut seen: HashMap<&str, &str> = HashMap::new(); + for (english, japanese) in JAPANESE_CATEGORIES { + if let Some(other) = seen.insert(japanese, english) { + panic!("`{english}` and `{other}` both draw as `{japanese}`"); + } + } + } +} diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 207b69a19..1da662aad 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -22,7 +22,7 @@ use std::fmt::Display; use std::sync::OnceLock; thread_local! { - /// The resolved interface language ("en", "ja", "zh-hans", "zh-hant", "uk", "pt-br", "cs", "es", "ka"); "en" until the first + /// The resolved interface language ("en", "ja", "zh-hans", "zh-hant", "uk", "pt-br", "cs", "es", "pl", "ka"); "en" until the first /// frame sets it. static CURRENT: Cell<&'static str> = const { Cell::new("en") }; } @@ -50,6 +50,7 @@ const ROWS: &[(&str, &[(&str, &str)])] = &[ ("pt-br", PORTUGUESE), ("cs", CZECH), ("es", SPANISH), + ("pl", POLISH), ("ka", GEORGIAN), ("ka", GEORGIAN_PANELS), ]; @@ -64,6 +65,7 @@ fn catalog(language: &str) -> Option<&'static Index> { static PT_BR: OnceLock = OnceLock::new(); static CS: OnceLock = OnceLock::new(); static ES: OnceLock = OnceLock::new(); + static PL: OnceLock = OnceLock::new(); static KA: OnceLock = OnceLock::new(); let (index, rows) = match language { "ja" => (&JA, JAPANESE), @@ -73,6 +75,7 @@ fn catalog(language: &str) -> Option<&'static Index> { "pt-br" => (&PT_BR, PORTUGUESE), "cs" => (&CS, CZECH), "es" => (&ES, SPANISH), + "pl" => (&PL, POLISH), // Georgian also reads its own rows for text the other catalogs don't have yet. "ka" => return Some(KA.get_or_init(|| GEORGIAN.iter().chain(GEORGIAN_PANELS).copied().collect())), _ => return None, @@ -92,6 +95,13 @@ pub(super) fn missing(language: &str) -> Vec { rows.chain(literals).filter(|en| !table.is_some_and(|c| c.contains_key(en.as_str())) && seen.insert(en.clone())).collect() } +/// The number of panel/dialog rows `language` translates, for the row-count report; 0 when it has +/// no catalog or when it is English. +#[cfg(test)] +pub(super) fn translated_rows(language: &str) -> usize { + catalog(language).map_or(0, Index::len) +} + /// The string literals `src` passes to `tr(` and `tr_args(`. #[cfg(test)] fn tr_literals(src: &str) -> Vec { @@ -196,6 +206,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Add:", "追加:"), ("Adjust Exposure (stops)", "露出を調整(段単位)"), ("Adjust composition duration to the time span of the selected layers", "コンポジションのデュレーションを、選択したレイヤーの時間範囲に合わせる"), + ("Advanced", "詳細"), ("Advanced 3D", "アドバンスド3D"), ("Align Layers to:", "レイヤーの整列基準:"), ("All six face cameras turn together; the scene is not changed.", "6つの面のカメラがすべて一緒に回転します。シーンは変更されません。"), @@ -434,6 +445,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("No layer selected", "レイヤーが選択されていません"), ("No matching effects or presets", "一致するエフェクトまたはプリセットはありません"), ("No matching fonts", "一致するフォントはありません"), + ("Rescan Fonts", "フォントを再スキャン"), ("Variable fonts only", "可変フォントのみ"), ("No model credits were built into this copy.", "このビルドにはモデルのクレジットが含まれていません。"), ("No nested compositions", "ネストされたコンポジションはありません"), @@ -467,7 +479,9 @@ const JAPANESE: &[(&str, &str)] = &[ ("Overlay", "オーバーレイ"), ("Parameter Name:", "パラメーター名:"), ("Parent pick whip: drag onto a layer", "親ピックウィップ:レイヤー上にドラッグ"), + ("Track Matte pick whip: drag onto a layer", "トラックマットピックウィップ:レイヤー上にドラッグ"), ("Pixel Aspect Ratio:", "ピクセル縦横比:"), + ("Play/Stop", "再生/停止"), ("Playing on Video Preview", "ビデオプレビューで再生中"), ("Post-Render Action: {}", "レンダリング後の処理:{}"), ("Preserve frame rate when nested or in render queue", "ネスト時またはレンダーキュー内でフレームレートを保持"), @@ -901,6 +915,310 @@ const JAPANESE: &[(&str, &str)] = &[ ("Total Offset", "合計オフセット"), ("Per Layer", "レイヤーごと"), ("Track", "トラック"), + ("Force alphabetical order", "アルファベット順を強制"), + ("Frame rate (fps)", "フレームレート (fps)"), + ("Import Kind", "読み込みの種類"), + ("Delay (s)", "遅延 (秒)"), + ("Amount (%)", "量 (%)"), + ("Cascade (farther pins trail longer)", "カスケード (離れたピンほど長く遅れる)"), + ("Speed (%)", "速度 (%)"), + ("Use Draft Deformation", "下書き変形を使用"), + ("Show Mesh", "メッシュを表示"), + ("Scale X (%)", "X 方向の拡大率 (%)"), + ("Scale Y (%)", "Y 方向の拡大率 (%)"), + ("Rotation (degrees)", "回転 (度)"), + ("Move X", "X 移動"), + ("Move Y", "Y 移動"), + ("Position (pixels)", "位置 (ピクセル)"), + ("Position (percent of the composition)", "位置 (コンポジションに対する割合)"), + ("Use Dimensions From", "使用するサイズの基準"), + ("Still Duration (seconds)", "静止画のデュレーション (秒)"), + ("Add to Render Queue", "レンダーキューに追加"), + ("Sequence Layers", "レイヤーをシーケンス化"), + ("Duration (seconds)", "デュレーション (秒)"), + ("Matte color (premultiplied)", "マットの色 (プリマルチプライ)"), + ("Invert Alpha", "アルファを反転"), + ("Assume this frame rate", "このフレームレートとみなす"), + ("Separate Fields", "フィールドを分離"), + ("Pixel Aspect Ratio", "ピクセル縦横比"), + ("Loop (times)", "ループ (回)"), + ("Assign Profile", "プロファイルを割り当て"), + ("Preserve RGB", "RGB を保持"), + ("Interpret As Linear Light", "リニア光として解釈"), + ("Start Timecode", "開始タイムコード"), + ("Override Start", "開始値を上書き"), + ("Time display style", "タイムコード表示"), + ("Color engine", "カラーエンジン"), + ("Working space", "作業用スペース"), + ("Working gamma", "作業用ガンマ"), + ("Linearize working space", "作業用スペースをリニア化"), + ("Blend colors using 1.0 gamma", "1.0 ガンマで色を混合"), + ("Output color space", "出力カラースペース"), + ("Video rendering and effects", "ビデオのレンダリングとエフェクト"), + ("Time Span", "時間範囲"), + ("Blur (pixels before auto-trace)", "ブラー (自動トレース前のピクセル)"), + ("Tolerance (pixels)", "許容値 (ピクセル)"), + ("Threshold (%)", "しきい値 (%)"), + ("Minimum Area (pixels)", "最小領域 (ピクセル)"), + ("Corner Roundness (%)", "角の丸み (%)"), + ("Apply To New Layer", "新しいレイヤーに適用"), + ("Sensitivity threshold (0–1, lower finds more)", "感度のしきい値 (0~1、低いほど多く検出)"), + ("Time field (empty: automatic)", "時間フィールド (空欄: 自動)"), + ("Video start (ISO date-time, hh:mm:ss, timecode; empty: file date)", "ビデオ開始 (ISO 日時、hh:mm:ss、タイムコード、空欄: ファイルの日付)"), + ("First sample at (comp seconds)", "最初のサンプル位置 (コンポジション秒)"), + ("Frame rate", "フレームレート"), + ("Color (#rrggbb)", "カラー (#rrggbb)"), + ("Embed footage files", "フッテージを埋め込む"), + ("Embed up to (MB)", "埋め込み上限 (MB)"), + ("Orient Along Path", "パスに沿って向きを設定"), + ("Orient Towards Camera", "カメラの方向に向ける"), + ("Orient Towards Point of Interest", "対象点の方向に向ける"), + ("Single Composition", "単一コンポジション"), + ("Multiple Compositions", "複数のコンポジション"), + ("Dissolve Front Layer", "前面のレイヤーをディゾルブ"), + ("Cross Dissolve Front and Back Layers", "前後のレイヤーをクロスディゾルブ"), + ("Interpret Straight - Unmatted", "ストレート (マットなし) として解釈"), + ("Interpret Premultiplied - Matted With Color", "プリマルチプライ (色でマット) として解釈"), + ("Upper Field First", "偶数フィールド優先"), + ("Lower Field First", "奇数フィールド優先"), + ("Rec. 2020", "Rec. 2020"), + ("Use Source File Timecode", "ソースファイルのタイムコードを使用"), + ("Feet + Frames (35mm)", "フィート + フレーム (35mm)"), + ("Feet + Frames (16mm)", "フィート + フレーム (16mm)"), + ("Current Frame", "現在のフレーム"), + ("Work Area", "作業領域"), + ("Create Markers", "マーカーを作成"), + ("Split Layers", "レイヤーを分割"), + ("Split and Precompose", "分割してプリコンポーズ"), + ("All layers", "すべてのレイヤー"), + ("None (leave them out)", "なし (除外)"), + ("Nested sequences", "ネストしたシーケンス"), + ("Solid…", "平面…"), + ("Light…", "ライト…"), + ("Camera…", "カメラ…"), + ("Null Object", "ヌルオブジェクト"), + ("Shape Layer", "シェイプレイヤー"), + ("Adjustment Layer", "調整レイヤー"), + ("Layer Settings…", "レイヤー設定…"), + ("Create Shapes from Vector Layer", "ベクターレイヤーからシェイプを作成"), + ("Create Shapes from Text", "テキストからシェイプを作成"), + ("Create Masks from Text", "テキストからマスクを作成"), + ("New Mask", "新しいマスク"), + ("Center In View", "ビュー内で中央揃え"), + ("Fit to Comp", "コンポジションに合わせる"), + ("Flip Horizontal", "水平方向に反転"), + ("Flip Vertical", "垂直方向に反転"), + ("Enable Time Remapping", "時間リマップを有効化"), + ("Time-Reverse Layer", "レイヤーを時間反転"), + ("Freeze Frame", "フレームを固定"), + ("Freeze On Last Frame", "最後のフレームで固定"), + ("Add Marker", "マーカーを追加"), + ("Bring Layer to Front", "レイヤーを最前面へ"), + ("Bring Layer Forward", "レイヤーを前面へ"), + ("Send Layer Backward", "レイヤーを背面へ"), + ("Send Layer to Back", "レイヤーを最背面へ"), + ("Pre-compose…", "プリコンポーズ…"), + ("Split Layer", "レイヤーを分割"), + ("Layer Style Options...", "レイヤースタイルのオプション..."), + ("Convert to Editable Styles", "編集可能なスタイルに変換"), + ("Show All", "すべて表示"), + ("Remove All", "すべて削除"), + ("Separate Dimensions", "次元を分離"), + ("Add Property to Essential Graphics", "エッセンシャルグラフィックスにプロパティを追加"), + ("Continuous Bezier", "連続ベジェ"), + ("Auto Bezier", "自動ベジェ"), + ("Lock To Time", "時間に固定"), + ("Rove Across Time", "時間をまたいで移動"), + ("Anchor Point", "アンカーポイント"), + ("All Transform Properties", "すべてのトランスフォームプロパティ"), + ("Line Anchor", "行のアンカー"), + ("Line Spacing", "行間"), + ("Character Offset", "文字オフセット"), + ("Character Value", "文字値"), + ("Fill Color: RGB", "塗りの色: RGB"), + ("Fill Color: Hue", "塗りの色: 色相"), + ("Fill Color: Saturation", "塗りの色: 彩度"), + ("Fill Color: Brightness", "塗りの色: 明るさ"), + ("Fill Color: Opacity", "塗りの色: 不透明度"), + ("Stroke Color: RGB", "線の色: RGB"), + ("Stroke Color: Hue", "線の色: 色相"), + ("Stroke Color: Saturation", "線の色: 彩度"), + ("Stroke Color: Brightness", "線の色: 明るさ"), + ("Stroke Color: Opacity", "線の色: 不透明度"), + ("Stroke Width", "線幅"), + ("Front: Color", "前面: 色"), + ("Bevel: Color", "ベベル: 色"), + ("Side: Color", "側面: 色"), + ("Back: Color", "背面: 色"), + ("Effect Controls", "エフェクトコントロール"), + ("Effects & Presets", "エフェクト&プリセット"), + ("Motion Sketch", "モーションスケッチ"), + ("Render Queue", "レンダーキュー"), + ("Mask Interpolation", "マスク補間"), + ("Script Console", "スクリプトコンソール"), + ("Essential Graphics", "エッセンシャルグラフィックス"), + ("ScriptUI Panel", "ScriptUI パネル"), + ("Lumetri Scopes", "Lumetri スコープ"), + ("Media Browser", "メディアブラウザー"), + ("Content-Aware Fill", "コンテンツに応じた塗り"), + ("Create Nulls From Paths", "パスからヌルを作成"), + ("VR Comp Editor", "VR コンポジションエディター"), + ("Media Duration", "メディアの長さ"), + ("Frame Rate", "フレームレート"), + ("File Path", "ファイルパス"), + ("Interpret Footage", "フッテージの解釈"), + ("Create a new Folder", "新しいフォルダーを作成"), + ("Create a new Composition", "新しいコンポジションを作成"), + ("First Frame", "最初のフレーム"), + ("Previous Frame", "前のフレーム"), + ("Next Frame", "次のフレーム"), + ("Last Frame", "最後のフレーム"), + ("Name (A–Z)", "名前 (A–Z)"), + ("Merged PRs", "マージ済み PR"), + ("Lines added", "追加行数"), + ("Lines deleted", "削除行数"), + ("Line delta", "行数の差分"), + ("Binary assets added", "追加バイナリ"), + ("Binary assets removed", "削除バイナリ"), + ("First commit", "最初のコミット"), + ("Last commit", "最後のコミット"), + ("Display name", "表示名"), + ("Real name", "本名"), + ("ArtCraft website", "ArtCraft のウェブサイト"), + ("Source code on GitHub", "GitHub のソースコード"), + ("3D Renderer", "3D レンダラー"), + ("Preset Name", "プリセット名"), + ("Save Preset…", "プリセットを保存…"), + ("Delete Preset", "プリセットを削除"), + ("Lock Aspect Ratio", "縦横比を固定"), + ("Frame Rate presets", "フレームレートのプリセット"), + ("Background Color", "背景色"), + ("Basic", "基本"), + ("Shutter Angle", "シャッター角度"), + ("Shutter Phase", "シャッター位相"), + ("Samples Per Frame", "フレームあたりのサンプル数"), + ("Adaptive Sample Limit", "適応サンプル数の上限"), + ("X and Y", "X と Y"), + ("X only", "X のみ"), + ("Y only", "Y のみ"), + ("Track Camera", "カメラをトラッキング"), + ("Warp Stabilizer", "ワープスタビライザー"), + ("Track Motion", "モーションをトラッキング"), + ("Stabilize Motion", "モーションを安定化"), + ("Motion Source:", "モーションソース:"), + ("Current Track:", "現在のトラック:"), + ("Track Type:", "トラックの種類:"), + ("Motion Target:", "モーションの適用先:"), + ("Track Mask:", "トラックマスク:"), + ("Analyze forward", "前方を解析"), + ("Analyze backward", "後方を解析"), + ("Continue Tracking", "トラッキングを続行"), + ("Stop Tracking", "トラッキングを停止"), + ("Adapt Feature", "特徴を適応"), + ("Extrapolate Motion", "モーションを外挿"), + ("Attach Point", "アタッチポイント"), + ("Track Name", "トラック名"), + ("Motion Tracker Options", "モーショントラッカーのオプション"), + ("Motion Tracker Apply Options", "モーショントラッカーの適用オプション"), + ("Edit Value Graph", "値グラフを編集"), + ("Edit Speed Graph", "速度グラフを編集"), + ("Show Reference Graph", "参照グラフを表示"), + ( + "Auto-Select Graph Type (speed graph for spatial properties, else value graph)", + "グラフの種類を自動選択 (空間プロパティは速度グラフ、それ以外は値グラフ)", + ), + ("Auto-zoom graph height", "グラフの高さを自動ズーム"), + ("Fit all graphs to view", "すべてのグラフをビューに合わせる"), + ("Fit selection to view", "選択項目をビューに合わせる"), + ("Snap (key drags snap to the current time and other keys)", "スナップ (キーのドラッグを現在の時間や他のキーに合わせる)"), + ("Show Transform Box when multiple keys are selected", "複数のキーを選択したときにトランスフォームボックスを表示"), + ("Separate Dimensions (Position)", "次元を分離 (位置)"), + ("Convert selected keyframes to Hold", "選択したキーフレームをホールドに変換"), + ("Convert selected keyframes to Linear", "選択したキーフレームをリニアに変換"), + ("Convert selected keyframes to Auto Bezier", "選択したキーフレームを自動ベジェに変換"), + ("Easy Ease (F9)", "イージーイーズ (F9)"), + ("Easy Ease In (Shift+F9)", "イージーイーズイン (Shift+F9)"), + ("Easy Ease Out (Ctrl+Shift+F9)", "イージーイーズアウト (Ctrl+Shift+F9)"), + ("Spatial Path", "空間パス"), + ("Temporal Path", "時間パス"), + ("All the Same", "すべて同じ"), + ("All Independently", "すべて個別"), + ("Noise Type:", "ノイズの種類:"), + ("Apply To:", "適用先:"), + ("Temporal Graph", "時間グラフ"), + ("Keyframe Rate:", "キーフレームレート:"), + ("Capture speed at:", "速度の記録位置:"), + ("Start Capture", "記録を開始"), + ("Cancel Capture", "記録をキャンセル"), + ("Small Caps", "スモールキャップ"), + ("All Small Caps", "すべてスモールキャップ"), + ("Superscript / Superior", "上付き文字"), + ("Subscript / Inferior", "下付き文字"), + ("Standard Ligatures", "標準合字"), + ("Discretionary Ligatures", "任意合字"), + ("Contextual Alternates", "状況依存の代替字形"), + ("Stylistic Alternates", "スタイル代替字形"), + ("Titling Alternates", "タイトル用代替字形"), + ("Default Figure Style", "既定の数字スタイル"), + ("Tabular Lining", "等幅ライニング"), + ("Proportional Oldstyle", "プロポーショナルオールドスタイル"), + ("Proportional Lining", "プロポーショナルライニング"), + ("Tabular Oldstyle", "等幅オールドスタイル"), + ("Fill Over Stroke", "線の上に塗り"), + ("Stroke Over Fill", "塗りの上に線"), + ("Stroke order", "線の順序"), + ("Single-line Composer", "単一行コンポーザー"), + ("Every-line Composer", "全行コンポーザー"), + ("Text direction", "テキストの方向"), + ("Recently Used", "最近使用した項目"), + ("User Presets", "ユーザープリセット"), + ("Remove from Favorites", "お気に入りから削除"), + ("Show Animation Presets", "アニメーションプリセットを表示"), + (" Refresh List", " リストを更新"), + (" Clear Recently Used", " 最近使用した項目を消去"), + ("Show 32 bpc Effects Only", "32 bpc のエフェクトのみ表示"), + ("Show GPU Accelerated Effects Only", "GPU アクセラレーションのエフェクトのみ表示"), + ("Show Effects for All Color Depths", "すべての色深度のエフェクトを表示"), + ("* Animation Presets", "* アニメーションプリセット"), + ("Select a layer to apply to", "適用するレイヤーを選択してください"), + ("Search effects and presets", "エフェクトとプリセットを検索"), + ("Alpha Expansion:", "アルファの拡張:"), + ("Fill Method:", "塗りの方法:"), + ("Edge Blend", "エッジのブレンド"), + ("Entire Duration", "全体のデュレーション"), + ("Reference Frame:", "参照フレーム:"), + ("Use Current Time", "現在の時間を使用"), + ("Generate Fill Layer", "塗りレイヤーを生成"), + ("Lighting Correction:", "ライティングの補正:"), + ("Create Text and Camera", "テキストとカメラを作成"), + ("Create Solid and Camera", "平面とカメラを作成"), + ("Create Null and Camera", "ヌルとカメラを作成"), + ("Create Shadow Catcher, Camera and Light", "シャドウキャッチャー、カメラ、ライトを作成"), + ("Unable to solve camera", "カメラを解析できません"), + ("Waiting to solve", "解析待ち"), + ("Create Camera", "カメラを作成"), + ("track point", "トラックポイント"), + ("Ruler zero point", "ルーラーの原点"), + ("Vertical ruler", "垂直ルーラー"), + ("Horizontal ruler", "水平ルーラー"), + ("Viewer controls", "ビューアーのコントロール"), + ("Resolution/Down Sample Factor", "解像度/ダウンサンプル係数"), + ("Adjust Exposure", "露出を調整"), + ("Snapshot taken", "スナップショットを撮影しました"), + ("Fast Previews", "高速プレビュー"), + ("3D View", "3D ビュー"), + ("Current time", "現在の時間"), + ("Layer Transform", "レイヤーのトランスフォーム"), + ("Reset Transform", "トランスフォームをリセット"), + ("Text Animation", "テキストアニメーション"), + ("Text Animation Presets", "テキストアニメーションのプリセット"), + ("EffectCraft on getartcraft.com", "getartcraft.com の EffectCraft"), + ("Small Screen", "小さい画面"), + ("Tool Creates Shape", "ツールでシェイプを作成"), + ("Tool Creates Mask", "ツールでマスクを作成"), + ("Blend Mode", "描画モード"), + ("Show mesh", "メッシュを表示"), + ("Record Options...", "記録オプション..."), ]; const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ @@ -937,6 +1255,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Add:", "添加:"), ("Adjust Exposure (stops)", "调整曝光(光圈级数)"), ("Adjust composition duration to the time span of the selected layers", "将合成时长调整为所选图层的时间范围"), + ("Advanced", "高级"), ("Advanced 3D", "高级 3D"), ("Align Layers to:", "将图层对齐到:"), ("All six face cameras turn together; the scene is not changed.", "六个面摄像机一起旋转;场景不会改变。"), @@ -1166,6 +1485,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("No layer selected", "未选择图层"), ("No matching effects or presets", "没有匹配的效果或预设"), ("No matching fonts", "没有匹配的字体"), + ("Rescan Fonts", "重新扫描字体"), ("Variable fonts only", "仅可变字体"), ("No model credits were built into this copy.", "此副本未内置模型致谢。"), ("No nested compositions", "没有嵌套合成"), @@ -1199,7 +1519,9 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Overlay", "叠加"), ("Parameter Name:", "参数名称:"), ("Parent pick whip: drag onto a layer", "父级拾取器:拖到图层上"), + ("Track Matte pick whip: drag onto a layer", "轨道遮罩拾取器:拖到图层上"), ("Pixel Aspect Ratio:", "像素长宽比:"), + ("Play/Stop", "播放/停止"), ("Playing on Video Preview", "在视频预览中播放"), ("Post-Render Action: {}", "渲染后动作:{}"), ("Preserve frame rate when nested or in render queue", "嵌套或在渲染队列中时保留帧速率"), @@ -1641,6 +1963,370 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Total Offset", "总偏移"), ("Per Layer", "每个图层"), ("Track", "跟踪"), + ("Selection Tool", "选择工具"), + ("Hand Tool", "抓手工具"), + ("Zoom Tool", "缩放工具"), + ("Orbit Around Cursor Tool", "绕行摄像机工具"), + ("Pan Under Cursor Tool", "平移摄像机工具"), + ("Dolly Towards Cursor Tool", "推拉摄像机工具"), + ("Rotation Tool", "旋转工具"), + ("Pan Behind (Anchor Point) Tool", "向后平移工具"), + ("Rectangle Tool", "矩形工具"), + ("Rounded Rectangle Tool", "圆角矩形工具"), + ("Ellipse Tool", "椭圆工具"), + ("Polygon Tool", "多边形工具"), + ("Star Tool", "星形工具"), + ("Pen Tool", "钢笔工具"), + ("Add Vertex Tool", "添加顶点工具"), + ("Delete Vertex Tool", "删除顶点工具"), + ("Convert Vertex Tool", "转换顶点工具"), + ("Mask Feather Tool", "蒙版羽化工具"), + ("Horizontal Type Tool", "横排文字工具"), + ("Vertical Type Tool", "直排文字工具"), + ("Brush Tool", "画笔工具"), + ("Clone Stamp Tool", "仿制图章工具"), + ("Eraser Tool", "橡皮擦工具"), + ("Roto Brush Tool", "Roto 笔刷工具"), + ("Refine Edge Tool", "调整边缘工具"), + ("Object Matte Tool", "对象遮罩工具"), + ("Puppet Position Pin Tool", "人偶位置控点工具"), + ("Puppet Starch Pin Tool", "人偶扑粉控点工具"), + ("Puppet Bend Pin Tool", "人偶弯曲控点工具"), + ("Puppet Advanced Pin Tool", "人偶高级控点工具"), + ("Puppet Overlap Pin Tool", "人偶重叠控点工具"), + ("Shortcut", "快捷键"), + ("Range", "范围"), + ("Play From", "从以下位置播放"), + ("Frame Rate", "帧速率"), + ("Resolution", "分辨率"), + ("Overlays", "叠加"), + ("Layer Controls", "图层控制"), + ("Loop", "循环"), + ("Cache Before Playback", "播放前缓存"), + ("Pre-roll", "预卷"), + ("Post-roll", "后卷"), + ("If caching, play cached frames", "缓存时播放已缓存帧"), + ("Apply", "应用"), + ("Analyze", "分析"), + ("Create Camera", "创建摄像机"), + ("Add Point", "添加点"), + ("Remove Point", "移除点"), + ("Render", "渲染"), + ("Save Current as Template…", "将当前项存为模板…"), + ("New Composition From Footage", "从素材新建合成"), + ("Search effects and presets", "搜索效果和预设"), + ("Search project", "搜索项目"), + ("Search", "搜索"), + ("(no composition)", "没有合成"), + ("per second", "每秒"), + ("Waveform", "波形"), + ("steps", "步"), + ("Building waveform…", "正在生成波形…"), + ("No effects. Drag one from Effects & Presets.", "没有效果。从效果和预设中拖入一个。"), + ("Click the ring to add a colour; Alt-click a stop (or drag it off) to remove it", "点击色环添加颜色;按住 Alt 点击色标(或将其拖出)可移除"), + ("Start from a ready-made project. Opening a template makes an untitled copy.", "从现成项目开始。打开模板会创建一份未命名副本。"), + ("Square Pixels", "方形像素"), + ("Basic", "基本"), + ("3D Renderer", "3D 渲染器"), + ("Black", "黑色"), + ("White", "白色"), + ("Composition Settings", "合成设置"), + ("Force alphabetical order", "强制按字母顺序"), + ("Frame rate (fps)", "帧速率 (fps)"), + ("Import Kind", "导入类型"), + ("Delay (s)", "延迟 (秒)"), + ("Amount (%)", "数量 (%)"), + ("Cascade (farther pins trail longer)", "级联 (越远的图钉滞后越长)"), + ("Speed (%)", "速度 (%)"), + ("Use Draft Deformation", "使用草稿变形"), + ("Show Mesh", "显示网格"), + ("Scale X (%)", "X 缩放 (%)"), + ("Scale Y (%)", "Y 缩放 (%)"), + ("Rotation (degrees)", "旋转 (度)"), + ("Move X", "移动 X"), + ("Move Y", "移动 Y"), + ("Position (pixels)", "位置 (像素)"), + ("Position (percent of the composition)", "位置 (合成百分比)"), + ("Use Dimensions From", "尺寸来源"), + ("Still Duration (seconds)", "静止图像持续时间 (秒)"), + ("Add to Render Queue", "添加到渲染队列"), + ("Sequence Layers", "序列图层"), + ("Duration (seconds)", "持续时间 (秒)"), + ("Matte color (premultiplied)", "遮罩颜色 (预乘)"), + ("Invert Alpha", "反转 Alpha"), + ("Assume this frame rate", "按此帧速率处理"), + ("Separate Fields", "分离场"), + ("Pixel Aspect Ratio", "像素长宽比"), + ("Loop (times)", "循环 (次)"), + ("Assign Profile", "指定配置文件"), + ("Preserve RGB", "保留 RGB"), + ("Interpret As Linear Light", "解释为线性光"), + ("Start Timecode", "开始时间码"), + ("Override Start", "覆盖开始"), + ("Time display style", "时间显示样式"), + ("Color engine", "颜色引擎"), + ("Working space", "工作空间"), + ("Working gamma", "工作伽马"), + ("Linearize working space", "线性化工作空间"), + ("Blend colors using 1.0 gamma", "使用 1.0 伽马混合颜色"), + ("Output color space", "输出颜色空间"), + ("Video rendering and effects", "视频渲染与效果"), + ("Time Span", "时间范围"), + ("Blur (pixels before auto-trace)", "模糊 (自动描摹前的像素)"), + ("Tolerance (pixels)", "容差 (像素)"), + ("Threshold (%)", "阈值 (%)"), + ("Minimum Area (pixels)", "最小区域 (像素)"), + ("Corner Roundness (%)", "角圆度 (%)"), + ("Apply To New Layer", "应用到新图层"), + ("Sensitivity threshold (0–1, lower finds more)", "灵敏度阈值 (0–1,越低找到的越多)"), + ("Time field (empty: automatic)", "时间字段 (为空: 自动)"), + ("Video start (ISO date-time, hh:mm:ss, timecode; empty: file date)", "视频开始 (ISO 日期时间、hh:mm:ss、时间码;为空: 文件日期)"), + ("First sample at (comp seconds)", "首个采样位置 (合成秒)"), + ("Frame rate", "帧速率"), + ("Color (#rrggbb)", "颜色 (#rrggbb)"), + ("Embed footage files", "嵌入素材文件"), + ("Embed up to (MB)", "嵌入上限 (MB)"), + ("Orient Along Path", "沿路径定向"), + ("Orient Towards Camera", "朝向摄像机"), + ("Orient Towards Point of Interest", "朝向目标点"), + ("Single Composition", "单个合成"), + ("Multiple Compositions", "多个合成"), + ("Dissolve Front Layer", "溶解前层"), + ("Cross Dissolve Front and Back Layers", "交叉溶解前后层"), + ("Interpret Straight - Unmatted", "解释为直接 (未遮罩)"), + ("Interpret Premultiplied - Matted With Color", "解释为预乘 (以颜色遮罩)"), + ("Upper Field First", "上场优先"), + ("Lower Field First", "下场优先"), + ("Rec. 2020", "Rec. 2020"), + ("Use Source File Timecode", "使用源文件时间码"), + ("Feet + Frames (35mm)", "英尺 + 帧 (35mm)"), + ("Feet + Frames (16mm)", "英尺 + 帧 (16mm)"), + ("Current Frame", "当前帧"), + ("Work Area", "工作区域"), + ("Create Markers", "创建标记"), + ("Split Layers", "拆分图层"), + ("Split and Precompose", "拆分并预合成"), + ("All layers", "所有图层"), + ("None (leave them out)", "无 (不含)"), + ("Nested sequences", "嵌套序列"), + ("Solid…", "纯色…"), + ("Light…", "灯光…"), + ("Camera…", "摄像机…"), + ("Null Object", "空对象"), + ("Shape Layer", "形状图层"), + ("Adjustment Layer", "调整图层"), + ("Layer Settings…", "图层设置…"), + ("Create Shapes from Vector Layer", "从矢量图层创建形状"), + ("Create Shapes from Text", "从文本创建形状"), + ("Create Masks from Text", "从文本创建遮罩"), + ("New Mask", "新建遮罩"), + ("Center In View", "在视图中居中"), + ("Fit to Comp", "适应合成"), + ("Flip Horizontal", "水平翻转"), + ("Flip Vertical", "垂直翻转"), + ("Enable Time Remapping", "启用时间重映射"), + ("Time-Reverse Layer", "时间反向图层"), + ("Freeze Frame", "冻结帧"), + ("Freeze On Last Frame", "在最后一帧冻结"), + ("Add Marker", "添加标记"), + ("Bring Layer to Front", "图层置顶"), + ("Bring Layer Forward", "图层上移一层"), + ("Send Layer Backward", "图层下移一层"), + ("Send Layer to Back", "图层置底"), + ("Pre-compose…", "预合成…"), + ("Split Layer", "拆分图层"), + ("Layer Style Options...", "图层样式选项..."), + ("Convert to Editable Styles", "转换为可编辑样式"), + ("Show All", "全部显示"), + ("Remove All", "全部移除"), + ("Separate Dimensions", "分离维度"), + ("Add Property to Essential Graphics", "将属性添加到基本图形"), + ("Continuous Bezier", "连续贝塞尔"), + ("Auto Bezier", "自动贝塞尔"), + ("Lock To Time", "锁定到时间"), + ("Rove Across Time", "跨时间漫游"), + ("Anchor Point", "锚点"), + ("All Transform Properties", "所有变换属性"), + ("Line Anchor", "行锚点"), + ("Line Spacing", "行距"), + ("Character Offset", "字符偏移"), + ("Character Value", "字符值"), + ("Fill Color: RGB", "填充颜色: RGB"), + ("Fill Color: Hue", "填充颜色: 色相"), + ("Fill Color: Saturation", "填充颜色: 饱和度"), + ("Fill Color: Brightness", "填充颜色: 亮度"), + ("Fill Color: Opacity", "填充颜色: 不透明度"), + ("Stroke Color: RGB", "描边颜色: RGB"), + ("Stroke Color: Hue", "描边颜色: 色相"), + ("Stroke Color: Saturation", "描边颜色: 饱和度"), + ("Stroke Color: Brightness", "描边颜色: 亮度"), + ("Stroke Color: Opacity", "描边颜色: 不透明度"), + ("Stroke Width", "描边宽度"), + ("Front: Color", "前面: 颜色"), + ("Bevel: Color", "斜面: 颜色"), + ("Side: Color", "侧面: 颜色"), + ("Back: Color", "背面: 颜色"), + ("Effect Controls", "效果控件"), + ("Effects & Presets", "效果和预设"), + ("Motion Sketch", "运动草图"), + ("Render Queue", "渲染队列"), + ("Mask Interpolation", "遮罩插值"), + ("Script Console", "脚本控制台"), + ("Essential Graphics", "基本图形"), + ("ScriptUI Panel", "ScriptUI 面板"), + ("Lumetri Scopes", "Lumetri 示波器"), + ("Media Browser", "媒体浏览器"), + ("Content-Aware Fill", "内容识别填充"), + ("Create Nulls From Paths", "从路径创建空对象"), + ("VR Comp Editor", "VR 合成编辑器"), + ("Media Duration", "媒体持续时间"), + ("File Path", "文件路径"), + ("Interpret Footage", "解释素材"), + ("Create a new Folder", "新建文件夹"), + ("Create a new Composition", "新建合成"), + ("First Frame", "第一帧"), + ("Previous Frame", "上一帧"), + ("Next Frame", "下一帧"), + ("Last Frame", "最后一帧"), + ("Name (A–Z)", "名称 (A–Z)"), + ("Merged PRs", "已合并 PR"), + ("Lines added", "添加的行数"), + ("Lines deleted", "删除的行数"), + ("Line delta", "行数差异"), + ("Binary assets added", "添加的二进制资源"), + ("Binary assets removed", "删除的二进制资源"), + ("First commit", "首次提交"), + ("Last commit", "最近提交"), + ("Display name", "显示名称"), + ("Real name", "真实姓名"), + ("ArtCraft website", "ArtCraft 网站"), + ("Source code on GitHub", "GitHub 上的源代码"), + ("Preset Name", "预设名称"), + ("Save Preset…", "保存预设…"), + ("Delete Preset", "删除预设"), + ("Lock Aspect Ratio", "锁定长宽比"), + ("Frame Rate presets", "帧速率预设"), + ("Background Color", "背景颜色"), + ("Shutter Angle", "快门角度"), + ("Shutter Phase", "快门相位"), + ("Samples Per Frame", "每帧采样数"), + ("Adaptive Sample Limit", "自适应采样上限"), + ("X and Y", "X 和 Y"), + ("X only", "仅 X"), + ("Y only", "仅 Y"), + ("Track Camera", "跟踪摄像机"), + ("Warp Stabilizer", "变形稳定器"), + ("Track Motion", "跟踪运动"), + ("Stabilize Motion", "稳定运动"), + ("Motion Source:", "运动源:"), + ("Current Track:", "当前跟踪:"), + ("Track Type:", "跟踪类型:"), + ("Motion Target:", "运动目标:"), + ("Track Mask:", "跟踪遮罩:"), + ("Analyze forward", "向前分析"), + ("Analyze backward", "向后分析"), + ("Continue Tracking", "继续跟踪"), + ("Stop Tracking", "停止跟踪"), + ("Adapt Feature", "自适应特征"), + ("Extrapolate Motion", "外推运动"), + ("Attach Point", "附加点"), + ("Track Name", "跟踪名称"), + ("Motion Tracker Options", "运动跟踪器选项"), + ("Motion Tracker Apply Options", "运动跟踪器应用选项"), + ("Edit Value Graph", "编辑值图表"), + ("Edit Speed Graph", "编辑速度图表"), + ("Show Reference Graph", "显示参考图表"), + ("Auto-Select Graph Type (speed graph for spatial properties, else value graph)", "自动选择图表类型 (空间属性用速度图表,否则用值图表)"), + ("Auto-zoom graph height", "自动缩放图表高度"), + ("Fit all graphs to view", "使所有图表适合视图"), + ("Fit selection to view", "使所选内容适合视图"), + ("Snap (key drags snap to the current time and other keys)", "吸附 (关键帧拖动时吸附到当前时间和其他关键帧)"), + ("Show Transform Box when multiple keys are selected", "选择多个关键帧时显示变换框"), + ("Separate Dimensions (Position)", "分离维度 (位置)"), + ("Convert selected keyframes to Hold", "将所选关键帧转换为定格"), + ("Convert selected keyframes to Linear", "将所选关键帧转换为线性"), + ("Convert selected keyframes to Auto Bezier", "将所选关键帧转换为自动贝塞尔"), + ("Easy Ease (F9)", "缓动 (F9)"), + ("Easy Ease In (Shift+F9)", "缓入 (Shift+F9)"), + ("Easy Ease Out (Ctrl+Shift+F9)", "缓出 (Ctrl+Shift+F9)"), + ("Spatial Path", "空间路径"), + ("Temporal Path", "时间路径"), + ("All the Same", "全部相同"), + ("All Independently", "全部独立"), + ("Noise Type:", "噪波类型:"), + ("Apply To:", "应用到:"), + ("Temporal Graph", "时间图表"), + ("Keyframe Rate:", "关键帧速率:"), + ("Capture speed at:", "速度捕获位置:"), + ("Start Capture", "开始捕获"), + ("Cancel Capture", "取消捕获"), + ("Small Caps", "小型大写字母"), + ("All Small Caps", "全部小型大写字母"), + ("Superscript / Superior", "上标"), + ("Subscript / Inferior", "下标"), + ("Standard Ligatures", "标准连字"), + ("Discretionary Ligatures", "可选连字"), + ("Contextual Alternates", "上下文替换"), + ("Stylistic Alternates", "样式替换"), + ("Titling Alternates", "标题替换"), + ("Default Figure Style", "默认数字样式"), + ("Tabular Lining", "等宽线条"), + ("Proportional Oldstyle", "比例旧式"), + ("Proportional Lining", "比例线条"), + ("Tabular Oldstyle", "等宽旧式"), + ("Fill Over Stroke", "填充在描边之上"), + ("Stroke Over Fill", "描边在填充之上"), + ("Stroke order", "描边顺序"), + ("Single-line Composer", "单行书写器"), + ("Every-line Composer", "逐行书写器"), + ("Text direction", "文本方向"), + ("Recently Used", "最近使用"), + ("User Presets", "用户预设"), + ("Remove from Favorites", "从收藏夹移除"), + ("Show Animation Presets", "显示动画预设"), + (" Refresh List", " 刷新列表"), + (" Clear Recently Used", " 清除最近使用"), + ("Show 32 bpc Effects Only", "仅显示 32 bpc 效果"), + ("Show GPU Accelerated Effects Only", "仅显示 GPU 加速效果"), + ("Show Effects for All Color Depths", "显示所有颜色深度的效果"), + ("* Animation Presets", "* 动画预设"), + ("Select a layer to apply to", "请选择要应用到的图层"), + ("Alpha Expansion:", "Alpha 扩展:"), + ("Fill Method:", "填充方式:"), + ("Edge Blend", "边缘混合"), + ("Entire Duration", "整个持续时间"), + ("Reference Frame:", "参考帧:"), + ("Use Current Time", "使用当前时间"), + ("Generate Fill Layer", "生成填充图层"), + ("Lighting Correction:", "光照校正:"), + ("Create Text and Camera", "创建文本和摄像机"), + ("Create Solid and Camera", "创建纯色和摄像机"), + ("Create Null and Camera", "创建空对象和摄像机"), + ("Create Shadow Catcher, Camera and Light", "创建阴影捕捉器、摄像机和灯光"), + ("Unable to solve camera", "无法求解摄像机"), + ("Waiting to solve", "等待求解"), + ("track point", "跟踪点"), + ("Ruler zero point", "标尺零点"), + ("Vertical ruler", "垂直标尺"), + ("Horizontal ruler", "水平标尺"), + ("Viewer controls", "查看器控件"), + ("Resolution/Down Sample Factor", "分辨率/降采样系数"), + ("Adjust Exposure", "调整曝光"), + ("Snapshot taken", "已拍摄快照"), + ("Fast Previews", "快速预览"), + ("3D View", "3D 视图"), + ("Current time", "当前时间"), + ("Layer Transform", "图层变换"), + ("Reset Transform", "重置变换"), + ("Text Animation", "文本动画"), + ("Text Animation Presets", "文本动画预设"), + ("EffectCraft on getartcraft.com", "getartcraft.com 上的 EffectCraft"), + ("Small Screen", "小屏幕"), + ("Tool Creates Shape", "工具创建形状"), + ("Tool Creates Mask", "工具创建遮罩"), + ("Blend Mode", "混合模式"), + ("Show mesh", "显示网格"), + ("Record Options...", "记录选项..."), ]; const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ @@ -1677,6 +2363,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Add:", "添加:"), ("Adjust Exposure (stops)", "調整曝光(光圈級數)"), ("Adjust composition duration to the time span of the selected layers", "將合成時長調整為所選圖層的時間範圍"), + ("Advanced", "進階"), ("Advanced 3D", "高級 3D"), ("Align Layers to:", "將圖層對齊到:"), ("All six face cameras turn together; the scene is not changed.", "六個面攝影機一起旋轉;場景不會改變。"), @@ -1906,6 +2593,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("No layer selected", "未選取圖層"), ("No matching effects or presets", "沒有匹配的效果或預設集"), ("No matching fonts", "沒有符合的字體"), + ("Rescan Fonts", "重新掃描字型"), ("Variable fonts only", "僅可變字型"), ("No model credits were built into this copy.", "此副本未內置模型致謝。"), ("No nested compositions", "沒有嵌套合成"), @@ -1939,7 +2627,9 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Overlay", "疊加"), ("Parameter Name:", "參數名稱:"), ("Parent pick whip: drag onto a layer", "上層拾取線:拖到圖層上"), + ("Track Matte pick whip: drag onto a layer", "軌道遮罩拾取線:拖到圖層上"), ("Pixel Aspect Ratio:", "像素長寬比例:"), + ("Play/Stop", "播放/停止"), ("Playing on Video Preview", "在視訊預覽中播放"), ("Post-Render Action: {}", "算圖後動作:{}"), ("Preserve frame rate when nested or in render queue", "嵌套或在算圖佇列中時保留影格速率"), @@ -2327,6 +3017,370 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Total Offset", "總偏移"), ("Per Layer", "每個圖層"), ("Track", "追蹤"), + ("Selection Tool", "選取工具"), + ("Hand Tool", "手形工具"), + ("Zoom Tool", "縮放工具"), + ("Orbit Around Cursor Tool", "繞行攝影機工具"), + ("Pan Under Cursor Tool", "平移攝影機工具"), + ("Dolly Towards Cursor Tool", "推拉攝影機工具"), + ("Rotation Tool", "旋轉工具"), + ("Pan Behind (Anchor Point) Tool", "向後平移工具"), + ("Rectangle Tool", "矩形工具"), + ("Rounded Rectangle Tool", "圓角矩形工具"), + ("Ellipse Tool", "橢圓工具"), + ("Polygon Tool", "多邊形工具"), + ("Star Tool", "星形工具"), + ("Pen Tool", "筆型工具"), + ("Add Vertex Tool", "添加頂點工具"), + ("Delete Vertex Tool", "刪除頂點工具"), + ("Convert Vertex Tool", "轉換頂點工具"), + ("Mask Feather Tool", "遮色片羽化工具"), + ("Horizontal Type Tool", "橫排文字工具"), + ("Vertical Type Tool", "垂直文字工具"), + ("Brush Tool", "筆刷工具"), + ("Clone Stamp Tool", "仿製圖章工具"), + ("Eraser Tool", "橡皮擦工具"), + ("Roto Brush Tool", "Roto 筆刷工具"), + ("Refine Edge Tool", "調整邊緣工具"), + ("Object Matte Tool", "物件遮色片工具"), + ("Puppet Position Pin Tool", "人偶位置控點工具"), + ("Puppet Starch Pin Tool", "人偶撲粉控點工具"), + ("Puppet Bend Pin Tool", "人偶彎曲控點工具"), + ("Puppet Advanced Pin Tool", "人偶高級控點工具"), + ("Puppet Overlap Pin Tool", "人偶重疊控點工具"), + ("Shortcut", "快速鍵"), + ("Range", "範圍"), + ("Play From", "從下列位置播放"), + ("Frame Rate", "影格速率"), + ("Resolution", "解析度"), + ("Overlays", "覆疊"), + ("Layer Controls", "圖層控制"), + ("Loop", "循環"), + ("Cache Before Playback", "播放前快取"), + ("Pre-roll", "預卷"), + ("Post-roll", "後卷"), + ("If caching, play cached frames", "快取時播放已快取影格"), + ("Apply", "套用"), + ("Analyze", "分析"), + ("Create Camera", "建立攝影機"), + ("Add Point", "添加點"), + ("Remove Point", "移除點"), + ("Render", "算圖"), + ("Save Current as Template…", "將目前項目存為範本…"), + ("New Composition From Footage", "從素材新增合成"), + ("Search effects and presets", "搜尋效果與預設"), + ("Search project", "搜尋專案"), + ("Search", "搜尋"), + ("(no composition)", "沒有合成"), + ("per second", "每秒"), + ("Waveform", "波形"), + ("steps", "步驟"), + ("Building waveform…", "正在產生波形…"), + ("No effects. Drag one from Effects & Presets.", "沒有效果。從效果與預設集中拖入一個。"), + ("Click the ring to add a colour; Alt-click a stop (or drag it off) to remove it", "點擊色環新增顏色;按住 Alt 點擊色標(或將其拖出)即可移除"), + ("Start from a ready-made project. Opening a template makes an untitled copy.", "從現成專案開始。開啟範本會建立一份未命名副本。"), + ("Square Pixels", "方形像素"), + ("Basic", "基本"), + ("3D Renderer", "3D 算圖引擎"), + ("Black", "黑色"), + ("White", "白色"), + ("Composition Settings", "合成設置"), + ("Force alphabetical order", "強制依字母順序"), + ("Frame rate (fps)", "幀率 (fps)"), + ("Import Kind", "匯入類型"), + ("Delay (s)", "延遲 (秒)"), + ("Amount (%)", "數量 (%)"), + ("Cascade (farther pins trail longer)", "階層 (越遠的圖釘延遲越長)"), + ("Speed (%)", "速度 (%)"), + ("Use Draft Deformation", "使用草稿變形"), + ("Show Mesh", "顯示網格"), + ("Scale X (%)", "X 縮放 (%)"), + ("Scale Y (%)", "Y 縮放 (%)"), + ("Rotation (degrees)", "旋轉 (度)"), + ("Move X", "移動 X"), + ("Move Y", "移動 Y"), + ("Position (pixels)", "位置 (像素)"), + ("Position (percent of the composition)", "位置 (合成百分比)"), + ("Use Dimensions From", "尺寸來源"), + ("Still Duration (seconds)", "靜態影像持續時間 (秒)"), + ("Add to Render Queue", "新增至轉譯佇列"), + ("Sequence Layers", "序列圖層"), + ("Duration (seconds)", "持續時間 (秒)"), + ("Matte color (premultiplied)", "遮罩顏色 (預乘)"), + ("Invert Alpha", "反轉 Alpha"), + ("Assume this frame rate", "以此幀率為準"), + ("Separate Fields", "分離場"), + ("Pixel Aspect Ratio", "像素長寬比"), + ("Loop (times)", "循環 (次)"), + ("Assign Profile", "指定設定檔"), + ("Preserve RGB", "保留 RGB"), + ("Interpret As Linear Light", "解譯為線性光"), + ("Start Timecode", "開始時間碼"), + ("Override Start", "覆寫開始"), + ("Time display style", "時間顯示樣式"), + ("Color engine", "色彩引擎"), + ("Working space", "工作空間"), + ("Working gamma", "工作 Gamma"), + ("Linearize working space", "線性化工作空間"), + ("Blend colors using 1.0 gamma", "使用 1.0 Gamma 混合顏色"), + ("Output color space", "輸出色彩空間"), + ("Video rendering and effects", "視訊轉譯與效果"), + ("Time Span", "時間範圍"), + ("Blur (pixels before auto-trace)", "模糊 (自動描摹前的像素)"), + ("Tolerance (pixels)", "容差 (像素)"), + ("Threshold (%)", "閾值 (%)"), + ("Minimum Area (pixels)", "最小區域 (像素)"), + ("Corner Roundness (%)", "角圓度 (%)"), + ("Apply To New Layer", "套用至新圖層"), + ("Sensitivity threshold (0–1, lower finds more)", "靈敏度閾值 (0–1,越低找到的越多)"), + ("Time field (empty: automatic)", "時間欄位 (空白: 自動)"), + ("Video start (ISO date-time, hh:mm:ss, timecode; empty: file date)", "視訊開始 (ISO 日期時間、hh:mm:ss、時間碼;空白: 檔案日期)"), + ("First sample at (comp seconds)", "首個取樣位置 (合成秒)"), + ("Frame rate", "幀率"), + ("Color (#rrggbb)", "顏色 (#rrggbb)"), + ("Embed footage files", "內嵌素材檔案"), + ("Embed up to (MB)", "內嵌上限 (MB)"), + ("Orient Along Path", "沿路徑定向"), + ("Orient Towards Camera", "朝向攝影機"), + ("Orient Towards Point of Interest", "朝向目標點"), + ("Single Composition", "單一合成"), + ("Multiple Compositions", "多個合成"), + ("Dissolve Front Layer", "溶解前層"), + ("Cross Dissolve Front and Back Layers", "交叉溶解前後層"), + ("Interpret Straight - Unmatted", "解譯為直接 (未遮罩)"), + ("Interpret Premultiplied - Matted With Color", "解譯為預乘 (以顏色遮罩)"), + ("Upper Field First", "上場優先"), + ("Lower Field First", "下場優先"), + ("Rec. 2020", "Rec. 2020"), + ("Use Source File Timecode", "使用來源檔案時間碼"), + ("Feet + Frames (35mm)", "英尺 + 幀 (35mm)"), + ("Feet + Frames (16mm)", "英尺 + 幀 (16mm)"), + ("Current Frame", "目前幀"), + ("Work Area", "工作區域"), + ("Create Markers", "建立標記"), + ("Split Layers", "分割圖層"), + ("Split and Precompose", "分割並預合成"), + ("All layers", "所有圖層"), + ("None (leave them out)", "無 (不含)"), + ("Nested sequences", "巢狀序列"), + ("Solid…", "純色…"), + ("Light…", "燈光…"), + ("Camera…", "攝影機…"), + ("Null Object", "空物件"), + ("Shape Layer", "形狀圖層"), + ("Adjustment Layer", "調整圖層"), + ("Layer Settings…", "圖層設定…"), + ("Create Shapes from Vector Layer", "從向量圖層建立形狀"), + ("Create Shapes from Text", "從文字建立形狀"), + ("Create Masks from Text", "從文字建立遮罩"), + ("New Mask", "新增遮罩"), + ("Center In View", "在視圖中置中"), + ("Fit to Comp", "符合合成"), + ("Flip Horizontal", "水平翻轉"), + ("Flip Vertical", "垂直翻轉"), + ("Enable Time Remapping", "啟用時間重對應"), + ("Time-Reverse Layer", "時間反轉圖層"), + ("Freeze Frame", "凍結幀"), + ("Freeze On Last Frame", "在最後一幀凍結"), + ("Add Marker", "新增標記"), + ("Bring Layer to Front", "圖層移至最上層"), + ("Bring Layer Forward", "圖層上移一層"), + ("Send Layer Backward", "圖層下移一層"), + ("Send Layer to Back", "圖層移至最下層"), + ("Pre-compose…", "預合成…"), + ("Split Layer", "分割圖層"), + ("Layer Style Options...", "圖層樣式選項..."), + ("Convert to Editable Styles", "轉換為可編輯樣式"), + ("Show All", "全部顯示"), + ("Remove All", "全部移除"), + ("Separate Dimensions", "分離維度"), + ("Add Property to Essential Graphics", "將屬性新增至基本圖形"), + ("Continuous Bezier", "連續貝茲"), + ("Auto Bezier", "自動貝茲"), + ("Lock To Time", "鎖定至時間"), + ("Rove Across Time", "跨時間漫遊"), + ("Anchor Point", "錨點"), + ("All Transform Properties", "所有變換屬性"), + ("Line Anchor", "行錨點"), + ("Line Spacing", "行距"), + ("Character Offset", "字元偏移"), + ("Character Value", "字元值"), + ("Fill Color: RGB", "填色: RGB"), + ("Fill Color: Hue", "填色: 色相"), + ("Fill Color: Saturation", "填色: 飽和度"), + ("Fill Color: Brightness", "填色: 亮度"), + ("Fill Color: Opacity", "填色: 不透明度"), + ("Stroke Color: RGB", "筆畫顏色: RGB"), + ("Stroke Color: Hue", "筆畫顏色: 色相"), + ("Stroke Color: Saturation", "筆畫顏色: 飽和度"), + ("Stroke Color: Brightness", "筆畫顏色: 亮度"), + ("Stroke Color: Opacity", "筆畫顏色: 不透明度"), + ("Stroke Width", "筆畫寬度"), + ("Front: Color", "前面: 顏色"), + ("Bevel: Color", "斜角: 顏色"), + ("Side: Color", "側面: 顏色"), + ("Back: Color", "背面: 顏色"), + ("Effect Controls", "效果控制項"), + ("Effects & Presets", "效果與預設集"), + ("Motion Sketch", "運動草圖"), + ("Render Queue", "轉譯佇列"), + ("Mask Interpolation", "遮罩插補"), + ("Script Console", "指令碼主控台"), + ("Essential Graphics", "基本圖形"), + ("ScriptUI Panel", "ScriptUI 面板"), + ("Lumetri Scopes", "Lumetri 示波器"), + ("Media Browser", "媒體瀏覽器"), + ("Content-Aware Fill", "內容感知填色"), + ("Create Nulls From Paths", "從路徑建立空物件"), + ("VR Comp Editor", "VR 合成編輯器"), + ("Media Duration", "媒體持續時間"), + ("File Path", "檔案路徑"), + ("Interpret Footage", "解譯素材"), + ("Create a new Folder", "新增資料夾"), + ("Create a new Composition", "新增合成"), + ("First Frame", "第一幀"), + ("Previous Frame", "上一幀"), + ("Next Frame", "下一幀"), + ("Last Frame", "最後一幀"), + ("Name (A–Z)", "名稱 (A–Z)"), + ("Merged PRs", "已合併 PR"), + ("Lines added", "新增的行數"), + ("Lines deleted", "刪除的行數"), + ("Line delta", "行數差異"), + ("Binary assets added", "新增的二進位資源"), + ("Binary assets removed", "刪除的二進位資源"), + ("First commit", "首次提交"), + ("Last commit", "最近提交"), + ("Display name", "顯示名稱"), + ("Real name", "真實姓名"), + ("ArtCraft website", "ArtCraft 網站"), + ("Source code on GitHub", "GitHub 上的原始碼"), + ("Preset Name", "預設集名稱"), + ("Save Preset…", "儲存預設集…"), + ("Delete Preset", "刪除預設集"), + ("Lock Aspect Ratio", "鎖定長寬比"), + ("Frame Rate presets", "幀率預設集"), + ("Background Color", "背景顏色"), + ("Shutter Angle", "快門角度"), + ("Shutter Phase", "快門相位"), + ("Samples Per Frame", "每幀取樣數"), + ("Adaptive Sample Limit", "自適應取樣上限"), + ("X and Y", "X 和 Y"), + ("X only", "僅 X"), + ("Y only", "僅 Y"), + ("Track Camera", "追蹤攝影機"), + ("Warp Stabilizer", "彎曲穩定器"), + ("Track Motion", "追蹤運動"), + ("Stabilize Motion", "穩定運動"), + ("Motion Source:", "運動來源:"), + ("Current Track:", "目前追蹤:"), + ("Track Type:", "追蹤類型:"), + ("Motion Target:", "運動目標:"), + ("Track Mask:", "追蹤遮罩:"), + ("Analyze forward", "向前分析"), + ("Analyze backward", "向後分析"), + ("Continue Tracking", "繼續追蹤"), + ("Stop Tracking", "停止追蹤"), + ("Adapt Feature", "自適應特徵"), + ("Extrapolate Motion", "外推運動"), + ("Attach Point", "附加點"), + ("Track Name", "追蹤名稱"), + ("Motion Tracker Options", "運動追蹤器選項"), + ("Motion Tracker Apply Options", "運動追蹤器套用選項"), + ("Edit Value Graph", "編輯數值圖表"), + ("Edit Speed Graph", "編輯速度圖表"), + ("Show Reference Graph", "顯示參考圖表"), + ("Auto-Select Graph Type (speed graph for spatial properties, else value graph)", "自動選擇圖表類型 (空間屬性用速度圖表,否則用數值圖表)"), + ("Auto-zoom graph height", "自動縮放圖表高度"), + ("Fit all graphs to view", "使所有圖表符合視圖"), + ("Fit selection to view", "使所選內容符合視圖"), + ("Snap (key drags snap to the current time and other keys)", "貼齊 (關鍵影格拖曳時貼齊目前時間和其他關鍵影格)"), + ("Show Transform Box when multiple keys are selected", "選擇多個關鍵影格時顯示變換框"), + ("Separate Dimensions (Position)", "分離維度 (位置)"), + ("Convert selected keyframes to Hold", "將所選關鍵影格轉換為定格"), + ("Convert selected keyframes to Linear", "將所選關鍵影格轉換為線性"), + ("Convert selected keyframes to Auto Bezier", "將所選關鍵影格轉換為自動貝茲"), + ("Easy Ease (F9)", "緩和 (F9)"), + ("Easy Ease In (Shift+F9)", "緩入 (Shift+F9)"), + ("Easy Ease Out (Ctrl+Shift+F9)", "緩出 (Ctrl+Shift+F9)"), + ("Spatial Path", "空間路徑"), + ("Temporal Path", "時間路徑"), + ("All the Same", "全部相同"), + ("All Independently", "全部獨立"), + ("Noise Type:", "雜訊類型:"), + ("Apply To:", "套用至:"), + ("Temporal Graph", "時間圖表"), + ("Keyframe Rate:", "關鍵影格率:"), + ("Capture speed at:", "速度擷取位置:"), + ("Start Capture", "開始擷取"), + ("Cancel Capture", "取消擷取"), + ("Small Caps", "小型大寫字母"), + ("All Small Caps", "全部小型大寫字母"), + ("Superscript / Superior", "上標"), + ("Subscript / Inferior", "下標"), + ("Standard Ligatures", "標準連字"), + ("Discretionary Ligatures", "選用連字"), + ("Contextual Alternates", "上下文替代"), + ("Stylistic Alternates", "樣式替代"), + ("Titling Alternates", "標題替代"), + ("Default Figure Style", "預設數字樣式"), + ("Tabular Lining", "等寬襯線數字"), + ("Proportional Oldstyle", "比例舊式"), + ("Proportional Lining", "比例襯線數字"), + ("Tabular Oldstyle", "等寬舊式"), + ("Fill Over Stroke", "填色在筆畫之上"), + ("Stroke Over Fill", "筆畫在填色之上"), + ("Stroke order", "筆畫順序"), + ("Single-line Composer", "單行排版器"), + ("Every-line Composer", "逐行排版器"), + ("Text direction", "文字方向"), + ("Recently Used", "最近使用"), + ("User Presets", "使用者預設集"), + ("Remove from Favorites", "從我的最愛移除"), + ("Show Animation Presets", "顯示動畫預設集"), + (" Refresh List", " 重新整理清單"), + (" Clear Recently Used", " 清除最近使用"), + ("Show 32 bpc Effects Only", "僅顯示 32 bpc 效果"), + ("Show GPU Accelerated Effects Only", "僅顯示 GPU 加速效果"), + ("Show Effects for All Color Depths", "顯示所有色彩深度的效果"), + ("* Animation Presets", "* 動畫預設集"), + ("Select a layer to apply to", "請選擇要套用的圖層"), + ("Alpha Expansion:", "Alpha 擴張:"), + ("Fill Method:", "填色方式:"), + ("Edge Blend", "邊緣混合"), + ("Entire Duration", "整個持續時間"), + ("Reference Frame:", "參考幀:"), + ("Use Current Time", "使用目前時間"), + ("Generate Fill Layer", "產生填色圖層"), + ("Lighting Correction:", "光照校正:"), + ("Create Text and Camera", "建立文字和攝影機"), + ("Create Solid and Camera", "建立純色和攝影機"), + ("Create Null and Camera", "建立空物件和攝影機"), + ("Create Shadow Catcher, Camera and Light", "建立陰影擷取器、攝影機和燈光"), + ("Unable to solve camera", "無法求解攝影機"), + ("Waiting to solve", "等待求解"), + ("track point", "追蹤點"), + ("Ruler zero point", "尺規零點"), + ("Vertical ruler", "垂直尺規"), + ("Horizontal ruler", "水平尺規"), + ("Viewer controls", "檢視器控制項"), + ("Resolution/Down Sample Factor", "解析度/降取樣係數"), + ("Adjust Exposure", "調整曝光"), + ("Snapshot taken", "已拍攝快照"), + ("Fast Previews", "快速預覽"), + ("3D View", "3D 視圖"), + ("Current time", "目前時間"), + ("Layer Transform", "圖層變換"), + ("Reset Transform", "重設變換"), + ("Text Animation", "文字動畫"), + ("Text Animation Presets", "文字動畫預設集"), + ("EffectCraft on getartcraft.com", "getartcraft.com 上的 EffectCraft"), + ("Small Screen", "小螢幕"), + ("Tool Creates Shape", "工具建立形狀"), + ("Tool Creates Mask", "工具建立遮罩"), + ("Blend Mode", "混合模式"), + ("Show mesh", "顯示網格"), + ("Record Options...", "記錄選項..."), ]; /// Ukrainian (uk): original translations of the same interface rows as the Chinese catalogs. @@ -2606,6 +3660,7 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("No layer selected", "Шар не виділено"), ("No matching effects or presets", "Не знайдено відповідних ефектів або наборів"), ("No matching fonts", "Не знайдено відповідних шрифтів"), + ("Rescan Fonts", "Повторно сканувати шрифти"), ("Variable fonts only", "Лише змінні шрифти"), ("No model credits were built into this copy.", "Ця копія не містить відомостей про моделі."), ("No nested compositions", "Немає вкладених композицій"), @@ -2639,6 +3694,7 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("Overlay", "Накладання"), ("Parameter Name:", "Назва параметра:"), ("Parent pick whip: drag onto a layer", "Зв’язування батьківського шару: перетягніть на шар"), + ("Track Matte pick whip: drag onto a layer", "Зв’язування трекової маски: перетягніть на шар"), ("Pixel Aspect Ratio:", "Пропорції пікселя:"), ("Playing on Video Preview", "Відтворення на відеоперегляді"), ("Post-Render Action: {}", "Дія після рендерингу: {}"), @@ -3140,6 +4196,7 @@ const PORTUGUESE: &[(&str, &str)] = &[ ("New", "Novo"), ("New Comp from Selection", "Nova composição a partir da seleção"), ("Replace with Precomposition", "Substituir por pré-composição"), + ("Rescan Fonts", "Reanalisar fontes"), ("used once", "usado uma vez"), ("used {} times", "usado {} vezes"), ("Compositions that use this item", "Composições que usam este item"), @@ -3192,6 +4249,7 @@ const PORTUGUESE: &[(&str, &str)] = &[ ("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"), + ("Track Matte pick whip: drag onto a layer", "Pick whip de track matte: 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: {}"), @@ -3654,6 +4712,753 @@ const PORTUGUESE: &[(&str, &str)] = &[ ("files", "arquivos"), ]; +/// Polish (pl): original translations covering every row in the Chinese panel catalogs. +const POLISH: &[(&str, &str)] = &[ + ("Appearance Settings…", "Ustawienia wyglądu…"), + ("Add to favorites", "Dodaj do ulubionych"), + ("Remove from favorites", "Usuń z ulubionych"), + (" OK ", " OK "), + (" Delete ", " Usuń "), + (" Open Auto-Save ", " Otwórz autozapis "), + ("(missing)", "(brak)"), + ("360 View Orientation", "Orientacja widoku 360"), + ("3:2 Pulldown: {}", "Pulldown 3:2: {}"), + ( + "A clean-room, open-source compositor for motion graphics and visual effects: native on macOS, Windows and Linux, and in the browser. Part of the ArtCraft family of creative apps.", + "Kompozytor grafiki ruchomej i efektów wizualnych, open source i tworzony metodą clean-room: natywny w macOS, Windows i Linuksie oraz w przeglądarce. Część rodziny aplikacji twórczych ArtCraft.", + ), + ("About EffectCraft", "O programie EffectCraft"), + ("About...", "O programie..."), + ("Active", "Aktywny"), + ("Active Camera", "Aktywna kamera"), + ("Alpha", "Alfa"), + ("Adapt Feature On Every Frame", "Dostosuj cechę w każdej klatce"), + ("Adaptive Sample Limit:", "Adaptacyjny limit próbek:"), + ("Add Animator", "Dodaj animator"), + ("Add Comment", "Dodaj komentarz"), + ("Add Formatting", "Dodaj formatowanie"), + ("Add Group", "Dodaj grupę"), + ("Add Mirror", "Dodaj lustro"), + ("Add Selected As", "Dodaj zaznaczone jako"), + ("Add a comment", "Dodaj komentarz"), + ("Add a shape, fill, stroke or path operation", "Dodaj kształt, wypełnienie, obrys lub operację na ścieżce"), + ("Add to Composition", "Dodaj do kompozycji"), + ("Add to Favorites", "Dodaj do ulubionych"), + ("Add:", "Dodaj:"), + ("Adjust Exposure (stops)", "Dostosuj ekspozycję (EV)"), + ("Adjust composition duration to the time span of the selected layers", "Dopasuj czas trwania kompozycji do zakresu czasu zaznaczonych warstw"), + ("Advanced 3D", "Zaawansowane 3D"), + ("Align Layers to:", "Wyrównaj warstwy do:"), + ("All six face cameras turn together; the scene is not changed.", "Wszystkie sześć kamer ścian obraca się razem; scena się nie zmienia."), + ("Anchor:", "Zakotwiczenie:"), + ("Aperture", "Przysłona"), + ("Appearance Mode", "Tryb wyglądu"), + ("Appearance Mode: {}", "Tryb wyglądu: {}"), + ("Apply Dimensions:", "Zastosuj wymiary:"), + ("Apply Motion To:", "Zastosuj ruch do:"), + ("Arrange", "Kolejność"), + ("Assign", "Przypisz"), + ("Auto", "Auto"), + ("Auto-saves", "Autozapisy"), + ("Back", "Tył"), + ("Background Color:", "Kolor tła:"), + ("Blend Mode:", "Tryb mieszania:"), + ("Blending Mode", "Tryb mieszania"), + ("Blur", "Rozmycie"), + ("Blur Level", "Poziom rozmycia"), + ("Boundary", "Granica"), + ("Browser Storage", "Pamięć przeglądarki"), + ("Browse…", "Przeglądaj…"), + ("Brush Dynamics", "Dynamika pędzla"), + ("Caching frames…", "Buforowanie klatek…"), + ("Cancel", "Anuluj"), + ("Casts Shadows", "Rzuca cienie"), + ("Change Renderer…", "Zmień renderer…"), + ( + "Changing a shortcut creates a custom preset; the default stays as it is.", + "Zmiana skrótu tworzy niestandardowe ustawienie; domyślne pozostaje bez zmian.", + ), + ("Channel", "Kanał"), + ("Channel:", "Kanał:"), + ("Chapter:", "Rozdział:"), + ("Choose...", "Wybierz..."), + ("Classic 3D", "Klasyczne 3D"), + ("Clear", "Wyczyść"), + ("Clear list", "Wyczyść listę"), + ("Click a key to see its commands.", "Kliknij klawisz, aby zobaczyć jego polecenia."), + ( + "Click above the bar to add an opacity stop, below it to add a color stop.", + "Kliknij nad paskiem, aby dodać punkt krycia, pod nim, aby dodać punkt koloru.", + ), + ("Click adds a point (½ height = no change)", "Kliknięcie dodaje punkt (½ wysokości = bez zmian)"), + ("Click adds a point; drag one off to remove", "Kliknięcie dodaje punkt; przeciągnij go poza obszar, aby usunąć"), + ("Click to make this viewer active", "Kliknij, aby uaktywnić tę przeglądarkę"), + ("Click to reveal the expression in the timeline", "Kliknij, aby pokazać wyrażenie na osi czasu"), + ("Click to show the active composition here", "Kliknij, aby pokazać tu aktywną kompozycję"), + ("Click, then click in the Composition panel", "Kliknij, a potem kliknij w panelu Kompozycja"), + ("Clone Options", "Opcje klonowania"), + ("Clone Source Overlay:", "Nakładka źródła klonowania:"), + ("Close", "Zamknij"), + ("Close Panel", "Zamknij panel"), + ("Close tutorial", "Zamknij samouczek"), + ("Color", "Kolor"), + ("Color Depth: {}", "Głębia kolorów: {}"), + ("Color:", "Kolor:"), + ("Colour map", "Mapa kolorów"), + ("Columns", "Kolumny"), + ("Comment:", "Komentarz:"), + ("Community", "Społeczność"), + ("Composition", "Kompozycja"), + ("Composition Mini-Flowchart", "Miniaturowy schemat kompozycji"), + ("Composition Name:", "Nazwa kompozycji:"), + ("Composition Settings ▸ 3D Renderer", "Ustawienia kompozycji ▸ Renderer 3D"), + ("Composition Settings…", "Ustawienia kompozycji…"), + ("Composition marker bin: drag a marker to the time ruler", "Pojemnik znaczników kompozycji: przeciągnij znacznik na linijkę czasu"), + ("Cone Angle", "Kąt stożka"), + ("Cone Feather", "Wtapianie stożka"), + ("Constrain Proportions", "Zachowaj proporcje"), + ("Continuous (Lock Outgoing to Incoming)", "Ciągły (zablokuj wyjście do wejścia)"), + ("Cue Point", "Punkt sygnału"), + ("Current Render", "Bieżące renderowanie"), + ("Dark", "Ciemny"), + ("Dark Theme", "Ciemny motyw"), + ("Darker", "Ciemniej"), + ("Default Output:", "Domyślne wyjście:"), + ("Default: {}", "Domyślnie: {}"), + ("Defaults", "Domyślne"), + ("Delete", "Usuń"), + ("Delete Selected Points", "Usuń zaznaczone punkty"), + ("Delete selected project items", "Usuń zaznaczone elementy projektu"), + ("Delete this template", "Usuń ten szablon"), + ("Depth of Field", "Głębia ostrości"), + ("Discord", "Discord"), + ("Distribute Layers:", "Rozmieść warstwy:"), + ("Don't Recover", "Nie odzyskuj"), + ("Don't Save", "Nie zapisuj"), + ("Double-click a layer to open it in the Layer panel", "Kliknij dwukrotnie warstwę, aby otworzyć ją w panelu Warstwa"), + ("Double-click footage in the Project panel to open it here", "Kliknij dwukrotnie materiał w panelu Projekt, aby otworzyć go tutaj"), + ("Double-click to edit the comment", "Kliknij dwukrotnie, aby edytować komentarz"), + ("Download", "Pobierz"), + ( + "Drag properties here from the timeline,\nor use Animation > Add Property to Essential Graphics.", + "Przeciągnij tu właściwości z osi czasu\nalbo użyj Animacja > Dodaj właściwość do Essential Graphics.", + ), + ("Drag to change", "Przeciągnij, aby zmienić"), + ("Drag to create a guide", "Przeciągnij, aby utworzyć linię pomocniczą"), + ("Drag to slip the source", "Przeciągnij, aby przesunąć źródło"), + ("Drop Frame", "Z pomijaniem klatek"), + ("Duplicate", "Powiel"), + ("Duration:", "Czas trwania:"), + ("Edit Gradient…", "Edytuj gradient…"), + ("Edit Target: {}", "Cel edycji: {}"), + ("Edit in the Timeline", "Edytuj na osi czasu"), + ("EffectCraft didn't quit normally last time.", "EffectCraft nie zakończył się poprawnie ostatnim razem."), + ("Effects & Presets options", "Opcje efektów i ustawień"), + ("Copy Presets Folder Path", "Kopiuj ścieżkę folderu ustawień"), + ("Effects: {}", "Efekty: {}"), + ("Empty folder", "Pusty folder"), + ("Enable Depth of Field", "Włącz głębię ostrości"), + ("Enable Expression", "Włącz wyrażenie"), + ("Enhance", "Ulepsz"), + ("Essential Properties — {}", "Najważniejsze właściwości — {}"), + ("Event", "Zdarzenie"), + ("Export Template…", "Eksportuj szablon…"), + ("Export...", "Eksportuj..."), + ("Expression Language Menu", "Menu języka wyrażeń"), + ("Expression pick whip: drag onto a property", "Łącznik wyrażenia: przeciągnij na właściwość"), + ("Expression:", "Wyrażenie:"), + ("Eyedropper: click, then click in the Composition panel", "Kroplomierz: kliknij, a potem kliknij w panelu Kompozycja"), + ("Falloff", "Zanikanie"), + ("Falloff Distance", "Odległość zanikania"), + ("Favorites", "Ulubione"), + ("Field Render: {}", "Renderowanie pól: {}"), + ("Figures", "Cyfry"), + ("Fill Target", "Cel wypełnienia"), + ("Finished", "Zakończono"), + ("Flowchart: {}", "Schemat: {}"), + ("Focus Distance", "Odległość ostrości"), + ("Follow Feature Rotation and Scale", "Podążaj za obrotem i skalą cechy"), + ("Footage: {}", "Materiał: {}"), + ("Format: {}", "Format: {}"), + ("Frame Aspect Ratio: {}", "Proporcje klatki: {}"), + ("Frame Blending: {}", "Mieszanie klatek: {}"), + ("Frame Rate:", "Liczba klatek na sekundę:"), + ("Frame Render Time {}ms", "Czas renderowania klatki {}ms"), + ("Frame Target:", "Cel klatki:"), + ("Freeze", "Zamroź"), + ("Grab bag", "Różności"), + ("Gradient Editor", "Edytor gradientu"), + ("Grid", "Siatka"), + ("Guide Layers: {}", "Warstwy pomocnicze: {}"), + ("Guides", "Linie pomocnicze"), + ("Height:", "Wysokość:"), + ("Hide This", "Ukryj to"), + ("Histogram", "Histogram"), + ("Hold In Place", "Zatrzymaj w miejscu"), + ("Home", "Start"), + ("If Confidence is Below", "Jeśli pewność jest poniżej"), + ("Import", "Importuj"), + ("Import and Add to Composition", "Importuj i dodaj do kompozycji"), + ("Import...", "Importuj..."), + ("Imported media", "Zaimportowane multimedia"), + ("Influence:", "Wpływ:"), + ("Install from File…", "Zainstaluj z pliku…"), + ("Intensity", "Natężenie"), + ("Inverted", "Odwrócone"), + ("Join the ArtCraft community on Discord", "Dołącz do społeczności ArtCraft na Discordzie"), + ("Key: {}", "Klawisz: {}"), + ("LEARN", "NAUKA"), + ("Label", "Etykieta"), + ("Label Colors and Names:", "Kolory i nazwy etykiet:"), + ("Label:", "Etykieta:"), + ("Latest auto-save: {}", "Ostatni autozapis: {}"), + ("Layer", "Warstwa"), + ("Layer controls", "Elementy sterujące warstwy"), + ("Layer:", "Warstwa:"), + ("Layer: {}", "Warstwa: {}"), + ("Learn", "Nauka"), + ("Leave all attributes in \"{}\"", "Zachowaj wszystkie atrybuty w „{}”"), + ("Licence: {}", "Licencja: {}"), + ("Ligatures", "Ligatury"), + ("Light", "Jasny"), + ("Light Theme", "Jasny motyw"), + ("Light Type", "Typ światła"), + ("Lighter", "Jaśniej"), + ("Link Selected Property", "Połącz zaznaczoną właściwość"), + ("Location:", "Lokalizacja:"), + ("Lock Aspect Ratio to {}", "Zablokuj proporcje na {}"), + ("Lock to Zoom", "Zablokuj do powiększenia"), + ("Locked: {} — {}", "Zablokowano: {} — {}"), + ("Log:", "Dziennik:"), + ("Loop (Trace Path)", "Pętla (śledź ścieżkę)"), + ("MIT OR Apache-2.0 • Fonts: Inter, JetBrains Mono (OFL)", "MIT OR Apache-2.0 • Czcionki: Inter, JetBrains Mono (OFL)"), + ("Magnification ratio popup", "Menu współczynnika powiększenia"), + ("Make Comp Size", "Ustaw rozmiar kompozycji"), + ("Markers", "Znaczniki"), + ("Masks", "Maski"), + ("Maximize Panel", "Maksymalizuj panel"), + ("Media Replacement", "Zamiana multimediów"), + ("Mesh:", "Siatka:"), + ("Diameter:", "Średnica:"), + ("Method Used: {} Average Error: {} pixels", "Użyta metoda: {} Średni błąd: {} px"), + ("Models folder: {}", "Folder modeli: {}"), + ("Modifiers:", "Modyfikatory:"), + ("More", "Więcej"), + ("More ArtCraft apps", "Więcej aplikacji ArtCraft"), + ("More tutorials", "Więcej samouczków"), + ("Motion Blur", "Rozmycie ruchu"), + ("Motion Blur: {}", "Rozmycie ruchu: {}"), + ("Motion Sketch: drag to record", "Szkic ruchu: przeciągnij, aby nagrać"), + ("Move Down", "Przesuń w dół"), + ("Move Up", "Przesuń w górę"), + ("Move to Project Root", "Przenieś do katalogu głównego projektu"), + ("Name", "Nazwa"), + ("Name:", "Nazwa:"), + ("Navigation", "Nawigacja"), + ("New", "Nowy"), + ("New Comp from Selection", "Nowa kompozycja z zaznaczenia"), + ("Replace with Precomposition", "Zastąp kompozycją wstępną"), + ("New Duration:", "Nowy czas trwania:"), + ("New composition name:", "Nazwa nowej kompozycji:"), + ("New from Render Queue item:", "Nowy z elementu kolejki renderowania:"), + ("New from Template", "Nowy z szablonu"), + ("Next", "Dalej"), + ("Nice work. Pick another tutorial on the Home screen's Learn tab.", "Dobra robota. Wybierz kolejny samouczek na karcie Nauka ekranu startowego."), + ( + "No VR environments yet. Create one with Composition ▸ VR ▸ Create VR Environment…", + "Brak środowisk VR. Utwórz je poleceniem Kompozycja ▸ VR ▸ Utwórz środowisko VR…", + ), + ("No audio layer selected", "Nie zaznaczono warstwy audio"), + ("No background jobs running", "Brak zadań w tle"), + ("No composition markers", "Brak znaczników kompozycji"), + ("No contributor data was built into this copy.", "Ta kopia nie zawiera danych o współtwórcach."), + ("No device list from this host; the system default output is used.", "Ten system nie podaje listy urządzeń; używane jest domyślne wyjście systemowe."), + ("No layer selected", "Nie zaznaczono warstwy"), + ("No matching effects or presets", "Brak pasujących efektów lub ustawień"), + ("No matching fonts", "Brak pasujących czcionek"), + ("Variable fonts only", "Tylko czcionki zmienne"), + ("No model credits were built into this copy.", "Ta kopia nie zawiera informacji o modelach."), + ("No nested compositions", "Brak zagnieżdżonych kompozycji"), + ("No shortcut", "Brak skrótu"), + ("No unlocked layer selected", "Nie zaznaczono odblokowanej warstwy"), + ("No video", "Brak wideo"), + ("None", "Brak"), + ("Not applied: tick it in the list to add it.", "Niezastosowany: zaznacz go na liście, aby go dodać."), + ("Not assigned", "Nieprzypisany"), + ("Note: Shadows are only cast from layers with Cast Shadows enabled.", "Uwaga: cienie rzucają tylko warstwy z włączoną opcją Rzuca cienie."), + ("Notify", "Powiadom"), + ("OK", "OK"), + ("Offset:", "Przesunięcie:"), + ("On ({}) stop:", "Punkt na ({}):"), + ("One-Node Camera", "Kamera jednowęzłowa"), + ("Opacity:", "Krycie:"), + ("Open Composition", "Otwórz kompozycję"), + ("used once", "użyty raz"), + ("used {} times", "użycia: {}"), + ("Compositions that use this item", "Kompozycje, które używają tego elementu"), + ("Open Last Saved Project", "Otwórz ostatnio zapisany projekt"), + ("Open New Composition", "Otwórz nową kompozycję"), + ("Open Output", "Otwórz wynik"), + ("Open a composition to build Essential Graphics", "Otwórz kompozycję, aby zbudować Essential Graphics"), + ("Open a composition to see its scopes", "Otwórz kompozycję, aby zobaczyć jej zakresy"), + ("Open a composition to track motion.", "Otwórz kompozycję, aby śledzić ruch."), + ("Opus Tuning: {}", "Strojenie Opus: {}"), + ("Original Duration:", "Oryginalny czas trwania:"), + ("Output Cycle: {}", "Cykl wyjścia: {}"), + ("Output To:", "Wyjście do:"), + ("Overlay", "Nakładka"), + ("Parameter Name:", "Nazwa parametru:"), + ("Parent pick whip: drag onto a layer", "Łącznik rodzica: przeciągnij na warstwę"), + ("Pixel Aspect Ratio:", "Proporcje piksela:"), + ("Playing on Video Preview", "Odtwarzanie w podglądzie wideo"), + ("Post-Render Action: {}", "Akcja po renderowaniu: {}"), + ("Preserve frame rate when nested or in render queue", "Zachowaj liczbę klatek po zagnieżdżeniu lub w kolejce renderowania"), + ("Preserve resolution when nested", "Zachowaj rozdzielczość po zagnieżdżeniu"), + ("Preset", "Ustawienie"), + ("Preset:", "Ustawienie:"), + ("Presets", "Ustawienia"), + ("Previous", "Wstecz"), + ("Primary:", "Główny:"), + ("Process Before Match", "Przetwarzaj przed dopasowaniem"), + ("Profile: {}", "Profil: {}"), + ( + "Project color depth: click to cycle 8/16/32 bpc, Alt-click for Project Settings", + "Głębia kolorów projektu: kliknij, aby przełączać 8/16/32 bpc, Alt-klik otwiera Ustawienia projektu", + ), + ("Project: {}", "Projekt: {}"), + ("Projects", "Projekty"), + ("Property pick whip: drag onto a property", "Łącznik właściwości: przeciągnij na właściwość"), + ("Protected Region", "Obszar chroniony"), + ("Proxy Use: {}", "Użycie proxy: {}"), + ("Push All to Comp", "Wypchnij wszystko do kompozycji"), + ("RGB", "RGB"), + ("Radius", "Promień"), + ("Rate Control: {}", "Kontrola przepływności: {}"), + ("Recent", "Ostatnie"), + ("Recording", "Nagrywanie"), + ("Remove", "Usuń"), + ("Rename", "Zmień nazwę"), + ("Render: {} ms • UI {} fps{}", "Renderowanie: {} ms • UI {} fps{}"), + ("Renderer:", "Renderer:"), + ("Request Persistent Storage", "Poproś o trwałą pamięć"), + ("Reset", "Resetuj"), + ("Reset Labels", "Resetuj etykiety"), + ("Reset Preset", "Resetuj ustawienie"), + ("Reset to Default", "Przywróć domyślne"), + ("Reset to Saved Layout", "Przywróć zapisany układ"), + ("Resolution/Down Sample Factor Popup", "Menu rozdzielczości/współczynnika próbkowania"), + ("Restore Panel Size", "Przywróć rozmiar panelu"), + ("Reverse the order", "Odwróć kolejność"), + ("Revert All", "Przywróć wszystko"), + ("Roman Hanging Punctuation", "Interpunkcja wisząca (łacińska)"), + ("Roving:", "Wędrujące:"), + ("Ruler zero point (drag to move, double-click to reset)", "Punkt zerowy linijki (przeciągnij, aby przesunąć, kliknij dwukrotnie, aby zresetować)"), + ("Rulers", "Linijki"), + ("Run", "Uruchom"), + ("Sample Format: {}", "Format próbek: {}"), + ("Samples Per Frame:", "Próbek na klatkę:"), + ("Save from #{}", "Zapisz od #{}"), + ("Saved {}", "Zapisano {}"), + ("Script… (⌘/Ctrl+Enter runs)", "Skrypt… (⌘/Ctrl+Enter uruchamia)"), + ("Search commands or shortcuts", "Szukaj poleceń lub skrótów"), + ("Select", "Zaznacz"), + ("Select a command to change its shortcut.", "Zaznacz polecenie, aby zmienić jego skrót."), + ("Select a layer to see its effects", "Zaznacz warstwę, aby zobaczyć jej efekty"), + ("Select a layer to see its properties", "Zaznacz warstwę, aby zobaczyć jej właściwości"), + ("({}) Selected Objects", "Zaznaczone obiekty ({})"), + ( + "Select a layer with a mask or shape path (or the path property), or Puppet pins.", + "Zaznacz warstwę z maską lub ścieżką kształtu (albo właściwość ścieżki) lub pinezki Puppet.", + ), + ("Select a text layer", "Zaznacz warstwę tekstową"), + ("Select an item in the Project panel", "Zaznacz element w panelu Projekt"), + ("Select animated properties to show them in the Graph Editor", "Zaznacz animowane właściwości, aby pokazać je w edytorze wykresów"), + ( + "Select the leader pin, then Shift-click the pins that should trail it (hair, cloth, tails)", + "Zaznacz pinezkę prowadzącą, a potem Shift-kliknij pinezki, które mają za nią podążać (włosy, tkanina, ogony)", + ), + ("Set Ground Plane and Origin", "Ustaw płaszczyznę podłoża i początek układu"), + ("Set Paint Colors to Black and White (D)", "Ustaw kolory malowania na czarny i biały (D)"), + ("Settings", "Ustawienia"), + ("Settings Name:", "Nazwa ustawień:"), + ("Shadow Darkness", "Ciemność cienia"), + ("Shadow Diffusion", "Rozproszenie cienia"), + ("Shadows", "Cienie"), + ("Shape…", "Kształt…"), + ("Show", "Pokaż"), + ("Show Layer Name", "Pokaż nazwę warstwy"), + ("Show Source Name", "Pokaż nazwę źródła"), + ("Show me", "Pokaż mi"), + ("Show:", "Pokaż:"), + ("Shutter Angle:", "Kąt migawki:"), + ("Shutter Phase:", "Faza migawki:"), + ("Size", "Rozmiar"), + ("Skip", "Pomiń"), + ("Snapping", "Przyciąganie"), + ("Snapping options", "Opcje przyciągania"), + ("Snapshot", "Migawka"), + ("Solo Supported Properties", "Solo dla obsługiwanych właściwości"), + ("Solo Switches: {}", "Przełączniki solo: {}"), + ("Sort", "Sortuj"), + ("Spacing:", "Odstęp:"), + ("Spatial Interpolation:", "Interpolacja przestrzenna:"), + ("Speed:", "Szybkość:"), + ("Start Timecode:", "Początkowy kod czasowy:"), + ("Start: {} Duration: {}", "Początek: {} Czas trwania: {}"), + ("Step {} of {}: {}", "Krok {} z {}: {}"), + ("Stop", "Zatrzymaj"), + ("Stop {} at {}°", "Punkt {} na {}°"), + ("Stored in: {}", "Przechowywane w: {}"), + ("Stretch", "Rozciągnij"), + ("Stretch Factor:", "Współczynnik rozciągnięcia:"), + ("Stylistic Sets", "Zestawy stylistyczne"), + ("Subpixel Positioning", "Pozycjonowanie podpikselowe"), + ("Swap Colors", "Zamień kolory"), + ("Swap Paint Colors (X)", "Zamień kolory malowania (X)"), + ("Sync with System", "Synchronizuj z systemem"), + ("System Default", "Domyślne systemowe"), + ("Table", "Tabela"), + ("Template name", "Nazwa szablonu"), + ("Template: {}\n{}", "Szablon: {}\n{}"), + ("Temporal Interpolation:", "Interpolacja czasowa:"), + ("The Media Browser is not available here (use File ▸ Import)", "Przeglądarka multimediów nie jest tu dostępna (użyj Plik ▸ Importuj)"), + ("The OpenEXR layer shown in red, green, blue and alpha", "Warstwa OpenEXR pokazana w kanałach czerwonym, zielonym, niebieskim i alfa"), + ("The render queue is empty.", "Kolejka renderowania jest pusta."), + ( + "These adjacent keyframes cannot interpolate because their stop counts differ.", + "Te sąsiednie klatki kluczowe nie mogą być interpolowane, bo mają różną liczbę punktów.", + ), + ("This ScriptUI panel has closed", "Ten panel ScriptUI został zamknięty"), + ("This change will create a new solid for this layer.", "Ta zmiana utworzy nową planszę dla tej warstwy."), + ("Time", "Czas"), + ("Time Stretch…", "Rozciągnięcie czasu…"), + ("Time:", "Czas:"), + ("Time: {}", "Czas: {}"), + ("Title/Action Safe", "Obszar bezpieczny tytułów/akcji"), + ("Title/action safe", "Obszar bezpieczny tytułów/akcji"), + ("Toggle Switches / Modes", "Przełącz przełączniki / tryby"), + ("Track Name:", "Nazwa ścieżki:"), + ( + "Trained models are available in the desktop app; the browser uses the classic engine.", + "Wytrenowane modele są dostępne w aplikacji desktopowej; przeglądarka używa klasycznego silnika.", + ), + ("Transform", "Przekształć"), + ("Transparency grid", "Siatka przezroczystości"), + ("Tutorial complete", "Samouczek ukończony"), + ("Two-Node Camera", "Kamera dwuwęzłowa"), + ("Type", "Typ"), + ("Type a command or effect…", "Wpisz polecenie lub efekt…"), + ("URL:", "URL:"), + ("Undock Panel", "Odepnij panel"), + ("Move Panel to New Window", "Przenieś panel do nowego okna"), + ("Move Panel to Main Window", "Przenieś panel do głównego okna"), + ("Unfreeze", "Odmroź"), + ("Unlink {}", "Odłącz {}"), + ( + "Use this option to place the currently selected layers together into a new intermediate composition.", + "Użyj tej opcji, aby umieścić zaznaczone warstwy razem w nowej kompozycji pośredniej.", + ), + ("VR Environments", "Środowiska VR"), + ("Value:", "Wartość:"), + ("Variable Font Axes", "Osie czcionki zmiennej"), + ("Video Codec: {}", "Kodek wideo: {}"), + ("View:", "Widok:"), + ("Welcome to EffectCraft", "Witaj w EffectCraft"), + ("Width:", "Szerokość:"), + ("You can undo this.", "Można to cofnąć."), + ("You can't undo this.", "Tego nie można cofnąć."), + ("Your changes will be lost if you don't save them.", "Jeśli nie zapiszesz zmian, zostaną utracone."), + ("Zoom", "Powiększenie"), + ("built-in", "wbudowany"), + ("frames per second", "klatek na sekundę"), + ("template name", "nazwa szablonu"), + ("{} contributors · {} commits", "Współtwórcy: {} · commity: {}"), + ("{} x {}, {}MB per 8bpc frame", "{} x {}, {} MB na klatkę 8 bpc"), + ("{} — coming soon", "{} — wkrótce"), + ("{} — double-click to open, {}-click to reveal", "{} — kliknij dwukrotnie, aby otworzyć, {}-klik, aby pokazać w folderze"), + ("⌘/Ctrl-drag a pin to record its motion", "⌘/Ctrl-przeciągnij pinezkę, aby nagrać jej ruch"), + ("▣ {}", "▣ {}"), + ("⚠ {} is used by:", "⚠ {} jest używany przez:"), + ("3D Reference Axes", "Osie odniesienia 3D"), + ("Affect all layers that use this solid", "Dotyczy wszystkich warstw używających tej planszy"), + ("Choose grid and guide options", "Wybierz opcje siatki i linii pomocniczych"), + ("Colorize", "Koloryzuj"), + ("Custom", "Niestandardowe"), + ("Custom...", "Niestandardowe..."), + ("Fit", "Dopasuj"), + ("Fit up to 100%", "Dopasuj do 100%"), + ("Full", "Pełna"), + ("Half", "Połowa"), + ("Only this layer uses this solid.", "Tylko ta warstwa używa tej planszy."), + ("Proportional Grid", "Siatka proporcjonalna"), + ("Quarter", "Jedna czwarta"), + ("Region of Interest", "Obszar zainteresowania"), + ("Reset Exposure", "Resetuj ekspozycję"), + ("Show Channel and Color Management Settings", "Pokaż ustawienia kanałów i zarządzania kolorem"), + ("Show Snapshot (hold)", "Pokaż migawkę (przytrzymaj)"), + ("Take Snapshot", "Zrób migawkę"), + ("Third", "Jedna trzecia"), + ("Toggle Mask and Shape Path Visibility", "Przełącz widoczność ścieżek masek i kształtów"), + ("Toggle Transparency Grid", "Przełącz siatkę przezroczystości"), + ("16 bits per channel", "16 bitów na kanał"), + ("32 bits per channel (float)", "32 bity na kanał (zmiennoprzecinkowe)"), + ("8 bits per channel", "8 bitów na kanał"), + ("Adaptive Resolution", "Rozdzielczość adaptacyjna"), + ("Adobe-style built-in", "Wbudowany w stylu Adobe"), + ("Blue", "Niebieski"), + ("Current Settings", "Bieżące ustawienia"), + ("Disable Per-character 3D", "Wyłącz 3D dla każdego znaku"), + ("Draft", "Robocza"), + ("Enable Per-character 3D", "Włącz 3D dla każdego znaku"), + ("Fast Draft", "Szybka robocza"), + ("Fast Previews (Draft 3D, Faster Effects)", "Szybkie podglądy (robocze 3D, szybsze efekty)"), + ("Green", "Zielony"), + ("Import Image Sequence", "Importuj sekwencję obrazów"), + ("Import PDF / Illustrator File", "Importuj plik PDF / Illustrator"), + ("Import Photoshop File", "Importuj plik Photoshopa"), + ("Interpret Unlabeled Alpha As", "Interpretuj nieoznaczoną alfę jako"), + ("Join the ArtCraft Discord", "Dołącz do Discorda ArtCraft"), + ("Mercury GPU Acceleration ({})", "Akceleracja Mercury GPU ({})"), + ("Mercury Software Only", "Tylko programowy Mercury"), + ("OCIO (built-in ACES config)", "OCIO (wbudowana konfiguracja ACES)"), + ("Off (Final Quality)", "Wył. (jakość końcowa)"), + ("OpenGL (compatibility)", "OpenGL (zgodność)"), + ("Opus Bitrate: {} kbps", "Przepływność Opus: {} kb/s"), + ("RGB Straight", "RGB proste"), + ("Red", "Czerwony"), + ("Tone map", "Mapowanie tonów"), + ("Use In-Window Menu Bar on macOS", "Używaj paska menu w oknie w macOS"), + ("Wireframe", "Szkielet"), + ("sRGB IEC61966-2.1 (default)", "sRGB IEC61966-2.1 (domyślny)"), + ("New Project", "Nowy projekt"), + ("Open Project…", "Otwórz projekt…"), + ("Templates", "Szablony"), + ("EffectCraft home page", "Strona główna EffectCraft"), + ("EffectCraft on GitHub", "EffectCraft w serwisie GitHub"), + ("Motion graphics and visual effects · version {}", "Grafika ruchoma i efekty wizualne · wersja {}"), + ("New Composition", "Nowa kompozycja"), + ("Open Demo Project", "Otwórz projekt demonstracyjny"), + ("Import Footage…", "Importuj materiał…"), + ("Filter recent files", "Filtruj ostatnie pliki"), + ("NAME", "NAZWA"), + ("OPENED", "OTWARTY"), + ("SIZE", "ROZMIAR"), + ("KIND", "RODZAJ"), + ("Your recent files will show here", "Tu pojawią się ostatnie pliki"), + ("Create a new project, or open the demo project to look around.", "Utwórz nowy projekt albo otwórz projekt demonstracyjny, aby się rozejrzeć."), + ("No recent files match the filter", "Żaden ostatni plik nie pasuje do filtra"), + ("Try another name or folder.", "Spróbuj innej nazwy lub folderu."), + ("Just now", "Przed chwilą"), + ("Yesterday", "Wczoraj"), + ("{} minute ago", "{} minutę temu"), + ("{} minutes ago", "{} min temu"), + ("{} hour ago", "{} godzinę temu"), + ("{} hours ago", "{} godz. temu"), + ("{} day ago", "{} dzień temu"), + ("{} days ago", "{} dni temu"), + ("EffectCraft project", "Projekt EffectCraft"), + ("After Effects project", "Projekt After Effects"), + ("Lottie animation", "Animacja Lottie"), + ("Project", "Projekt"), + ("General", "Ogólne"), + ("Language", "Język"), + ("Levels of Undo", "Poziomy cofania"), + ("Path Point and Handle Size", "Rozmiar punktów i uchwytów ścieżki"), + ("Recent Projects Shown", "Liczba wyświetlanych ostatnich projektów"), + ("Options", "Opcje"), + ("Show Tool Tips", "Pokazuj podpowiedzi"), + ("Create Layers at Composition Start Time", "Twórz warstwy w czasie początku kompozycji"), + ("Switches Affect Nested Comps", "Przełączniki wpływają na zagnieżdżone kompozycje"), + ("Default Spatial Interpolation to Linear", "Domyślna interpolacja przestrzenna liniowa"), + ("Preserve Constant Vertex and Feather Point Count when Editing Masks", "Zachowaj stałą liczbę wierzchołków i punktów wtapiania podczas edycji masek"), + ("Synchronize Time of All Related Items", "Synchronizuj czas wszystkich powiązanych elementów"), + ("Expression Pick Whip Writes Compact English", "Łącznik wyrażeń zapisuje zwięzły angielski"), + ("Create Split Layers Above Original Layer", "Twórz podzielone warstwy nad oryginalną warstwą"), + ("Use System Color Picker", "Używaj systemowego próbnika kolorów"), + ( + "EffectCraft always uses its own colour picker (the same on every platform and the web).", + "EffectCraft zawsze używa własnego próbnika kolorów (takiego samego na każdej platformie i w przeglądarce).", + ), + ("Startup & Repair", "Uruchamianie i naprawa"), + ("Show Home Screen When Launching", "Pokazuj ekran startowy przy uruchamianiu"), + ("Show Home Screen When Opening a Project", "Pokazuj ekran startowy przy otwieraniu projektu"), + ("Offer to Open the Latest Auto-Save After a Crash", "Proponuj otwarcie ostatniego autozapisu po awarii"), + ("Window Graphics", "Grafika okna"), + ("Automatic", "Automatycznie"), + ( + "Window Graphics applies from the next launch. When the graphics driver stops EffectCraft as its window opens, the next launch switches to OpenGL.", + "Grafika okna obowiązuje od następnego uruchomienia. Gdy sterownik graficzny zatrzyma EffectCraft przy otwieraniu okna, następne uruchomienie przełączy się na OpenGL.", + ), + ("Repair", "Naprawa"), + ("Reset Settings", "Resetuj ustawienia"), + ("Reset Keyboard Shortcuts", "Resetuj skróty klawiaturowe"), + ("Empty Disk Cache", "Opróżnij pamięć dyskową"), + ("New Project Loads Template", "Nowy projekt wczytuje szablon"), + ("Template Project", "Projekt szablonu"), + ("Auto-Save", "Autozapis"), + ("Automatically Save Projects", "Automatycznie zapisuj projekty"), + ("Save Every", "Zapisuj co"), + ("Minutes", "min"), + ("Maximum Project Versions", "Maksymalna liczba wersji projektu"), + ("Auto-Save Location", "Lokalizacja autozapisu"), + ("Next to Project", "Obok projektu"), + ("Custom Location", "Lokalizacja niestandardowa"), + ("Save When Starting Render Queue", "Zapisuj przy uruchamianiu kolejki renderowania"), + ("Save Now", "Zapisz teraz"), + ("Motion Path", "Ścieżka ruchu"), + ("All Keyframes", "Wszystkie klatki kluczowe"), + ("No Motion Path", "Bez ścieżki ruchu"), + ("No More Than (Seconds)", "Nie więcej niż (sekundy)"), + ("No More Than (Keyframes)", "Nie więcej niż (klatki kluczowe)"), + ("Seconds", "s"), + ("Keyframes", "Klatki kluczowe"), + ("Display", "Wyświetlanie"), + ("Show Rendering Progress in Info Panel and Flowchart", "Pokazuj postęp renderowania w panelu Informacje i schemacie"), + ("Hardware Accelerate Composition, Layer and Footage Panels", "Akceleracja sprzętowa paneli Kompozycja, Warstwa i Materiał"), + ("Panels are always drawn by the GPU (wgpu).", "Panele są zawsze rysowane przez GPU (wgpu)."), + ("Previews", "Podglądy"), + ("Adaptive Resolution Limit", "Limit rozdzielczości adaptacyjnej"), + ("Show Internal Wireframes", "Pokazuj wewnętrzne szkielety"), + ("Cache Frames When Idle", "Buforuj klatki w bezczynności"), + ("Viewer Zoom Quality", "Jakość powiększenia przeglądarki"), + ("Faster", "Szybciej"), + ("More Accurate", "Dokładniej"), + ("Display Color Space", "Przestrzeń kolorów wyświetlacza"), + ("GPU Information...", "Informacje o GPU..."), + ("Appearance", "Wygląd"), + ("Brightness", "Jasność"), + ("UI Scale", "Skala interfejsu"), + ("Labels and Colors", "Etykiety i kolory"), + ("Use Label Color for Layer Handles and Paths", "Używaj koloru etykiety dla uchwytów i ścieżek warstw"), + ("Use Label Color for Related Tabs", "Używaj koloru etykiety dla powiązanych kart"), + ("Cycle Mask Colors", "Zmieniaj kolejno kolory masek"), + ("Use Gradients", "Używaj gradientów"), + ("Menu Bar", "Pasek menu"), + ("Grids & Guides", "Siatki i linie pomocnicze"), + ("Gridline Every", "Linia siatki co"), + ("Subdivisions", "Podpodziały"), + ("Style", "Styl"), + ("Lines", "Linie"), + ("Dashed Lines", "Linie przerywane"), + ("Dots", "Kropki"), + ("Horizontal", "Poziomo"), + ("Vertical", "Pionowo"), + ("Safe Margins", "Marginesy bezpieczne"), + ("Action-safe", "Bezpieczny dla akcji"), + ("Title-safe", "Bezpieczny dla tytułów"), + ("Labels", "Etykiety"), + ("Text Engine", "Silnik tekstu"), + ("Latin", "Łaciński"), + ("South Asian and Middle Eastern", "Południowoazjatycki i bliskowschodni"), + ("Show Font Preview", "Pokazuj podgląd czcionki"), + ("Number of Recent Fonts to Display", "Liczba wyświetlanych ostatnich czcionek"), + ("Show Font Names in English", "Pokazuj nazwy czcionek po angielsku"), + ("Still Footage", "Materiał nieruchomy"), + ("Length of Composition", "Długość kompozycji"), + ("Duration (Seconds)", "Czas trwania (sekundy)"), + ("Still Duration", "Czas trwania obrazu"), + ("Sequence Footage", "Materiał sekwencyjny"), + ("Report Missing Frames", "Zgłaszaj brakujące klatki"), + ("Automatically Reload Footage", "Automatycznie wczytuj ponownie materiał"), + ("Off", "Wył."), + ("Non-Sequence Footage", "Materiał niesekwencyjny"), + ("All Footage", "Wszystkie materiały"), + ("Ask User", "Pytaj użytkownika"), + ("Guess", "Zgadnij"), + ("Ignore", "Ignoruj"), + ("Straight", "Prosta"), + ("Premultiplied", "Wstępnie mnożona"), + ("Default Drag Import As", "Domyślny import przeciągnięciem jako"), + ("Footage", "Materiał"), + ("Composition - Retain Layer Sizes", "Kompozycja – zachowaj rozmiary warstw"), + ("Export", "Eksport"), + ("Default Output Folder", "Domyślny folder wyjściowy"), + ("Segment Sequences", "Dziel sekwencje na segmenty"), + ("Files per Segment", "Pliki na segment"), + ("files", "pliki"), + ("Segment Movie Files", "Dziel pliki filmowe na segmenty"), + ("Segment Size", "Rozmiar segmentu"), + ("Append Bit Depth to File Name", "Dodawaj głębię bitową do nazwy pliku"), + ("Audio", "Audio"), + ("Audio Hardware", "Sprzęt audio"), + ("Preview Sample Rate", "Częstotliwość próbkowania podglądu"), + ("Audio Output Mapping", "Mapowanie wyjść audio"), + ("Left", "Lewy"), + ("Right", "Prawy"), + ("channel", "kanał"), + ("Disk", "Dysk"), + ("Disk Cache", "Pamięć dyskowa"), + ("Enable Disk Cache", "Włącz pamięć dyskową"), + ("Maximum Disk Cache Size", "Maksymalny rozmiar pamięci dyskowej"), + ("Disk Cache Folder", "Folder pamięci dyskowej"), + ("Media Cache", "Pamięć multimediów"), + ("Database and Cache Folder", "Folder bazy danych i pamięci"), + ("Conformed Audio Folder", "Folder dopasowanego audio"), + ("Memory & CPU", "Pamięć i procesor"), + ("RAM Reserved for Other Applications", "Pamięć RAM zarezerwowana dla innych aplikacji"), + ("Cache Budgets", "Budżety pamięci podręcznej"), + ("Layer Cache", "Pamięć warstw"), + ("Footage Frame Cache", "Pamięć klatek materiałów"), + ("Preview (RAM) Cache", "Pamięć podglądu (RAM)"), + ("Reduce Cache Size When System Is Low on Memory", "Zmniejsz pamięć podręczną, gdy w systemie brakuje pamięci"), + ("Video", "Wideo"), + ("Enable Video Preview Output", "Włącz wyjście podglądu wideo"), + ("Video Device", "Urządzenie wideo"), + ("Floating Window", "Okno pływające"), + ("Full Screen", "Pełny ekran"), + ("Video Output During Playback", "Wyjście wideo podczas odtwarzania"), + ("Mirror on Computer Monitor", "Powielaj na monitorze komputera"), + ("Disable Video Output When in Background", "Wyłącz wyjście wideo w tle"), + ("Default 3D Renderer", "Domyślny renderer 3D"), + ("Show 3D Reference Axes", "Pokazuj osie odniesienia 3D"), + ("Extended Viewer", "Rozszerzona przeglądarka"), + ("Realtime Shadows in Draft", "Cienie w czasie rzeczywistym w trybie roboczym"), + ("Scripting & Expressions", "Skrypty i wyrażenia"), + ("Application Scripting", "Skrypty aplikacji"), + ("Allow Scripts to Write Files and Access Network", "Zezwalaj skryptom na zapis plików i dostęp do sieci"), + ("Warn User When Executing Files", "Ostrzegaj przy uruchamianiu plików"), + ("Enable JavaScript Debugger", "Włącz debuger JavaScript"), + ("Expressions Editor", "Edytor wyrażeń"), + ("Font Size", "Rozmiar czcionki"), + ("Syntax Highlighting", "Podświetlanie składni"), + ("Line Numbers", "Numery wierszy"), + ("Auto-complete", "Autouzupełnianie"), + ("Bracket Matching", "Dopasowywanie nawiasów"), + ("Word Wrap", "Zawijanie wierszy"), + ("Show Expression Error Banner", "Pokazuj pasek błędów wyrażeń"), + ( + "Expressions use the JavaScript engine. Script errors report their file and line in the Script Console; there is no step debugger.", + "Wyrażenia używają silnika JavaScript. Błędy skryptów podają plik i wiersz w konsoli skryptów; nie ma debugera krokowego.", + ), + ("Roto Brush", "Pędzel Roto"), + ("Segmentation Model", "Model segmentacji"), + ( + "Roto Brush 2.0 and 3.0 use the model chosen here (Version 1.0 always uses the classic engine). Models are open source, downloaded only when you ask, and checked against their published SHA-256.", + "Pędzel Roto 2.0 i 3.0 używa wybranego tu modelu (wersja 1.0 zawsze używa klasycznego silnika). Modele są open source, pobierane tylko na Twoje żądanie i sprawdzane z opublikowanym SHA-256.", + ), + ("Face Tracking", "Śledzenie twarzy"), + ("Face Model", "Model twarzy"), + ( + "Face tracking in the Tracker panel (Outline Only and Detailed Features) uses the model chosen here. Models are open source, downloaded only when you ask, and checked against their published SHA-256.", + "Śledzenie twarzy w panelu Śledzenie (tylko kontur i szczegółowe cechy) używa wybranego tu modelu. Modele są open source, pobierane tylko na Twoje żądanie i sprawdzane z opublikowanym SHA-256.", + ), + ("Match System", "Zgodnie z systemem"), + ("Default", "Domyślny"), + ("Stored, but not used by EffectCraft yet", "Zapisane, ale jeszcze nieużywane przez EffectCraft"), + ("Total Offset", "Całkowite przesunięcie"), + ("Per Layer", "Na warstwę"), + ("Track", "Ścieżka"), + ("Copy", "Kopiuj"), + ("Paste", "Wklej"), + ("Edit Value…", "Edytuj wartość…"), + ("Remove Expression", "Usuń wyrażenie"), + ("Effects", "Efekty"), + ("Copy Source Path", "Kopiuj ścieżkę źródła"), + ("Resolution:", "Rozdzielczość:"), + ("Guide Layer", "Warstwa pomocnicza"), + ("Layer Styles", "Style warstwy"), +]; + /// Czech (cs): original translations covering every row in the Chinese panel catalogs. const CZECH: &[(&str, &str)] = &[ (" OK ", " OK "), @@ -3900,6 +5705,7 @@ const CZECH: &[(&str, &str)] = &[ ("New", "Nový"), ("New Comp from Selection", "Nová kompozice z výběru"), ("Replace with Precomposition", "Nahradit předkompozicí"), + ("Rescan Fonts", "Znovu nalézt písma"), ("used once", "použito jednou"), ("used {} times", "použito {}×"), ("Compositions that use this item", "Kompozice, které tuto položku používají"), @@ -3950,6 +5756,7 @@ const CZECH: &[(&str, &str)] = &[ ("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"), + ("Track Matte pick whip: drag onto a layer", "Propojovací nástroj masky stopy: 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í: {}"), @@ -4483,6 +6290,7 @@ const SPANISH: &[(&str, &str)] = &[ ("No layer selected", "No hay ninguna capa seleccionada"), ("No matching effects or presets", "No hay efectos ni ajustes preestablecidos que coincidan"), ("No matching fonts", "No hay fuentes coincidentes"), + ("Rescan Fonts", "Volver a analizar fuentes"), ("Variable fonts only", "Solo fuentes variables"), ("No model credits were built into this copy.", "No se incluyeron créditos de modelos en esta copia."), ("No nested compositions", "No hay composiciones anidadas"), @@ -4513,6 +6321,7 @@ const SPANISH: &[(&str, &str)] = &[ ("Overlay", "Superposición"), ("Parameter Name:", "Nombre del parámetro:"), ("Parent pick whip: drag onto a layer", "Selector de capa padre: arrastra hasta una capa"), + ("Track Matte pick whip: drag onto a layer", "Selector de mate de seguimiento: arrastra hasta una capa"), ("Pixel Aspect Ratio:", "Relación de aspecto de píxel:"), ("Playing on Video Preview", "Reproduciendo en la previsualización de vídeo"), ("Post-Render Action: {}", "Acción posterior al procesamiento: {}"), @@ -5738,6 +7547,7 @@ mod tests { ("menus/frontend.rs", include_str!("../menus/frontend.rs")), ("menus/invoke.rs", include_str!("../menus/invoke.rs")), ("menus/shortcuts.rs", include_str!("../menus/shortcuts.rs")), + ("state.rs", include_str!("../state.rs")), ("prefs_live.rs", include_str!("../prefs_live.rs")), ("widgets.rs", include_str!("../widgets.rs")), ("panels/anim_tools.rs", include_str!("../panels/anim_tools.rs")), @@ -5804,6 +7614,8 @@ mod tests { ("engine settings choices", include_str!("../../../engine/src/settings/prefs/choices.rs")), ("engine viewer channels", include_str!("../../../engine/src/viewer.rs")), ("engine viewer commands", include_str!("../../../engine/src/cmd/viewer_cmds.rs")), + // The text animator menu's labels come from the engine as data, like the settings pages. + ("engine text animator kinds", include_str!("../../../project/src/build.rs")), ]; fn catalogs() -> Vec<(&'static str, &'static str)> { @@ -5815,6 +7627,55 @@ mod tests { rows.iter().find(|(source, _)| *source == en).map_or(en, |(_, translated)| *translated) } + #[test] + fn the_toolbar_asks_the_catalog_for_the_tool_names() { + let header = include_str!("../header.rs"); + for call in ["crate::i18n::tr(tool.label())", "crate::i18n::tr(cur.label())"] { + assert!(header.contains(call), "header.rs no longer draws {call}"); + } + assert!( + header.contains("app.auto.add(&format!(\"tools.select.{tool:?}\"), row.rect, tool.label())"), + "the automation label must stay the English tool name" + ); + } + + #[test] + fn the_preview_panel_labels_are_translated() { + // The Preview panel draws these from a local closure, a check table and a roll tuple, none of + // which the call-shape scan sees; they had no rows at all. + for key in [ + "Shortcut", + "Range", + "Play From", + "Frame Rate", + "Resolution", + "Overlays", + "Layer Controls", + "Loop", + "Cache Before Playback", + "Pre-roll", + "Post-roll", + "If caching, play cached frames", + ] { + for (name, table) in [("Simplified", SIMPLIFIED_CHINESE), ("Traditional", TRADITIONAL_CHINESE)] { + let value = table.iter().find(|(k, _)| *k == key).map(|(_, v)| *v); + assert!(value.is_some_and(|v| !v.is_empty()), "{key} has no {name} row"); + } + } + // A row is not a translation until the panel asks for it: the labels drawn one by one carry + // `tr(..)`, and the table and the roll tuple wrap the text they read at the call. + let source = include_str!("../panels/misc.rs"); + for literal in ["Shortcut", "Range", "Play From", "Frame Rate", "Resolution", "Full Screen", "If caching, play cached frames"] { + assert!(source.contains(&format!("tr(\"{literal}\")")), "{literal} is drawn without asking the catalog"); + } + for call in ["check_row(app, ui, &p, pos2(cx, cy), key, tr(text), on)", "Align2::LEFT_CENTER, tr(text),"] { + assert!(source.contains(call), "{call} no longer asks the catalog"); + } + for table in ["\"Overlays\"", "\"Layer Controls\"", "\"Loop\"", "\"Cache Before Playback\"", "\"Pre-roll\"", "\"Post-roll\""] { + assert!(source.contains(table), "{table} is not in the panel"); + } + } + /// A row that translates nothing, repeats a source string, or changes the number of values the /// English source carries is a bug in the catalog itself. #[test] @@ -5874,10 +7735,321 @@ mod tests { assert_eq!(tr_args("Proxy Use: {}", &[]), "Proxy Use: "); } + /// Japanese reads its own rows: values fill the placeholders in order, and a name that is not a + /// row (a layer or file name) stays verbatim. + #[test] + fn japanese_rows_translate_the_panels() { + set_current("ja"); + assert_eq!(tr("Composition Name:"), "コンポジション名:"); + // The panel/dialog second step: panel tabs, form labels, context menus and messages. + assert_eq!(tr("Effect Controls"), "エフェクトコントロール"); + assert_eq!(tr("Media Duration"), "メディアの長さ"); + assert_eq!(tr("Bring Layer to Front"), "レイヤーを最前面へ"); + assert_eq!(tr("All Transform Properties"), "すべてのトランスフォームプロパティ"); + assert_eq!(tr("Fill Color: RGB"), "塗りの色: RGB"); + assert_eq!(tr("Lines added"), "追加行数"); + // The composition-settings tabs and the Preview transport pass these through `tr` as + // variables, so a missing row would fall back to English unnoticed. + assert_eq!(tr("Basic"), "基本"); + assert_eq!(tr("Advanced"), "詳細"); + assert_eq!(tr("Play/Stop"), "再生/停止"); + assert_eq!(tr("my layer.mp4"), "my layer.mp4"); + assert_eq!(tr_args("Proxy Use: {}", &[&"Best"]), "プロキシの使用:Best"); + assert_eq!(tr_args("Step {} of {}: {}", &[&1, &3, &"Colour"]), "ステップ1/3:Colour"); + set_current("en"); + } + /// Every row is asked for by a converted panel: a leftover row is stale, because the panel's /// English source changed or went away. A string a helper draws for the panel (a toolbar /// tooltip, a popup item, a checkbox label) is looked up inside that helper, so its English /// source only appears as a literal there. + #[test] + fn helper_and_painter_labels_are_translated() { + /// The `widgets.rs` helpers that draw a label, and which argument holds it. + const HELPER_LABELS: &[(&str, usize)] = + &[("text_button", 2), ("search_field", 3), ("text_field", 3), ("dropdown", 2), ("dropdown_tip", 2), ("text_fit", 3), ("check_label", 1)]; + /// Literals that draw as they are: a brand, a value, a separator, a unit, and the kerning + /// sample pair the Character panel shows. + const VERBATIM: &[&str] = &["EffectCraft", "32", "dB", "···", "×", "V/A"]; + + /// The top-level arguments of a call, given the text after its `(`. A call split over + /// several lines is still one argument list, so this walks past newlines until the + /// parenthesis closes. It understands the two string forms a label can be written in - a + /// plain one with escapes and a raw one (`r"…"`, `r#"…"#`) - so a comma or parenthesis + /// inside either is not mistaken for code. + fn args_of(s: &str) -> Vec<&str> { + #[derive(Clone, Copy)] + enum Mode { + Code, + Str(bool), + Raw(usize), + } + let cs: Vec<(usize, char)> = s.char_indices().collect(); + let (mut out, mut depth, mut start, mut end) = (Vec::new(), 0i32, None, s.len()); + let mut mode = Mode::Code; + let mut k = 0; + while k < cs.len() { + let (i, c) = cs[k]; + match mode { + Mode::Raw(hashes) => { + if c == '"' { + let mut n = 0; + while cs.get(k + 1 + n).is_some_and(|(_, h)| *h == '#') { + n += 1; + } + if n >= hashes { + mode = Mode::Code; + k += n; + } + } + } + Mode::Str(escaped) => { + mode = if c == '"' && !escaped { Mode::Code } else { Mode::Str(c == '\\' && !escaped) }; + } + Mode::Code => match c { + '"' => { + mode = Mode::Str(false); + if start.is_none() { + start = Some(i); + } + } + 'r' if start.is_none() && cs.get(k + 1).is_some_and(|(_, n)| *n == '#' || *n == '"') => { + let mut n = 0; + while cs.get(k + 1 + n).is_some_and(|(_, h)| *h == '#') { + n += 1; + } + if cs.get(k + 1 + n).is_some_and(|(_, q)| *q == '"') { + mode = Mode::Raw(n); + start = Some(i); + k += n + 1; + } + } + '(' | '[' | '{' => depth += 1, + ')' | ']' | '}' => { + if depth == 0 { + end = i; + break; + } + depth -= 1; + } + ',' if depth == 0 => { + if let Some(st) = start { + out.push(s[st..i].trim()); + } + start = None; + } + c if !c.is_whitespace() && start.is_none() => start = Some(i), + _ => {} + }, + } + k += 1; + } + if let Some(st) = start { + out.push(s[st..end].trim()); + } + out + } + + /// The text a literal argument draws: a plain string with its escapes undone, or a raw + /// string with none. `None` when the argument is not one literal (a concatenation, a call). + fn literal_text(arg: &str) -> Option { + if let Some(rest) = arg.strip_prefix('r') { + let hashes = rest.chars().take_while(|c| *c == '#').count(); + // An identifier that starts with `r` (`result`) is not a raw string: the quote has + // to follow the `#`s. + if rest.chars().nth(hashes) != Some('"') { + return None; + } + let close = format!("\"{}", "#".repeat(hashes)); + return arg.get(1 + hashes + 1..)?.strip_suffix(&close).map(str::to_string); + } + let body = arg.strip_prefix('"')?.strip_suffix('"')?; + // Decode the escapes that matter for the exemption: a literal written as `"\u{bb}"` is + // one character (`»`), not six. + let mut out = String::new(); + let mut it = body.chars().peekable(); + while let Some(c) = it.next() { + if c != '\\' { + if c == '"' { + return None; // two literals side by side + } + out.push(c); + continue; + } + match it.next()? { + '"' => out.push('"'), + '\\' => out.push('\\'), + 'n' => out.push('\n'), + 't' => out.push('\t'), + 'x' => { + let hex: String = it.by_ref().take(2).collect(); + let byte = u8::from_str_radix(&hex, 16).ok()?; + out.push(char::from(byte)); + } + 'u' => { + if it.next() != Some('{') { + return None; + } + let hex: String = it.by_ref().take_while(|c| *c != '}').collect(); + out.push(char::from_u32(u32::from_str_radix(&hex, 16).ok()?)?); + } + other => { + out.push('\\'); + out.push(other); + } + } + } + Some(out) + } + + /// `source` with comments blanked and the *contents* of every string literal blanked too, + /// with offsets, newlines and the quotes kept. Call tokens are looked for in this text, so a + /// `widgets::…(` or `.text(` that only appears inside a doc string or a sample is not + /// mistaken for a call; the argument list is then read from the original at that offset. + fn code_only(source: &str) -> String { + #[derive(Clone, Copy)] + enum Mode { + Code, + Str(bool), + Raw(usize), + Comment, + } + let cs: Vec<(usize, char)> = source.char_indices().collect(); + // Byte-for-byte: a blanked character becomes as many spaces as its UTF-8 length, so + // every offset found here indexes the original source as well. + let mut out: Vec = Vec::with_capacity(source.len()); + let keep = |out: &mut Vec, c: char| out.extend_from_slice(c.encode_utf8(&mut [0; 4]).as_bytes()); + let blank = |out: &mut Vec, c: char| out.extend(std::iter::repeat_n(b' ', c.len_utf8())); + let mut mode = Mode::Code; + let mut k = 0; + while k < cs.len() { + let (_, c) = cs[k]; + match mode { + Mode::Comment => { + if c == '\n' { + mode = Mode::Code; + keep(&mut out, c); + } else { + blank(&mut out, c); + } + } + Mode::Raw(hashes) => { + if c == '"' { + let mut n = 0; + while cs.get(k + 1 + n).is_some_and(|(_, h)| *h == '#') { + n += 1; + } + if n >= hashes { + keep(&mut out, c); + for j in 1..=n { + keep(&mut out, cs[k + j].1); + } + mode = Mode::Code; + k += n; + } else { + blank(&mut out, c); + } + } else { + blank(&mut out, c); + } + } + Mode::Str(escaped) => { + if c == '"' && !escaped { + mode = Mode::Code; + keep(&mut out, c); + } else { + mode = Mode::Str(c == '\\' && !escaped); + blank(&mut out, c); + } + } + Mode::Code => match c { + '"' => { + mode = Mode::Str(false); + keep(&mut out, c); + } + '/' if out.last() == Some(&b'/') => { + // The first `/` stays; this one becomes a space. Both are one byte, so + // the mask keeps its length and every offset still indexes the source. + blank(&mut out, c); + mode = Mode::Comment; + } + 'r' if matches!(cs.get(k + 1), Some((_, n)) if *n == '#' || *n == '"') => { + let mut n = 0; + while cs.get(k + 1 + n).is_some_and(|(_, h)| *h == '#') { + n += 1; + } + if cs.get(k + 1 + n).is_some_and(|(_, q)| *q == '"') { + keep(&mut out, c); + for j in 1..=n { + keep(&mut out, cs[k + j].1); + } + keep(&mut out, '"'); + mode = Mode::Raw(n); + k += n + 1; + } else { + keep(&mut out, c); + } + } + _ => keep(&mut out, c), + }, + } + k += 1; + } + debug_assert_eq!(out.len(), source.len(), "the mask must keep every byte offset"); + String::from_utf8(out).expect("mask keeps UTF-8 boundaries") + } + + for (path, source) in CONVERTED { + // The test module draws nothing; it holds sample strings. A test-only helper above it + // (`#[cfg(test)] pub(crate) fn press_key` in `dialogs.rs`) is kept in the scanned range, + // because production code follows it. + let cut = source + .match_indices("#[cfg(test)]") + .find(|(at, _)| source[at + "#[cfg(test)]".len()..].lines().take(3).any(|l| l.trim_start().starts_with("mod "))) + .map_or(source.len(), |(at, _)| at); + let source = &source[..cut]; + let code = code_only(source); + let line_of = |at: usize| code[..at].matches('\n').count() + 1; + let check = |at: usize, arg: &str, what: &str| { + // A single character is a symbol or a glyph, not interface text - the same exemption + // the call-shape scan above makes. A call that already asks the catalog is not a + // literal here at all. + if let Some(text) = literal_text(arg) + && text.chars().count() >= 2 + && !VERBATIM.contains(&text.as_str()) + { + panic!("{path}:{}: the interface text {arg} that {what} draws is not in the catalog", line_of(at)); + } + }; + for (call, index) in HELPER_LABELS { + let marker = format!("widgets::{call}("); + for (at, _) in code.match_indices(&marker) { + if let Some(arg) = args_of(&source[at + marker.len()..]).get(*index) { + check(at, arg, &format!("widgets::{call}")); + } + } + } + for (at, _) in code.match_indices(".text(") { + if let Some(arg) = args_of(&source[at + ".text(".len()..]).get(2) { + check(at, arg, "Painter::text"); + } + } + } + } + + #[test] + fn the_composition_settings_labels_are_translated() { + // The Composition Settings dialog (and New Composition) draws these from a `(label, value)` + // table, a tab array and a `match` arm, which the call-shape scan cannot see (#1451). They + // used to have no row in any catalog, so every interface drew them in English. + for key in ["Square Pixels", "Basic", "Advanced", "3D Renderer", "Black", "White", "Composition Settings"] { + for (name, table) in [("Simplified", SIMPLIFIED_CHINESE), ("Traditional", TRADITIONAL_CHINESE)] { + let value = table.iter().find(|(k, _)| *k == key).map(|(_, v)| *v); + assert!(value.is_some_and(|v| !v.is_empty()), "{key} has an empty {name} row"); + } + } + } + #[test] fn every_row_is_used_by_a_converted_panel() { for (en, _) in catalogs() { diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 949ea3771..385a6b1ca 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -48,6 +48,8 @@ pub enum Icon { Speaker, Solo, Lock, + /// An open padlock: the shackle lifted clear of the body on the right. + LockOpen, Shy, Collapse, Quality, @@ -402,6 +404,12 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) { pen.arc(8.0, 7.0, 3.0, 180.0, 360.0); pen.dot(8.0, 10.5, 1.0); } + LockOpen => { + pen.rect(3.5, 7.0, 12.5, 14.0); + pen.arc(8.0, 4.0, 3.0, 180.0, 360.0); + pen.line(&[(5.0, 4.0), (5.0, 7.0)]); + pen.dot(8.0, 10.5, 1.0); + } Shy => { pen.circle(8.0, 8.0, 5.8); pen.dot(6.0, 7.0, 0.8); diff --git a/crates/ui-egui/src/menus/bar.rs b/crates/ui-egui/src/menus/bar.rs index 0c334f74d..ec9ab9170 100644 --- a/crates/ui-egui/src/menus/bar.rs +++ b/crates/ui-egui/src/menus/bar.rs @@ -179,7 +179,12 @@ fn menu_nodes( "recentScripts" => app.session.prefs.recent_scripts.get(i).map(|r| r.text().to_string()), _ => None, }; - let r = ui.add_enabled(entry_enabled(app, e), egui::Button::new((gutter(entry_checked(app, e) == Some(true)), e.label.as_str()))); + let mut b = egui::Button::new((gutter(entry_checked(app, e) == Some(true)), e.label.as_str())); + // File ▸ Scripts entries show the key a script was given. + if let Some(s) = entry_shortcut(app, e) { + b = b.shortcut_text(shortcut_text(&s)); + } + let r = ui.add_enabled(entry_enabled(app, e), b); let r = match &tip { Some(t) => r.on_hover_text(t), None => r, diff --git a/crates/ui-egui/src/menus/entries.rs b/crates/ui-egui/src/menus/entries.rs index 8fee44a72..d7b5d56ca 100644 --- a/crates/ui-egui/src/menus/entries.rs +++ b/crates/ui-egui/src/menus/entries.rs @@ -119,7 +119,8 @@ pub(crate) fn entry_enabled(app: &EffectcraftApp, e: &MenuEntry) -> bool { } } if e.command == "edit.clear" && app.ui.focused == PanelKind::Project { - return app.session.is_enabled("project.delete"); + // An open text edit stands down: Clear must not delete the comp being typed into (#2227). + return app.session.state.text_edit.is_none() && app.session.is_enabled("project.delete"); } if e.command == "edit.clear" && app.ui.focused == PanelKind::RenderQueue { return !app.session.is_rendering() && !app.session.project.render_queue.is_empty(); diff --git a/crates/ui-egui/src/menus/frontend.rs b/crates/ui-egui/src/menus/frontend.rs index a3f95ee1c..5d65ffb8f 100644 --- a/crates/ui-egui/src/menus/frontend.rs +++ b/crates/ui-egui/src/menus/frontend.rs @@ -488,7 +488,15 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu app.ui.home_templates = true; Value::Null } - "effect.manage" | "anim.browsePresets" => { + "effect.manage" => { + app.show_panel(PanelKind::EffectsPresets); + Value::Null + } + "anim.browsePresets" => { + app.ui.effects_search.clear(); + app.ui.effects_view.presets = true; + app.ui.effects_open.insert("presets".into()); + ctx.data_mut(|data| data.insert_temp(egui::Id::unique(crate::panels::effects_presets::REVEAL_PRESETS_ID), true)); app.show_panel(PanelKind::EffectsPresets); Value::Null } diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index b266623f3..23065067f 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -151,7 +151,16 @@ fn vertex_delta(app: &EffectcraftApp, layer: effectcraft_engine::project::LayerI let s = &app.session; let (Some(cid), Some(comp)) = (s.active_comp_id(), s.active_comp()) else { return d }; let Some(l) = comp.layer(layer) else { return d }; - let ectx = effectcraft_engine::render::EvalCtx { project: &s.project, comp_id: cid, comp, time: s.time(), expr: s.expr.as_deref(), footage: None }; + let ectx = effectcraft_engine::render::EvalCtx { + project: &s.project, + comp_id: cid, + comp, + time: s.time(), + expr: s.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let (l2c, _) = crate::panels::viewer::l2c(&ectx, l); l2c.inverse().map_or(d, |inv| { let v = inv.apply_vec(vec2(d[0], d[1])); @@ -223,8 +232,13 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V return run_engine(app, ctx, "edit.pasteSvg", json!({"svg": svg})); } } - // Clear follows panel focus, including the Edit menu and keyboard shortcut. + // Clear follows panel focus, including the Edit menu and keyboard shortcut. An open text + // edit stands down: the user is working in a composition, and the Project-panel redirect + // would delete it without asking when nothing uses it (#2227). if id == "edit.clear" && params.as_object().is_some_and(|p| p.is_empty()) && app.ui.focused == PanelKind::Project { + if app.session.state.text_edit.is_some() { + return Ok(Value::Null); + } // Through `invoke`, so deleting items that compositions use still asks first (M3.16). return invoke(app, ctx, "project.delete", json!({})); } diff --git a/crates/ui-egui/src/menus/shortcuts.rs b/crates/ui-egui/src/menus/shortcuts.rs index b9990786c..f50fbdb3c 100644 --- a/crates/ui-egui/src/menus/shortcuts.rs +++ b/crates/ui-egui/src/menus/shortcuts.rs @@ -210,6 +210,9 @@ fn escape_dialog(app: &mut EffectcraftApp, ctx: &egui::Context) { ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)); if app.dialog == Some(crate::Dialog::LayerStyles) { crate::panels::layer_styles_dialog::finish(app, false); + } else if app.dialog == Some(crate::Dialog::GradientEditor) { + // Escape is Cancel: the preview rolls back. + crate::panels::gradient_editor::finish(app, false, ctx); } else if app.dialog == Some(crate::Dialog::Info) { crate::panels::forms::close_info(app); } else { @@ -222,10 +225,37 @@ fn escape_dialog(app: &mut EffectcraftApp, ctx: &egui::Context) { if app.dialog == Some(crate::Dialog::UnsavedChanges) { app.dialog_state.unsaved.command = None; } + // Escape is Cancel: a Preview that edited the project rolls back. + crate::panels::live_preview::cancel(app); app.dialog = None; } } +/// Escape with a dialog open: it is Cancel. Escape while recording a shortcut cancels the +/// recording, not the editor; so does Escape in its Delete Preset prompt. +fn cancel_dialog(app: &mut EffectcraftApp, ctx: &egui::Context) { + if crate::panels::shortcut_editor::recording(app) || crate::panels::shortcut_editor::confirming(app) { + return; + } + if app.dialog == Some(crate::Dialog::Info) { + crate::panels::forms::close_info(app); + return; + } + if app.dialog == Some(crate::Dialog::GradientEditor) { + crate::panels::gradient_editor::finish(app, false, ctx); + return; + } + // Escape in Settings is Cancel: restore the settings from when it opened. + if app.dialog == Some(crate::Dialog::Settings) + && let Some(p) = app.dialog_state.prefs_snapshot.take() + { + app.session.prefs = p; + app.session.prefs_changed(); + } + crate::panels::live_preview::cancel(app); + app.dialog = None; +} + type KeyEvent = (egui::Key, egui::Modifiers, bool, Option); /// The frame's key presses, with clipboard events mapped back to Ctrl+C / X / V. @@ -341,23 +371,7 @@ fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only } // Escape closes dialogs. if key == egui::Key::Escape && app.dialog.is_some() { - // Escape while recording a shortcut cancels the recording, not the editor; so does - // Escape in its Delete Preset prompt. - if crate::panels::shortcut_editor::recording(app) || crate::panels::shortcut_editor::confirming(app) { - continue; - } - if app.dialog == Some(crate::Dialog::Info) { - crate::panels::forms::close_info(app); - continue; - } - // Escape in Settings is Cancel: restore the settings from when it opened. - if app.dialog == Some(crate::Dialog::Settings) - && let Some(p) = app.dialog_state.prefs_snapshot.take() - { - app.session.prefs = p; - app.session.prefs_changed(); - } - app.dialog = None; + cancel_dialog(app, ctx); continue; } if app.dialog.is_some() { diff --git a/crates/ui-egui/src/native_menu.rs b/crates/ui-egui/src/native_menu.rs index 1ca573e8e..7876ec180 100644 --- a/crates/ui-egui/src/native_menu.rs +++ b/crates/ui-egui/src/native_menu.rs @@ -131,18 +131,28 @@ impl NativeMenu { /// otherwise follows the project clipboard and would be greyed out with nothing copied there, /// while the field can paste. pub fn build_now(app: &EffectcraftApp, ctx: &egui::Context) -> NativeMenu { - fn enable(nodes: &mut [NativeNode]) { + fn enable(app: &EffectcraftApp, nodes: &mut [NativeNode]) { for node in nodes { match node { - NativeNode::Item(item) if edits_text(&item.command) => item.enabled = true, - NativeNode::Submenu { children, .. } => enable(children), + NativeNode::Item(item) if edits_text(&item.command) => { + item.enabled = true; + let source = match item.command.as_str() { + "edit.undo" => Some("Undo"), + "edit.redo" => Some("Redo"), + _ => None, + }; + if let Some(source) = source { + item.label = crate::i18n::label(app, &item.command, source).into(); + } + } + NativeNode::Submenu { children, .. } => enable(app, children), _ => {} } } } let mut menu = build(app); if text_field_window(ctx).is_some() { - enable(&mut menu.menus); + enable(app, &mut menu.menus); } menu } @@ -186,8 +196,9 @@ pub fn build(app: &EffectcraftApp) -> NativeMenu { label: e.label.clone(), command: e.command.clone(), params: e.params.clone(), - shortcut: None, - accelerator: None, + // File ▸ Scripts entries show the key a script was given. + shortcut: crate::menus::entry_shortcut(app, e), + accelerator: crate::menus::entry_shortcut(app, e).as_deref().and_then(accelerator), enabled: crate::menus::entry_enabled(app, e), checked: crate::menus::entry_checked(app, e), })); @@ -233,6 +244,8 @@ pub fn build(app: &EffectcraftApp) -> NativeMenu { "Serviços" } else if crate::i18n::czech(app) { "Služby" + } else if crate::i18n::polish(app) { + "Usługi" } else if crate::i18n::georgian(app) { "სერვისები" } else { @@ -350,7 +363,12 @@ pub fn state_key(app: &EffectcraftApp) -> u64 { s.history.undo.iter().for_each(|(l, _)| l.hash(&mut h)); s.state.open_comps.hash(&mut h); s.active_comp_id().and_then(|c| s.state.views3d.get(&c)).map(|v| format!("{:?}", v.current)).hash(&mut h); - s.shortcuts().keys.len().hash(&mut h); + // Each binding's keys: a key changed to another one gives the item a new accelerator. + let shortcuts = s.shortcuts(); + for b in &shortcuts.bindables { + b.key.hash(&mut h); + shortcuts.keys.get(&b.key).hash(&mut h); + } s.keymaps.active.hash(&mut h); let v = &app.ui.viewer; (v.rulers, v.guides, v.snap_guides, v.lock_guides, v.grid, v.snap_grid, v.show_layer_controls, v.pasteboard).hash(&mut h); @@ -568,6 +586,27 @@ mod tests { assert_eq!(translated, ["Služby", "Skrýt EffectCraft", "Skrýt ostatní", "Zobrazit vše"]); } + #[test] + fn polish_translates_macos_predefined_items() { + if !cfg!(target_os = "macos") { + return; + } + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + app.session.execute("prefs.set", json!({"key": "general.language", "value": "pl"})).unwrap(); + fn labels(nodes: &[NativeNode], out: &mut Vec) { + for node in nodes { + match node { + NativeNode::Predefined { label, .. } => out.push(label.clone()), + NativeNode::Submenu { children, .. } => labels(children, out), + _ => {} + } + } + } + let mut translated = vec![]; + labels(&build(&app).menus, &mut translated); + assert_eq!(translated, ["Usługi", "Ukryj EffectCraft", "Ukryj pozostałe", "Pokaż wszystko"]); + } + #[test] fn georgian_translates_macos_predefined_items() { if !cfg!(target_os = "macos") { @@ -712,6 +751,24 @@ mod tests { assert!(spec2.items().iter().any(|i| i.command == "file.openRecent" && i.label == "Recent Test.ecproj")); } + /// A script's key changed to another key changes the key, so the File ▸ Scripts entry gets + /// the new accelerator even though the number of bindings stays the same. + #[test] + fn a_changed_shortcut_changes_the_state_key() { + let mut app = app(); + let script = json!({"name": "Rename Layers.jsx"}); + let b = app.session.shortcuts().find(&effectcraft_engine::shortcuts::binding_key("file.runScript", &script)).cloned().unwrap(); + app.session.keymaps.set(&b, &["Cmd+Alt+Shift+F7".to_string()]).unwrap(); + app.session.shortcuts_changed(); + let k0 = state_key(&app); + app.session.keymaps.set(&b, &["Cmd+Alt+Shift+F8".to_string()]).unwrap(); + app.session.shortcuts_changed(); + assert_ne!(state_key(&app), k0); + let spec = build(&app); + let entry = spec.items().into_iter().find(|i| i.command == "file.runScript" && i.params == script).unwrap(); + assert_eq!(entry.shortcut.as_deref(), Some("Cmd+Alt+Shift+F8")); + } + /// #1630: a checked Window menu entry closes its panel; an unchecked one opens it. #[test] fn window_menu_entry_toggles_its_panel() { @@ -742,4 +799,45 @@ mod tests { assert!(edits_text("edit.copy") && edits_text("edit.paste")); assert!(!edits_text("layer.newNull")); } + /// #2459: while a text field has the keyboard the Edit menu's Undo and Redo act on the field and + /// read plain "Undo" / "Redo"; the project history labels come back once focus leaves. + #[test] + fn a_focused_text_field_gets_plain_undo_and_redo_labels() { + fn label(menu: &NativeMenu, id: &str) -> String { + menu.items().into_iter().find(|i| i.command == id).map(|i| i.label.to_string()).unwrap_or_default() + } + for (language, undo, redo) in [("en", "Undo", "Redo"), ("ja", "取り消し", "やり直し")] { + let mut app = app(); + app.session.execute("prefs.set", json!({"key": "general.language", "value": language})).unwrap(); + app.session.execute("layer.newNull", json!({})).unwrap(); + app.session.execute("layer.newNull", json!({})).unwrap(); + app.session.execute("edit.undo", json!({})).unwrap(); + let ctx = egui::Context::default(); + let history = label(&build_now(&app, &ctx), "edit.undo"); + let redo_history = label(&build_now(&app, &ctx), "edit.redo"); + assert!(history.starts_with(undo) && history != undo, "{history}"); + assert!(redo_history.starts_with(redo) && redo_history != redo, "{redo_history}"); + let mut text = String::new(); + let mut id = None; + for _ in 0..2 { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + let r = ui.text_edit_singleline(&mut text); + r.request_focus(); + id = Some(r.id); + }); + out.textures_delta.clear(); + } + assert!(text_field_window(&ctx).is_some()); + let typing = build_now(&app, &ctx); + assert_eq!(label(&typing, "edit.undo"), undo); + assert_eq!(label(&typing, "edit.redo"), redo); + assert!(typing.items().into_iter().any(|i| i.command == "edit.undo" && i.enabled)); + assert!(typing.items().into_iter().any(|i| i.command == "edit.redo" && i.enabled)); + ctx.memory_mut(|m| m.surrender_focus(id.unwrap_or(egui::Id::NULL))); + assert!(text_field_window(&ctx).is_none()); + let after = build_now(&app, &ctx); + assert_eq!(label(&after, "edit.undo"), history); + assert_eq!(label(&after, "edit.redo"), redo_history); + } + } } diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 9a8fd2a54..f6a5b5321 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -27,6 +27,7 @@ pub mod key_dialogs; pub mod layer_panel; pub mod layer_styles_dialog; pub mod learn; +pub mod live_preview; pub mod markers_ui; pub mod media_panels; pub mod misc; @@ -35,6 +36,7 @@ pub mod paint_panels; pub mod panel_kit; pub mod path_vr_panels; pub mod precomp; +pub(crate) mod preview_merge; pub mod primitive_dialog; pub mod project; pub mod properties; @@ -175,6 +177,56 @@ pub(crate) fn space_hand(ctx: &egui::Context) -> bool { ctx.input(|i| i.key_down(egui::Key::Space)) && !ctx.egui_wants_keyboard_input() } +/// What a scroll gesture over a viewer (Composition, Layer) does this frame. +pub(crate) struct ViewerScroll { + /// Points to pan by: a two-finger trackpad scroll. + pub pan: egui::Vec2, + /// Factor to zoom by around the pointer: a pinch, Ctrl/Cmd+scroll, a mouse wheel, or + /// Alt + a trackpad scroll. + pub zoom: f32, +} + +/// Splits the frame's scroll input into a viewer pan and a zoom: a mouse wheel zooms and a +/// trackpad's two-finger scroll pans. +/// +/// Where the platform names the device (`MouseWheelSource`, macOS), that decides. Elsewhere a +/// wheel scrolls in lines, but in a browser and with some high-resolution wheels it scrolls in +/// points like a trackpad does, so the unit alone doesn't tell them apart. A scroll in points is +/// a trackpad's once it has shown what a wheel can't do: a touch phase, movement on both axes at +/// once, or a pinch. That holds until a wheel scrolls in lines. Until then the vertical part +/// zooms; the horizontal part always pans. +pub(crate) fn viewer_scroll(ctx: &egui::Context) -> ViewerScroll { + let id = egui::Id::unique("viewer-scroll-trackpad"); + // The scroll is smoothed over the frames after its event, so the device is remembered. + let was: bool = ctx.data(|d| d.get_temp(id)).unwrap_or(false); + let trackpad = ctx.input(|i| { + i.events.iter().fold(was, |t, e| match e { + egui::Event::MouseWheel { source: egui::MouseWheelSource::Trackpad | egui::MouseWheelSource::Momentum, .. } => true, + egui::Event::MouseWheel { source: egui::MouseWheelSource::Wheel, .. } => false, + egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta, phase, modifiers, .. } => { + // Shift turns a wheel's scroll sideways, which is still one axis. + t || *phase == egui::TouchPhase::Start || (delta.x != 0.0 && delta.y != 0.0 && !modifiers.shift) + } + egui::Event::MouseWheel { .. } => false, + egui::Event::Zoom(_) => true, + _ => t, + }) + }); + if trackpad != was { + ctx.data_mut(|d| d.insert_temp(id, trackpad)); + } + let (scroll, pinch, alt) = ctx.input(|i| (i.smooth_scroll_delta, i.zoom_delta(), i.modifiers.alt)); + let pinch = if pinch.is_finite() && pinch > 0.0 { pinch } else { 1.0 }; + let scroll = if scroll.x.is_finite() && scroll.y.is_finite() { scroll } else { egui::Vec2::ZERO }; + if trackpad && !alt { + ViewerScroll { pan: scroll, zoom: pinch } + } else if alt { + ViewerScroll { pan: egui::Vec2::ZERO, zoom: pinch * (scroll.y / 300.0).exp() } + } else { + ViewerScroll { pan: egui::vec2(scroll.x, 0.0), zoom: pinch * (scroll.y / 300.0).exp() } + } +} + pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rect) { match p { PanelKind::Composition => viewers::show(app, ui, 0, rect), @@ -281,7 +333,7 @@ pub fn panel_menu_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui) { } } _ => { - ui.label(egui::RichText::new(panel.title()).weak()); + ui.label(egui::RichText::new(crate::i18n::label(app, "window.panel", panel.title())).weak()); } } }); diff --git a/crates/ui-egui/src/panels/anim_tools.rs b/crates/ui-egui/src/panels/anim_tools.rs index b07da8567..e6b62efb4 100644 --- a/crates/ui-egui/src/panels/anim_tools.rs +++ b/crates/ui-egui/src/panels/anim_tools.rs @@ -127,7 +127,7 @@ pub fn wiggler(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.ui.anim_tools = st.clone(); let b = Rect::from_min_size(pos2(rect.max.x - 92.0, y), vec2(80.0, 24.0)); let enabled = app.session.state.selected_keys.len() >= 2; - if widgets::text_button(ui, b, "Apply", enabled, &t, egui::Id::unique("wg-apply")).clicked() { + if widgets::text_button(ui, b, crate::i18n::tr("Apply"), enabled, &t, egui::Id::unique("wg-apply")).clicked() { let params = json!({ "apply": if st.wiggle_spatial { "spatial" } else { "temporal" }, "noise": if st.wiggle_smooth { "smooth" } else { "jagged" }, @@ -175,7 +175,7 @@ pub fn smoother(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { y += ROW + 8.0; let b = Rect::from_min_size(pos2(rect.max.x - 92.0, y), vec2(80.0, 24.0)); let enabled = app.session.state.selected_keys.len() >= 3; - if widgets::text_button(ui, b, "Apply", enabled, &t, egui::Id::unique("sm-apply")).clicked() { + if widgets::text_button(ui, b, crate::i18n::tr("Apply"), enabled, &t, egui::Id::unique("sm-apply")).clicked() { apply(app, "keys.smooth", json!({"tolerance": tol})); } app.auto.add("smoother.apply", b, "Apply"); @@ -375,7 +375,7 @@ pub fn mask_interpolation(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rec changes.insert("keyframeRate".into(), json!(nr)); } } else { - p.text(pos2(xv + 78.0, y + 9.0), Align2::LEFT_CENTER, "per second", Tokens::ui(12.0), t.text_dim); + p.text(pos2(xv + 78.0, y + 9.0), Align2::LEFT_CENTER, crate::i18n::tr("per second"), Tokens::ui(12.0), t.text_dim); } y += ROW; let mut check = |app: &mut EffectcraftApp, y: f32, key: &str, text: &str, on: bool| { @@ -454,7 +454,7 @@ pub fn mask_interpolation(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rec } let b = Rect::from_min_size(pos2(rect.max.x - 92.0, y), vec2(80.0, 24.0)); let enabled = app.session.state.selected_keys.len() >= 2; - if widgets::text_button(ui, b, "Apply", enabled, &t, egui::Id::unique("mi-apply")).clicked() { + if widgets::text_button(ui, b, crate::i18n::tr("Apply"), enabled, &t, egui::Id::unique("mi-apply")).clicked() { match app.session.execute("mask.interpolate", json!({})) { Ok(r) => app.ui.status = format!("Mask Interpolation: {} keyframes added ({} vertices)", r["keys"], r["vertices"]), Err(e) => app.ui.status = e.to_string(), diff --git a/crates/ui-egui/src/panels/camera_tracker_ui.rs b/crates/ui-egui/src/panels/camera_tracker_ui.rs index bca98ea4b..3dc0be8d2 100644 --- a/crates/ui-egui/src/panels/camera_tracker_ui.rs +++ b/crates/ui-egui/src/panels/camera_tracker_ui.rs @@ -413,12 +413,12 @@ pub fn editor( let b1 = Rect::from_min_size(pos2(r.min.x + 24.0, r.min.y + 4.0), vec2(84.0, 22.0)); let b2 = Rect::from_min_size(pos2(b1.max.x + 8.0, b1.min.y), vec2(84.0, 22.0)); let b3 = Rect::from_min_size(pos2(b2.max.x + 8.0, b1.min.y), vec2(110.0, 22.0)); - if widgets::text_button(ui, b1, "Analyze", false, &t, egui::Id::unique(("ct-analyze", g.uid))).clicked() && !busy { + if widgets::text_button(ui, b1, crate::i18n::tr("Analyze"), false, &t, egui::Id::unique(("ct-analyze", g.uid))).clicked() && !busy { actions.push(("camera.analyze".into(), me.clone())); } app.auto.add(&format!("effectControls.cameraTracker.{}.analyze", g.uid), b1, "Analyze"); if running { - if widgets::text_button(ui, b2, "Cancel", false, &t, egui::Id::unique(("ct-cancel", g.uid))).clicked() { + if widgets::text_button(ui, b2, crate::i18n::tr("Cancel"), false, &t, egui::Id::unique(("ct-cancel", g.uid))).clicked() { actions.push(("camera.cancel".into(), json!({}))); } app.auto.add(&format!("effectControls.cameraTracker.{}.cancel", g.uid), b2, "Cancel"); @@ -426,7 +426,7 @@ pub fn editor( let params = effectcraft_engine::camera_track::static_params(g); let solve = ct::solve(¶ms); if solve.is_some() { - if widgets::text_button(ui, b3, "Create Camera", false, &t, egui::Id::unique(("ct-camera", g.uid))).clicked() { + if widgets::text_button(ui, b3, crate::i18n::tr("Create Camera"), false, &t, egui::Id::unique(("ct-camera", g.uid))).clicked() { actions.push(("camera.create".into(), me.clone())); } app.auto.add(&format!("effectControls.cameraTracker.{}.createCamera", g.uid), b3, "Create Camera"); diff --git a/crates/ui-egui/src/panels/comp_settings.rs b/crates/ui-egui/src/panels/comp_settings.rs index f06a069e8..01fbc5995 100644 --- a/crates/ui-egui/src/panels/comp_settings.rs +++ b/crates/ui-egui/src/panels/comp_settings.rs @@ -246,36 +246,18 @@ 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( +/// The controls of one tab. +fn tab_body( ui: &mut egui::Ui, + tab: Tab, 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); - ui.horizontal(|ui| { - for (tab, label, key) in [(Tab::Basic, "Basic", "basic"), (Tab::Advanced, "Advanced", "advanced"), (Tab::Renderer, "3D Renderer", "renderer")] { - let r = ui.selectable_label(d.tab == tab, label); - auto.add(&format!("dialog.comp.tab.{key}"), r.rect, label); - if r.clicked() { - d.tab = tab; - } - } - }); - ui.separator(); +) { let rate = FrameRate::from_f64(d.fps); - match d.tab { + match tab { Tab::Basic => { egui::Grid::new("comp-basic").num_columns(2).spacing([12.0, 9.0]).show(ui, |ui| { ui.label(tr("Preset:")); @@ -348,11 +330,11 @@ pub fn show( let cur = PIXEL_ASPECTS .iter() .find(|(_, v)| (v - d.pixel_aspect).abs() < 1e-3) - .map(|p| p.0.to_string()) + .map(|p| tr(p.0).to_string()) .unwrap_or_else(|| format!("{:.2}", d.pixel_aspect)); let r = craft_ui::ComboBox::from_id_salt("comp-par").width(220.0).selected_text(cur).show_ui(ui, |ui| { for (name, v) in PIXEL_ASPECTS { - if ui.selectable_label((v - d.pixel_aspect).abs() < 1e-3, *name).clicked() { + if ui.selectable_label((v - d.pixel_aspect).abs() < 1e-3, tr(name)).clicked() { d.pixel_aspect = *v; } } @@ -420,7 +402,7 @@ pub fn show( ui.horizontal(|ui| { let r = crate::widgets::srgb_color_button(ui, &mut d.bg); auto.add("dialog.comp.background", r.rect, "Background Color"); - ui.label(color_name(d.bg)); + ui.label(tr(color_name(d.bg))); }); ui.end_row(); }); @@ -485,6 +467,55 @@ pub fn show( }); } } +} + +/// The height `tab` would take at `top`, from laying it out unseen: it follows the font, the +/// language and the scale. Nothing it registers or changes is kept. +fn tab_height(ui: &mut egui::Ui, tab: Tab, d: &CompDraft, t: &Tokens, custom: &[CompPreset], top: f32) -> f32 { + let rect = egui::Rect::from_min_size(egui::pos2(ui.max_rect().left(), top), vec2(ui.max_rect().width(), 0.0)); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect).id_salt("comp-tab-measure").invisible()); + tab_body(&mut child, tab, &mut d.clone(), t, &mut Registry::default(), custom, &mut Vec::new()); + child.cursor().top() - top +} + +/// 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)>, + preview: Option<&mut bool>, +) -> 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); + ui.horizontal(|ui| { + for (tab, label, key) in [(Tab::Basic, "Basic", "basic"), (Tab::Advanced, "Advanced", "advanced"), (Tab::Renderer, "3D Renderer", "renderer")] { + let r = ui.selectable_label(d.tab == tab, tr(label)); + auto.add(&format!("dialog.comp.tab.{key}"), r.rect, label); + if r.clicked() { + d.tab = tab; + } + } + }); + ui.separator(); + // Every tab takes the height of the tallest one, so Cancel and OK stay put between tabs. + let body_top = ui.cursor().top(); + tab_body(ui, d.tab, d, t, auto, custom, cmds); + let shown = ui.cursor().top() - body_top; + let tallest = [Tab::Basic, Tab::Advanced, Tab::Renderer] + .into_iter() + .filter(|tab| *tab != d.tab) + .fold(shown, |h, tab| h.max(tab_height(ui, tab, d, t, custom, body_top))); + ui.add_space((tallest - shown).max(0.0)); ui.add_space(14.0); ui.horizontal(|ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { @@ -498,6 +529,9 @@ pub fn show( if r.clicked() { out = Some(serde_json::Value::Null); } + if let Some(on) = preview { + super::live_preview::checkbox(ui, auto, on, "dialog.comp.preview"); + } }); }); out diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 7c89ad32d..2a70d057d 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -66,6 +66,8 @@ pub struct DialogState { pub delete_items: super::delete_items::Pending, /// The Gradient Editor's draft and the property it edits. pub gradient: super::gradient_editor::Draft, + /// Preview of the settings dialog being edited (Solid, Camera, Light, Composition Settings). + pub live: super::live_preview::LivePreview, /// The user template the Delete Template prompt asks about: its id and name. pub delete_template: Option<(String, String)>, } @@ -76,6 +78,7 @@ pub fn open_new_comp(app: &mut EffectcraftApp) { app.dialog_state.comp = CompDraft { name: format!("Comp {n}"), tab: super::comp_settings::Tab::Basic, start_tc: None, dur_tc: None, preset_name: None, ..last }; app.dialog_state.editing_existing = false; + app.dialog_state.live = Default::default(); app.dialog = Some(Dialog::NewComp); } @@ -109,6 +112,7 @@ pub fn open_comp_settings_for(app: &mut EffectcraftApp, cid: ItemId) -> Result<( app.dialog_state.editing_existing = true; app.dialog_state.editing_comp = Some(cid); app.dialog = Some(Dialog::CompSettings); + super::live_preview::begin(app, Dialog::CompSettings); Ok(()) } @@ -123,6 +127,7 @@ pub fn open_new_solid(app: &mut EffectcraftApp) -> Result<(), String> { if d.solid_color == [0.0; 3] { d.solid_color = [0.2, 0.45, 0.85]; } + d.live = Default::default(); app.dialog = Some(Dialog::SolidSettings); Ok(()) } @@ -138,6 +143,7 @@ fn open_solid_settings(app: &mut EffectcraftApp, layer: u64, item: ItemId) -> Re (d.solid_name, d.solid_color, d.solid_size, d.solid_pixel_aspect) = (name, so.color, [so.width, so.height], so.pixel_aspect); (d.solid_layer, d.solid_shared, d.solid_affect_all) = (Some(layer), users > 1, false); app.dialog = Some(Dialog::SolidSettings); + super::live_preview::begin(app, Dialog::SolidSettings); Ok(()) } @@ -190,6 +196,31 @@ pub(crate) fn modal_vertical(ctx: &egui::Context, title: &str, size: egui::Vec2, modal_with_scroll(ctx, title, size, t, true, body); } +/// Where a modal dialog opens and whether it dims the window behind it. The default is the middle +/// of the window over a dimmed backdrop. +#[derive(Clone, Copy, Debug)] +pub struct Placement { + /// The opening position as a fraction of the room the dialog can move in, from the middle + /// `(0, 0)` to the bottom right `(1, 1)`. + pub start: egui::Vec2, + /// Dim the window behind the dialog. + pub dim: bool, +} + +impl Default for Placement { + fn default() -> Self { + Placement { start: vec2(0.0, 0.0), dim: true } + } +} + +/// [`modal`] at `place`: a dialog that edits what is behind it keeps the canvas in view. +pub(crate) fn modal_placed(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, place: Placement, body: impl FnOnce(&mut egui::Ui)) { + let max_height = (modal_space(ctx).y - 84.0).max(40.0); + modal_fit_placed(ctx, title, size, t, place, |ui, _| { + egui::ScrollArea::both().max_height(max_height).auto_shrink([false, true]).show(ui, body); + }); +} + fn modal_with_scroll(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, vertical_only: bool, body: impl FnOnce(&mut egui::Ui)) { let max_height = (modal_space(ctx).y - 84.0).max(40.0); modal_fit(ctx, title, size, t, |ui, _| { @@ -202,6 +233,11 @@ fn modal_with_scroll(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tok /// only its content: `body` gets the height left under the title, with `size` kept within the /// window. pub(crate) fn modal_fit(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, body: impl FnOnce(&mut egui::Ui, f32)) { + modal_fit_placed(ctx, title, size, t, Placement::default(), body); +} + +/// [`modal_fit`] at `place`. +pub(crate) fn modal_fit_placed(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, place: Placement, body: impl FnOnce(&mut egui::Ui, f32)) { let pass = ctx.cumulative_pass_nr_for(egui::ViewportId::ROOT); ctx.data_mut(|d| d.insert_temp(modal_pass_id(), pass)); let size = size.min(modal_space(ctx)); @@ -210,10 +246,10 @@ pub(crate) fn modal_fit(ctx: &egui::Context, title: &str, size: egui::Vec2, t: & // window, so dragging back never has to undo a part the dialog could not follow. let offset_id = id.with("offset"); let room = ((ctx.content_rect().size() - size) * 0.5).max(vec2(0.0, 0.0)); - let offset = ctx.data(|d| d.get_temp::(offset_id)).unwrap_or_default().clamp(-room, room); + let offset = ctx.data(|d| d.get_temp::(offset_id)).unwrap_or(room * place.start).clamp(-room, room); egui::Modal::new(id) .area(egui::Modal::default_area(id).anchor(Align2::CENTER_CENTER, offset)) - .backdrop_color(Color32::from_black_alpha(120)) + .backdrop_color(Color32::from_black_alpha(if place.dim { 120 } else { 0 })) .frame(egui::Frame::window(&ctx.style_of(ctx.theme())).fill(t.panel_bg).inner_margin(egui::Margin::same(18))) .show(ctx, |ui| { ui.set_width(size.x - 36.0); @@ -403,9 +439,15 @@ fn comp_settings(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens, exis let mut result = None; let custom = app.session.prefs.comp_presets.clone(); let mut cmds = Vec::new(); - modal(ctx, "Composition Settings", vec2(600.0, 470.0), t, |ui| { - result = super::comp_settings::show(ui, &mut d, t, &mut app.auto, &custom, &mut cmds); + // The title keys the modal id and its drag offset; `modal_fit` translates the heading itself, + // so passing the source keeps the dialog in place when the interface language changes. + let live = existing && super::live_preview::active(app); + let place = super::live_preview::place(app); + let mut preview = app.dialog_state.live.on; + modal_placed(ctx, "Composition Settings", vec2(600.0, 470.0), t, place, |ui| { + result = super::comp_settings::show(ui, &mut d, t, &mut app.auto, &custom, &mut cmds, live.then_some(&mut preview)); }); + app.dialog_state.live.on = preview; if d.preset_name.is_some() { // Save Preset asks for the name in its own window over the dialog, as After Effects does. result = None; @@ -423,11 +465,25 @@ fn comp_settings(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens, exis } app.dialog_state.comp = d; match result { - Some(serde_json::Value::Null) => app.dialog = None, + Some(serde_json::Value::Null) => { + super::live_preview::cancel(app); + app.dialog = None; + } Some(mut params) => { if existing && let (Some(cid), Some(m)) = (app.dialog_state.editing_comp, params.as_object_mut()) { m.insert("comp".into(), json!(cid.0)); } + // A preview already made the edit (one undo step): OK keeps it. + if existing && super::live_preview::keep(app, "comp.settings", params.clone()) { + // The Resolution is not part of the command, so the preview never applied it. + if let Some(cid) = app.dialog_state.editing_comp + && let Err(e) = set_resolution(app, cid.0, app.dialog_state.comp.resolution) + { + app.ui.status = e.to_string(); + } + app.dialog = None; + return; + } let r = if existing { app.session.execute("comp.settings", params) } else { app.session.execute("comp.new", params) }; let cid = if existing { app.dialog_state.editing_comp.map(|c| c.0) } else { r.as_ref().ok().and_then(|v| v["comp"].as_u64()) }; let r = match cid { @@ -442,7 +498,15 @@ fn comp_settings(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens, exis } app.dialog = None; } - None => {} + None => { + if existing && let Some(cid) = app.dialog_state.editing_comp { + let mut params = super::comp_settings::params(&app.dialog_state.comp); + if let Some(m) = params.as_object_mut() { + m.insert("comp".into(), json!(cid.0)); + } + super::live_preview::sync(app, "comp.settings", params); + } + } } } @@ -499,8 +563,11 @@ fn solid(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let comp_size = app.session.active_comp().map(|c| (c.width, c.height)).unwrap_or((1920, 1080)); let lock_id = egui::Id::unique("solid-lock-aspect"); let mut lock = ctx.data(|m| m.get_temp::(lock_id)).unwrap_or(true); + let live = super::live_preview::active(app); + let place = super::live_preview::place(app); + let mut preview = app.dialog_state.live.on; let auto = &mut app.auto; - modal(ctx, "Solid Settings", vec2(480.0, if editing.is_some() { 430.0 } else { 390.0 }), t, |ui| { + modal_placed(ctx, "Solid Settings", vec2(480.0, if editing.is_some() { 430.0 } else { 390.0 }), t, place, |ui| { ui.horizontal(|ui| { ui.label(tr("Name:")); let r = ui.add(egui::TextEdit::singleline(&mut name).desired_width(320.0)); @@ -586,16 +653,28 @@ fn solid(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let r = ui.button(tr("Cancel")); auto.add("dialog.solid.cancel", r.rect, "Cancel"); close = r.clicked(); + if live { + super::live_preview::checkbox(ui, auto, &mut preview, "dialog.solid.preview"); + } }); }); ctx.data_mut(|m| m.insert_temp(lock_id, lock)); let ds = &mut app.dialog_state; + ds.live.on = preview; (ds.solid_name, ds.solid_color, ds.solid_size, ds.solid_pixel_aspect, ds.solid_affect_all) = (name, color, size, par, affect_all); + let (command, params) = (solid_command(ds), solid_params(ds)); if ok || ui_enter(ctx) { - if let Err(e) = app.session.execute(solid_command(ds), solid_params(ds)) { + // A preview already made the edit (one undo step): OK keeps it. + if !super::live_preview::keep(app, command, params.clone()) + && let Err(e) = app.session.execute(command, params) + { app.ui.status = e.to_string(); } close = true; + } else if close { + super::live_preview::cancel(app); + } else { + super::live_preview::sync(app, command, params); } if close { app.dialog = None; diff --git a/crates/ui-egui/src/panels/dialogs_3d.rs b/crates/ui-egui/src/panels/dialogs_3d.rs index 0a4636b87..9b3535288 100644 --- a/crates/ui-egui/src/panels/dialogs_3d.rs +++ b/crates/ui-egui/src/panels/dialogs_3d.rs @@ -44,12 +44,19 @@ pub struct LightDraft { const FALLOFFS: [&str; 3] = ["None", "Smooth", "Inverse Square Clamped"]; -/// Whether a light type has Falloff and Shadows options. Ambient and Environment lights come -/// from no one place, so they have neither. +/// Whether a light type has Falloff options (distance attenuation): image-based Environment +/// lights and Ambient come from no one place, so they have none. fn falls_off(kind: LightKind) -> bool { !matches!(kind, LightKind::Ambient | LightKind::Environment) } +/// Whether a light type has Shadows options in the dialog. After Effects' Light Settings shows +/// Cast Shadows (with Shadow Darkness and Diffusion) for every light but Ambient — an +/// Environment light's shadow comes from its image, not a falloff. +fn casts_shadows(kind: LightKind) -> bool { + kind != LightKind::Ambient +} + fn comp_w(app: &EffectcraftApp) -> f64 { app.session.active_comp().map(|c| c.width as f64).unwrap_or(1920.0) } @@ -92,8 +99,14 @@ pub fn open_camera(app: &mut EffectcraftApp, layer: Option) -> Result<(), S } } }; + let editing = d.layer.is_some(); app.dialog_state.camera = d; app.dialog = Some(Dialog::CameraSettings); + if editing { + super::live_preview::begin(app, Dialog::CameraSettings); + } else { + app.dialog_state.live = Default::default(); + } Ok(()) } @@ -142,13 +155,19 @@ pub fn open_light(app: &mut EffectcraftApp, layer: Option) -> Result<(), St } } }; + let editing = d.layer.is_some(); app.dialog_state.light = d; app.dialog = Some(Dialog::LightSettings); + if editing { + super::live_preview::begin(app, Dialog::LightSettings); + } else { + app.dialog_state.live = Default::default(); + } Ok(()) } -fn buttons(ui: &mut egui::Ui, t: &Tokens, ok: &mut bool, close: &mut bool) -> (egui::Rect, egui::Rect) { - let mut r = (egui::Rect::NOTHING, egui::Rect::NOTHING); +fn buttons(ui: &mut egui::Ui, t: &Tokens, ok: &mut bool, close: &mut bool, preview: Option<&mut bool>) -> (egui::Rect, egui::Rect, egui::Rect) { + let mut r = (egui::Rect::NOTHING, egui::Rect::NOTHING, egui::Rect::NOTHING); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let b = ui.add(egui::Button::new(egui::RichText::new(tr(" OK ")).color(Color32::WHITE)).fill(t.accent)); r.0 = b.rect; @@ -160,6 +179,9 @@ fn buttons(ui: &mut egui::Ui, t: &Tokens, ok: &mut bool, close: &mut bool) -> (e if c.clicked() { *close = true; } + if let Some(on) = preview { + r.2 = ui.checkbox(on, tr("Preview")).rect; + } }); r } @@ -168,8 +190,11 @@ pub fn camera(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let w = comp_w(app); let mut d = app.dialog_state.camera.clone(); let (mut ok, mut close) = (false, false); - let mut rects = (egui::Rect::NOTHING, egui::Rect::NOTHING); - super::dialogs::modal(ctx, "Camera Settings", vec2(520.0, 420.0), t, |ui| { + let mut rects = (egui::Rect::NOTHING, egui::Rect::NOTHING, egui::Rect::NOTHING); + let live = super::live_preview::active(app); + let place = super::live_preview::place(app); + let mut preview = app.dialog_state.live.on; + super::dialogs::modal_placed(ctx, "Camera Settings", vec2(520.0, 420.0), t, place, |ui| { egui::Grid::new("cam-grid").num_columns(2).spacing([14.0, 9.0]).show(ui, |ui| { ui.label(tr("Type")); craft_ui::ComboBox::from_id_salt("cam-type").selected_text(if d.two_node { "Two-Node Camera" } else { "One-Node Camera" }).show_ui(ui, |ui| { @@ -222,46 +247,66 @@ pub fn camera(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.end_row(); }); ui.add_space(14.0); - rects = buttons(ui, t, &mut ok, &mut close); + rects = buttons(ui, t, &mut ok, &mut close, live.then_some(&mut preview)); }); + app.dialog_state.live.on = preview; if d.lock_to_zoom { d.focus = d.zoom; } app.auto.add("dialog.camera.ok", rects.0, "OK"); app.auto.add("dialog.camera.cancel", rects.1, "Cancel"); + if live { + app.auto.add("dialog.camera.preview", rects.2, "Preview"); + } app.dialog_state.camera = d.clone(); + let (id, p) = camera_command(&d); if ok || super::dialogs::ui_enter(ctx) { - let mut p = json!({ - "name": d.name, - "type": if d.two_node { "twoNode" } else { "oneNode" }, - "zoom": d.zoom, - "dof": d.dof, - "focusDistance": d.focus, - "aperture": d.aperture, - "blurLevel": d.blur, - }); - let id = match d.layer { - Some(l) => { - p["layer"] = json!(l); - "layer.cameraSettings" - } - None => "layer.newCamera", - }; - if let Err(e) = app.session.execute(id, p) { + // A preview already made the edit (one undo step): OK keeps it. + if !super::live_preview::keep(app, id, p.clone()) + && let Err(e) = app.session.execute(id, p) + { app.ui.status = e.to_string(); } close = true; + } else if close { + super::live_preview::cancel(app); + } else { + super::live_preview::sync(app, id, p); } if close { app.dialog = None; } } +/// The command Camera Settings' OK runs, and its parameters. +fn camera_command(d: &CameraDraft) -> (&'static str, Value) { + let mut p = json!({ + "name": d.name, + "type": if d.two_node { "twoNode" } else { "oneNode" }, + "zoom": d.zoom, + "dof": d.dof, + "focusDistance": d.focus, + "aperture": d.aperture, + "blurLevel": d.blur, + }); + let id = match d.layer { + Some(l) => { + p["layer"] = json!(l); + "layer.cameraSettings" + } + None => "layer.newCamera", + }; + (id, p) +} + pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let mut d = app.dialog_state.light.clone(); let (mut ok, mut close) = (false, false); - let mut rects = (egui::Rect::NOTHING, egui::Rect::NOTHING); - super::dialogs::modal(ctx, "Light Settings", vec2(480.0, 470.0), t, |ui| { + let mut rects = (egui::Rect::NOTHING, egui::Rect::NOTHING, egui::Rect::NOTHING); + let live = super::live_preview::active(app); + let place = super::live_preview::place(app); + let mut preview = app.dialog_state.live.on; + super::dialogs::modal_placed(ctx, "Light Settings", vec2(480.0, 470.0), t, place, |ui| { egui::Grid::new("light-grid").num_columns(2).spacing([14.0, 9.0]).show(ui, |ui| { ui.label(tr("Name")); let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(220.0)); @@ -306,8 +351,11 @@ pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.falloff_distance).range(0.0..=100000.0).suffix(" px"))); ui.end_row(); } + } + if casts_shadows(d.kind) { ui.label(tr("Shadows")); - ui.checkbox(&mut d.casts_shadows, tr("Casts Shadows")); + let cb = ui.checkbox(&mut d.casts_shadows, tr("Casts Shadows")); + app.auto.add_response("dialog.light.casts", &cb, "Casts Shadows"); ui.end_row(); ui.label(tr("Shadow Darkness")); ui.add_enabled(d.casts_shadows, crate::widgets::drag_value(egui::DragValue::new(&mut d.shadow_darkness).range(0.0..=100.0).suffix(" %"))); @@ -318,52 +366,71 @@ pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { } }); ui.add_space(14.0); - if falls_off(d.kind) { + if casts_shadows(d.kind) { ui.label(egui::RichText::new(tr("Note: Shadows are only cast from layers with Cast Shadows enabled.")).font(Tokens::ui(11.0)).color(t.text_dim)); ui.add_space(8.0); } - rects = buttons(ui, t, &mut ok, &mut close); + rects = buttons(ui, t, &mut ok, &mut close, live.then_some(&mut preview)); }); + app.dialog_state.live.on = preview; app.auto.add("dialog.light.ok", rects.0, "OK"); app.auto.add("dialog.light.cancel", rects.1, "Cancel"); + if live { + app.auto.add("dialog.light.preview", rects.2, "Preview"); + } app.dialog_state.light = d.clone(); + let (id, p) = light_command(&d); if ok || super::dialogs::ui_enter(ctx) { - let kind = d.kind; - let mut p = json!({ - "name": d.name, - "kind": kind.label(), - "color": [d.color[0], d.color[1], d.color[2]], - "intensity": d.intensity, - }); - if kind == LightKind::Spot { - p["coneAngle"] = json!(d.cone_angle); - p["coneFeather"] = json!(d.cone_feather); - } - if falls_off(kind) { - p["falloff"] = json!(d.falloff); - p["radius"] = json!(d.radius); - p["falloffDistance"] = json!(d.falloff_distance); - p["castsShadows"] = json!(d.casts_shadows); - p["shadowDarkness"] = json!(d.shadow_darkness); - p["shadowDiffusion"] = json!(d.shadow_diffusion); - } - let id = match d.layer { - Some(l) => { - p["layer"] = json!(l); - "layer.lightSettings" - } - None => "layer.newLight", - }; - if let Err(e) = app.session.execute(id, p) { + // A preview already made the edit (one undo step): OK keeps it. + if !super::live_preview::keep(app, id, p.clone()) + && let Err(e) = app.session.execute(id, p) + { app.ui.status = e.to_string(); } close = true; + } else if close { + super::live_preview::cancel(app); + } else { + super::live_preview::sync(app, id, p); } if close { app.dialog = None; } } +/// The command Light Settings' OK runs, and its parameters. +fn light_command(d: &LightDraft) -> (&'static str, Value) { + let kind = d.kind; + let mut p = json!({ + "name": d.name, + "kind": kind.label(), + "color": [d.color[0], d.color[1], d.color[2]], + "intensity": d.intensity, + }); + if kind == LightKind::Spot { + p["coneAngle"] = json!(d.cone_angle); + p["coneFeather"] = json!(d.cone_feather); + } + if falls_off(kind) { + p["falloff"] = json!(d.falloff); + p["radius"] = json!(d.radius); + p["falloffDistance"] = json!(d.falloff_distance); + } + if casts_shadows(kind) { + p["castsShadows"] = json!(d.casts_shadows); + p["shadowDarkness"] = json!(d.shadow_darkness); + p["shadowDiffusion"] = json!(d.shadow_diffusion); + } + let id = match d.layer { + Some(l) => { + p["layer"] = json!(l); + "layer.lightSettings" + } + None => "layer.newLight", + }; + (id, p) +} + /// Menu/shortcut routing: commands that open a 3D settings dialog when invoked without params. /// Returns true when a dialog was opened. pub fn route(app: &mut EffectcraftApp, id: &str, params: &Value) -> Result { diff --git a/crates/ui-egui/src/panels/effect_controls/editors.rs b/crates/ui-egui/src/panels/effect_controls/editors.rs index c95c90c07..bd566cb7f 100644 --- a/crates/ui-egui/src/panels/effect_controls/editors.rs +++ b/crates/ui-egui/src/panels/effect_controls/editors.rs @@ -8,12 +8,12 @@ pub(super) fn warp_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui: let busy = app.session.is_warp_analyzing(); let b1 = Rect::from_min_size(pos2(r.min.x + 24.0, r.min.y + 4.0), vec2(84.0, 22.0)); let b2 = Rect::from_min_size(pos2(b1.max.x + 8.0, b1.min.y), vec2(84.0, 22.0)); - if widgets::text_button(ui, b1, "Analyze", false, &t, egui::Id::unique(("warp-analyze", g.uid))).clicked() && !busy { + if widgets::text_button(ui, b1, crate::i18n::tr("Analyze"), false, &t, egui::Id::unique(("warp-analyze", g.uid))).clicked() && !busy { actions.push(("warp.analyze".into(), json!({"layer": layer.id.0, "effect": g.uid}))); } app.auto.add(&format!("effectControls.warp.{}.analyze", g.uid), b1, "Analyze"); if running { - if widgets::text_button(ui, b2, "Cancel", false, &t, egui::Id::unique(("warp-cancel", g.uid))).clicked() { + if widgets::text_button(ui, b2, crate::i18n::tr("Cancel"), false, &t, egui::Id::unique(("warp-cancel", g.uid))).clicked() { actions.push(("warp.cancel".into(), json!({}))); } app.auto.add(&format!("effectControls.warp.{}.cancel", g.uid), b2, "Cancel"); diff --git a/crates/ui-egui/src/panels/effect_controls/panel.rs b/crates/ui-egui/src/panels/effect_controls/panel.rs index 39cdee76c..122a6abf7 100644 --- a/crates/ui-egui/src/panels/effect_controls/panel.rs +++ b/crates/ui-egui/src/panels/effect_controls/panel.rs @@ -166,7 +166,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let comp_name = app.session.project.item(cid).map(|i| i.name.clone()).unwrap_or_default(); let snap_project = app.session.project.clone(); let snap_expr = app.session.expr.clone(); - let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time: app.session.time(), expr: snap_expr.as_deref(), footage: None }; + let ectx = EvalCtx { + project: &snap_project, + comp_id: cid, + comp: &comp, + time: app.session.time(), + expr: snap_expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let hdr = Rect::from_min_size(rect.min, vec2(rect.width(), 24.0)); p.text(pos2(hdr.min.x + 10.0, hdr.center().y), Align2::LEFT_CENTER, format!("{} • {}", comp_name, layer.name), Tokens::ui(11.5), t.text_dim); p.line_segment([hdr.left_bottom(), hdr.right_bottom()], Stroke::new(1.0, t.separator)); @@ -183,7 +192,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { bp.text( pos2(body.center().x, body.min.y + 40.0), Align2::CENTER_CENTER, - "No effects. Drag one from Effects & Presets.", + crate::i18n::tr("No effects. Drag one from Effects & Presets."), Tokens::ui(12.0), t.text_faint, ); diff --git a/crates/ui-egui/src/panels/effect_controls/rows.rs b/crates/ui-egui/src/panels/effect_controls/rows.rs index 05fea44c9..249c8462b 100644 --- a/crates/ui-egui/src/panels/effect_controls/rows.rs +++ b/crates/ui-egui/src/panels/effect_controls/rows.rs @@ -88,6 +88,11 @@ struct PropCx<'a> { } impl PropCx<'_> { + /// The tokens of the value fields: red while an expression drives the parameter (#1306). + fn value_tokens(&self) -> Tokens { + self.t.for_value(self.prop.has_expression()) + } + fn set(&self, actions: &mut Actions, v: serde_json::Value) { let (layer, uid, merge, scrub) = (self.layer, self.uid, format!("ec-{}", self.uid), self.scrub); actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": v, "merge": merge, "selected": true, "offset": scrub}))) @@ -243,8 +248,8 @@ fn value_angle(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: let Value::Scalar(v) = value else { return; }; - let PropCx { t, uid, cy, vx, prop, .. } = *cx; - let (rr, dr, nv) = fw::angle_field(ui, pos2(vx, cy - 9.0), egui::Id::unique(("ec-v", uid)), *v, 1, &t); + let PropCx { uid, cy, vx, prop, .. } = *cx; + let (rr, dr, nv) = fw::angle_field(ui, pos2(vx, cy - 9.0), egui::Id::unique(("ec-v", uid)), *v, 1, &cx.value_tokens()); app.auto.add(&format!("effectControls.prop.{uid}.value"), rr.union(dr), &fw::format_angle(*v, 1)); app.auto.add(&format!("effectControls.prop.{uid}.revolutions"), rr, &prop.name); if let Some(nv) = nv { @@ -257,7 +262,7 @@ fn value_scalar(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: let Value::Scalar(v) = value else { return; }; - let PropCx { t, uid, cy, vx, prop, .. } = *cx; + let PropCx { uid, cy, vx, prop, .. } = *cx; let (min, max, dec) = match &prop.ui { ParamUi::Slider { min, max, decimals, .. } => (*min, *max, *decimals as usize), _ => (-1e9, 1e9, 1), @@ -273,7 +278,7 @@ fn value_scalar(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: egui::Id::unique(("ec-v", uid)), *v, &widgets::HotSpec { speed, range: (min, max), decimals: dec.max(if dec == 0 { 0 } else { 1 }), suffix }, - &t, + &cx.value_tokens(), ); app.auto.add(&format!("effectControls.prop.{uid}.value"), vr, &prop.name); if let Some(nv) = nv { @@ -290,6 +295,7 @@ fn value_vector(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: let PropCx { t, uid, cy, vx, layer, prop, .. } = *cx; let c = value.components(); let n = shown_dims(layer, prop, value); + let vt = cx.value_tokens(); let mut x = vx; if matches!(prop.ui, ParamUi::Point | ParamUi::Point3) { let cr = Rect::from_center_size(pos2(x + 8.0, cy), vec2(18.0, 18.0)); @@ -310,7 +316,7 @@ fn value_vector(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: egui::Id::unique(("ec-v", uid, d)), c[d], &widgets::HotSpec { speed: 1.0, range: (-1e9, 1e9), decimals: 1, suffix: "" }, - &t, + &vt, ); app.auto.add(&format!("effectControls.prop.{uid}.value.{d}"), vr, &prop.name); dims.push(vr); diff --git a/crates/ui-egui/src/panels/effects_presets.rs b/crates/ui-egui/src/panels/effects_presets.rs index 380d1d869..e140e49e1 100644 --- a/crates/ui-egui/src/panels/effects_presets.rs +++ b/crates/ui-egui/src/panels/effects_presets.rs @@ -17,6 +17,7 @@ use crate::{EffectcraftApp, widgets}; const ROW_H: f32 = 21.0; const RECENT_MAX: usize = 8; +pub(crate) const REVEAL_PRESETS_ID: &str = "fx-reveal-presets"; /// A user animation preset file. #[derive(Clone, Debug, PartialEq)] @@ -157,6 +158,24 @@ fn folder_row( open } +/// Number of visible rows before Animation Presets in the current panel state. +fn rows_before_presets(favorites: usize, recent: usize, searching: bool, favorites_open: bool, recent_open: bool) -> usize { + let mut rows = 0usize; + if favorites > 0 { + rows = rows.saturating_add(1); + if searching || favorites_open { + rows = rows.saturating_add(favorites); + } + } + if !searching && recent > 0 { + rows = rows.saturating_add(1); + if recent_open { + rows = rows.saturating_add(recent); + } + } + rows +} + /// An effect row (fx icon, highlighted name, favourite star, 32 bpc badge). #[allow(clippy::too_many_arguments)] fn effect_row( @@ -357,14 +376,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } let sr = Rect::from_min_size(rect.min + vec2(8.0, 8.0), vec2(rect.width() - 44.0, 24.0)); let mut q = app.ui.effects_search.clone(); - widgets::search_field(ui, sr, &mut q, "Search effects and presets", &t); + widgets::search_field(ui, sr, &mut q, crate::i18n::tr("Search effects and presets"), &t); app.ui.effects_search = q.clone(); app.auto.add("effects.search", sr, "Search"); let force_scan = contents_menu(app, ui, &ctx, rect, sr, &t); let view = app.ui.effects_view.clone(); let list = Rect::from_min_max(pos2(rect.min.x, sr.max.y + 6.0), rect.max); let lp = p.with_clip_rect(list); - let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("fx-scroll"), list); + let mut scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("fx-scroll"), list); let query = q.trim().to_lowercase(); let searching = !query.is_empty(); let mut y = list.min.y - scroll.offset; @@ -378,6 +397,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // Favorites and Recently Used. let favs: Vec<_> = app.session.prefs.effects_favorites.iter().filter_map(|id| lookup(id)).filter(|e| keep(e)).collect(); + let recent: Vec<_> = app.ui.effects_recent.iter().filter_map(|id| lookup(id)).filter(|e| keep(e)).collect(); + let reveal_presets = ctx.data_mut(|data| data.remove_temp::(egui::Id::unique(REVEAL_PRESETS_ID))).unwrap_or(false); + if reveal_presets { + // These rows come before Animation Presets. Scroll past expanded lists so the section is + // visible even when Favorites or Recently Used is long. + let preceding_rows = + rows_before_presets(favs.len(), recent.len(), searching, app.ui.effects_open.contains("favorites"), app.ui.effects_open.contains("recent")); + scroll.offset = ROW_H * preceding_rows as f32; + y = list.min.y - scroll.offset; + } if !favs.is_empty() && folder_row(app, ui, &lp, list, &mut y, "favorites", "Favorites", 0.0, searching, Some(Icon::Star)) { let mut favs = favs; favs.sort_by_key(|e| e.name); @@ -385,7 +414,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { effect_row(app, ui, &lp, list, &mut y, e, 0.0, &query, "effects.favorites", &mut apply); } } - let recent: Vec<_> = app.ui.effects_recent.iter().filter_map(|id| lookup(id)).filter(|e| keep(e)).collect(); if !recent.is_empty() && !searching && folder_row(app, ui, &lp, list, &mut y, "recent", "Recently Used", 0.0, false, None) { for e in recent { effect_row(app, ui, &lp, list, &mut y, e, 0.0, &query, "effects.recent", &mut apply); diff --git a/crates/ui-egui/src/panels/expr_editor.rs b/crates/ui-egui/src/panels/expr_editor.rs index 157288b94..6d742dc4e 100644 --- a/crates/ui-egui/src/panels/expr_editor.rs +++ b/crates/ui-egui/src/panels/expr_editor.rs @@ -111,6 +111,7 @@ pub const COMPLETIONS: &[&str] = &[ "ease", "easeIn", "easeOut", + "bezier", "clamp", "random", "gaussRandom", diff --git a/crates/ui-egui/src/panels/flowchart.rs b/crates/ui-egui/src/panels/flowchart.rs index c7019492f..89cc3493e 100644 --- a/crates/ui-egui/src/panels/flowchart.rs +++ b/crates/ui-egui/src/panels/flowchart.rs @@ -250,7 +250,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let p = ui.painter().with_clip_rect(rect); p.rect_filled(rect, 0.0, t.tl_bg); let Some(rid) = root(app) else { - p.text(rect.center(), Align2::CENTER_CENTER, "(no composition)", Tokens::ui(12.0), t.text_faint); + p.text(rect.center(), Align2::CENTER_CENTER, crate::i18n::tr("(no composition)"), Tokens::ui(12.0), t.text_faint); return; }; // ---- toolbar: switches and direction. diff --git a/crates/ui-egui/src/panels/footage_panel.rs b/crates/ui-egui/src/panels/footage_panel.rs index 64c2ced12..ef6458475 100644 --- a/crates/ui-egui/src/panels/footage_panel.rs +++ b/crates/ui-egui/src/panels/footage_panel.rs @@ -68,7 +68,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let cached: Option<((u64, i64, u64), egui::TextureHandle, [u32; 2])> = ctx.data(|d| d.get_temp(tid)); let tex = match cached { Some((k, tex, size)) if k == key => Some((tex, size)), - _ => app.session.footage.frame(view.item, f, view.time).map(|img| { + _ => effectcraft_engine::commands::footage_panel::frame(&app.session, view.item, f, view.time).map(|img| { let ci = egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &img.to_rgba8()); let tex = crate::frames::load_fitted(&ctx, "footage-panel", ci, egui::TextureOptions::LINEAR); ctx.data_mut(|d| d.insert_temp(tid, (key, tex.clone(), [img.width, img.height]))); diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs index e121d5d02..29364275b 100644 --- a/crates/ui-egui/src/panels/forms.rs +++ b/crates/ui-egui/src/panels/forms.rs @@ -233,7 +233,7 @@ fn new_comp_from_selection_form(s: &effectcraft_engine::Session) -> (String, Vec } /// The Interpret Footage dialog for the selected footage (or its proxy), if there is one. -fn interpret_footage_form(s: &effectcraft_engine::Session, proxy: bool) -> Option<(String, Vec)> { +fn interpret_footage_form(s: &effectcraft_engine::Session, proxy: bool, base: &mut Value) -> Option<(String, Vec)> { let f = s.state.project_selection.first().and_then(|i| s.project.item(*i)).and_then(|it| match (&it.kind, &it.proxy) { (_, Some(px)) if proxy => Some(px.footage.clone()), (effectcraft_engine::project::ItemKind::Footage(f), _) if !proxy => Some(f.clone()), @@ -286,6 +286,30 @@ fn interpret_footage_form(s: &effectcraft_engine::Session, proxy: bool) -> Optio Field::bool("preserveRgb", "Preserve RGB", f.preserve_rgb), Field::bool("linearLight", "Interpret As Linear Light", f.linear_light), ]; + // Color ▸ Cineon Settings (DPX and Cineon files). The converted points show in the + // footage's Units, and `pointUnits` reads them back in those units when Units changes. + if f.is_cineon_file() { + use effectcraft_engine::project::CineonUnits; + let c = f.cineon; + let units: Vec<(&str, Value)> = CineonUnits::ALL.iter().map(|u| (u.label(), json!(u.id()))).collect(); + fields.extend([ + Field::note("cineonSettings", "Cineon Settings"), + Field::bool("logConversion", "Logarithmic Conversion", c.log_conversion), + Field::num("convertedBlack", "Converted Black Point", c.units.from_ten_bit(f64::from(c.converted_black))), + Field::num("convertedWhite", "Converted White Point", c.units.from_ten_bit(f64::from(c.converted_white))), + Field::num("tenBitBlack", "10 Bit Black Point", f64::from(c.black)), + Field::num("tenBitWhite", "10 Bit White Point", f64::from(c.white)), + Field::num("currentGamma", "Current Gamma", f64::from(c.gamma)), + Field::num("highlightRolloff", "Highlight Rolloff", f64::from(c.rolloff)), + Field::choice("cineonUnits", "Units", &units, CineonUnits::ALL.iter().position(|u| *u == c.units).unwrap_or(0)), + ]); + if !base.is_object() { + *base = json!({}); + } + if let Some(m) = base.as_object_mut() { + m.insert("pointUnits".into(), json!(c.units.id())); + } + } // After Frame Rate: an image sequence's frames (missing ones show colour bars, as in // After Effects), then Start Timecode. let mut timing = Vec::new(); @@ -487,7 +511,7 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool { let layer = comp.and_then(|c| s.state.selected_layers.first().and_then(|l| c.layer(*l))); let t = s.time(); let lt = layer.map(|l| l.layer_time(t)).unwrap_or(t); - let base = p.clone(); + let mut base = p.clone(); let (title, fields): (String, Vec) = match id { // Photoshop files: Import Kind (Footage / Composition / – Retain Layer Sizes). Dropped // files start at Settings ▸ Import ▸ Default Drag Import As. @@ -651,10 +675,33 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool { ], ), "file.interpretFootage" | "file.interpretProxy" - if !has(p, &["frameRate", "alpha", "loop", "pixelAspect", "colorProfile", "fields", "invertAlpha", "matteColor", "linearLight", "startFrame"]) => + if !has( + p, + &[ + "frameRate", + "alpha", + "loop", + "pixelAspect", + "colorProfile", + "fields", + "invertAlpha", + "matteColor", + "linearLight", + "startFrame", + "logConversion", + "cineonUnits", + "pointUnits", + "convertedBlack", + "convertedWhite", + "tenBitBlack", + "tenBitWhite", + "currentGamma", + "highlightRolloff", + ], + ) => { let proxy = id == "file.interpretProxy"; - let Some(form) = interpret_footage_form(s, proxy) else { return false }; + let Some(form) = interpret_footage_form(s, proxy, &mut base) else { return false }; form } "file.projectSettings" if p.as_object().is_none_or(|m| m.is_empty()) => project_settings_form(s), @@ -1074,4 +1121,83 @@ mod tests { assert_eq!(r["path"], path.as_str()); assert!(std::fs::read_to_string(&path).unwrap().contains(">(); + app.session.state.project_selection = vec![png]; + assert!(open_form(&mut app, "file.interpretFootage", &json!({}))); + assert!(!keys(&app).iter().any(|k| k == "logConversion"), "{:?}", keys(&app)); + app.session.state.project_selection = vec![dpx]; + assert!(open_form(&mut app, "file.interpretFootage", &json!({}))); + for k in ["logConversion", "convertedBlack", "convertedWhite", "tenBitBlack", "tenBitWhite", "currentGamma", "highlightRolloff", "cineonUnits"] { + assert!(keys(&app).iter().any(|x| x == k), "{k}: {:?}", keys(&app)); + } + // Units ▸ 8-bit with Converted White Point still showing 1023 (10-bit). + let form = &mut app.dialog_state.form; + for f in form.fields.iter_mut() { + match (f.key.as_str(), &mut f.kind) { + ("cineonUnits", FieldKind::Choice { sel, .. }) => *sel = 1, + ("logConversion", FieldKind::Bool(b)) => *b = true, + _ => {} + } + } + let params = form.params(); + assert_eq!((params["pointUnits"].as_str(), params["cineonUnits"].as_str()), (Some("10bit"), Some("8bit")), "{params}"); + app.session.execute("file.interpretFootage", params).unwrap(); + let Some(ItemKind::Footage(f)) = app.session.project.item(dpx).map(|i| &i.kind) else { panic!("not footage") }; + assert!(f.cineon.log_conversion && f.cineon.units == CineonUnits::EightBit, "{:?}", f.cineon); + assert!((f.cineon.converted_white - 1023.0).abs() < 1e-3, "{:?}", f.cineon); + // Opened again, the point shows in 8-bit units. + assert!(open_form(&mut app, "file.interpretFootage", &json!({}))); + let white = app.dialog_state.form.fields.iter().find(|f| f.key == "convertedWhite").map(|f| f.kind.clone()); + assert!(matches!(white, Some(FieldKind::Number { value, .. }) if (value - 255.0).abs() < 1e-6), "{white:?}"); + } } diff --git a/crates/ui-egui/src/panels/fx_editors.rs b/crates/ui-egui/src/panels/fx_editors.rs index 4ed96797a..85b6be25f 100644 --- a/crates/ui-egui/src/panels/fx_editors.rs +++ b/crates/ui-egui/src/panels/fx_editors.rs @@ -600,7 +600,7 @@ fn colorama_wheel( p.text( pos2(sr.min.x, sr.max.y + 9.0), Align2::LEFT_CENTER, - "Click the ring to add a colour; Alt-click a stop (or drag it off) to remove it", + crate::i18n::tr("Click the ring to add a colour; Alt-click a stop (or drag it off) to remove it"), Tokens::ui(10.5), t.text_faint, ); @@ -801,19 +801,19 @@ fn reshape_buttons( let b1 = Rect::from_min_size(pos2(r.min.x + 24.0, r.min.y + 4.0), vec2(84.0, 22.0)); let b2 = Rect::from_min_size(pos2(b1.max.x + 8.0, b1.min.y), vec2(96.0, 22.0)); let b3 = Rect::from_min_size(pos2(b2.max.x + 8.0, b1.min.y), vec2(56.0, 22.0)); - if widgets::text_button(ui, b1, "Add Point", false, &t, egui::Id::unique(("reshape-add", euid))).clicked() { + if widgets::text_button(ui, b1, crate::i18n::tr("Add Point"), false, &t, egui::Id::unique(("reshape-add", euid))).clicked() { let mut v = pairs.clone(); v.push(add_pair(&pairs)); set_str(actions, layer, g, "correspondencePoints", format_pairs(&v), None); } app.auto.add(&format!("effectControls.effect.{euid}.reshape.add"), b1, "Add Point"); - if widgets::text_button(ui, b2, "Remove Point", false, &t, egui::Id::unique(("reshape-remove", euid))).clicked() && !pairs.is_empty() { + if widgets::text_button(ui, b2, crate::i18n::tr("Remove Point"), false, &t, egui::Id::unique(("reshape-remove", euid))).clicked() && !pairs.is_empty() { let mut v = pairs.clone(); v.pop(); set_str(actions, layer, g, "correspondencePoints", format_pairs(&v), None); } app.auto.add(&format!("effectControls.effect.{euid}.reshape.remove"), b2, "Remove Point"); - if widgets::text_button(ui, b3, "Clear", false, &t, egui::Id::unique(("reshape-clear", euid))).clicked() && !pairs.is_empty() { + if widgets::text_button(ui, b3, crate::i18n::tr("Clear"), false, &t, egui::Id::unique(("reshape-clear", euid))).clicked() && !pairs.is_empty() { set_str(actions, layer, g, "correspondencePoints", String::new(), None); } app.auto.add(&format!("effectControls.effect.{euid}.reshape.clear"), b3, "Clear"); diff --git a/crates/ui-egui/src/panels/gradient_editor.rs b/crates/ui-egui/src/panels/gradient_editor.rs index e132a42d1..25e3d1d44 100644 --- a/crates/ui-egui/src/panels/gradient_editor.rs +++ b/crates/ui-egui/src/panels/gradient_editor.rs @@ -3,14 +3,18 @@ //! Opacity stops sit above the preview bar and colour stops below it. Click above or below the //! bar to add a stop (it takes that track's value there), drag a stop to move it, edit the //! selected stop's location, opacity or colour, and Delete removes it (never the last stop of a -//! track). The editor works on a draft: OK sets the property once with `prop.set` at the time it -//! opened (one undo step, nothing at all when nothing changed); Cancel and Escape leave it as it -//! was. +//! track). The editor works on a draft. With **Preview** on (the default, as in After Effects) each +//! edit is applied to the property at once, with `prop.set` at the time the editor opened and one +//! merged undo step, so the canvas shows the gradient as it is edited; OK keeps it, Cancel and +//! Escape roll it back to the checkpoint taken when the editor opened. With Preview off, OK sets +//! the property once (one undo step, nothing at all when nothing changed). The dialog opens at +//! the lower right of the window and does not dim it, so the canvas stays in view. //! //! Automation ids: `dialog.gradient.bar`, `dialog.gradient.addOpacity`, `dialog.gradient.addColor`, //! `dialog.gradient.opacityStop.`, `dialog.gradient.colorStop.` (in order along the bar), //! `dialog.gradient.location`, `dialog.gradient.opacity`, `dialog.gradient.color`, -//! `dialog.gradient.delete`, `dialog.gradient.ok`, `dialog.gradient.cancel`. +//! `dialog.gradient.delete`, `dialog.gradient.preview`, `dialog.gradient.ok`, +//! `dialog.gradient.cancel`. use effectcraft_engine::keyframe::{Gradient, Value}; use effectcraft_engine::project::{ItemId, LayerId}; @@ -18,6 +22,8 @@ use egui::{Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; use serde_json::{Value as Json, json}; use crate::i18n::tr; +use crate::panels::layer_styles_dialog::{Checkpoint, checkpoint}; +use crate::panels::preview_merge::{self, Applied}; use crate::theme::Tokens; use crate::{Dialog, EffectcraftApp}; @@ -58,6 +64,14 @@ pub struct Draft { pub baseline: Gradient, /// Stop counts (colour, opacity) of the keyframes just before and after the edit time. pub neighbors: Vec<(usize, usize)>, + /// Preview: edits are applied to the property as they are made. + pub preview: bool, + /// The gradient the property was last given by the preview, if it has been. + pub applied: Option, + /// The project as the preview left it, to tell edits made since from the preview's. + applied_at: Option, + /// The project and undo history as the editor opened, for Cancel. + checkpoint: Option, next_id: u64, } @@ -220,7 +234,10 @@ pub fn open(app: &mut EffectcraftApp, p: &Json) -> Result { let before = prop.keys.iter().rev().find(|k| k.time < lt).and_then(|k| counts(&k.value)); let after = prop.keys.iter().find(|k| k.time > lt).and_then(|k| counts(&k.value)); let target = Target { comp: cid.0, layer: lid, prop: uid, time: ct.seconds() }; - app.dialog_state.gradient = Draft::new(&g, Some(target), before.into_iter().chain(after).collect()); + let mut draft = Draft::new(&g, Some(target), before.into_iter().chain(after).collect()); + draft.preview = true; + draft.checkpoint = Some(checkpoint(app)); + app.dialog_state.gradient = draft; app.dialog = Some(Dialog::GradientEditor); Ok(json!({"dialog": "gradientEditor"})) } @@ -231,6 +248,11 @@ fn commit(app: &mut EffectcraftApp, ctx: &egui::Context) { app.dialog = None; let d = std::mem::take(&mut app.dialog_state.gradient); let g = d.gradient(); + // The preview already set the property to this draft, in one merged undo step: OK keeps it. + if d.applied.as_ref() == Some(&g) { + app.session.history.merge_key = None; + return; + } let Some(tg) = d.target else { return }; if g == d.baseline { return; @@ -241,6 +263,55 @@ fn commit(app: &mut EffectcraftApp, ctx: &egui::Context) { } } +/// Close the editor without keeping the preview: the project and undo history go back to how they +/// were when it opened (nothing happens when the preview never changed the property). +pub fn finish(app: &mut EffectcraftApp, ok: bool, ctx: &egui::Context) { + if ok { + // An edit made in the same frame as OK is not in the last preview yet. + sync_preview(app, ctx); + commit(app, ctx); + return; + } + let d = std::mem::take(&mut app.dialog_state.gradient); + if d.applied.is_some() + && let Some(cp) = d.checkpoint + { + preview_merge::rollback(app, cp, d.applied_at.as_ref()); + } + app.session.history.merge_key = None; + app.dialog = None; +} + +/// Keep the property in step with the draft while Preview is on: apply a changed draft with one +/// merged undo step, and roll back to the checkpoint when Preview goes off or the draft is back to +/// how it opened. +fn sync_preview(app: &mut EffectcraftApp, ctx: &egui::Context) { + let d = &app.dialog_state.gradient; + let Some(tg) = d.target.clone() else { return }; + let g = d.gradient(); + let want = d.preview && g != d.baseline; + if want && d.applied.as_ref() != Some(&g) { + let params = json!({ + "comp": tg.comp, "layer": tg.layer, "prop": tg.prop, "time": tg.time, "timeBase": "comp", + "value": Value::Gradient(g.clone()).to_json(), "merge": format!("gradient-editor-{}", tg.prop), + }); + match crate::menus::invoke(app, ctx, "prop.set", params) { + Ok(_) => { + app.dialog_state.gradient.applied = Some(g); + app.dialog_state.gradient.applied_at = Some(preview_merge::applied(app)); + } + Err(e) => app.ui.status = e, + } + } else if !want + && d.applied.is_some() + && let Some(cp) = d.checkpoint.clone() + { + let at = app.dialog_state.gradient.applied_at.take(); + preview_merge::rollback(app, cp, at.as_ref()); + app.dialog_state.gradient.applied = None; + } +} + pub(super) fn to_color32(c: [f32; 4]) -> Color32 { let b = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; Color32::from_rgba_unmultiplied(b(c[0]), b(c[1]), b(c[2]), b(c[3])) @@ -258,7 +329,8 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { return; } let mut answer: Option = None; - super::dialogs::modal(ctx, tr("Gradient Editor"), vec2(520.0, 270.0), t, |ui| { + // At the lower right, undimmed: the canvas it edits stays in view (After Effects). + super::dialogs::modal_placed(ctx, tr("Gradient Editor"), vec2(520.0, 270.0), t, super::live_preview::PLACE, |ui| { let w = ui.available_width().max(200.0); let (area, _) = ui.allocate_exact_size(vec2(w, 72.0), Sense::hover()); let bar = Rect::from_min_max(pos2(area.min.x + 8.0, area.min.y + 22.0), pos2(area.max.x - 8.0, area.min.y + 48.0)); @@ -416,6 +488,10 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { if r.clicked() { answer = Some(true); } + let mut preview = app.dialog_state.gradient.preview; + let r = ui.checkbox(&mut preview, tr("Preview")); + app.auto.add("dialog.gradient.preview", r.rect, "Preview"); + app.dialog_state.gradient.preview = preview; }); }); // Delete / Backspace remove the selected stop, but not while typing in a field. @@ -427,12 +503,8 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { app.dialog_state.gradient.remove(track, id); } match answer { - Some(true) => commit(app, ctx), - Some(false) => { - app.dialog = None; - app.dialog_state.gradient = Draft::default(); - } - None => {} + Some(ok) => finish(app, ok, ctx), + None => sync_preview(app, ctx), } } diff --git a/crates/ui-egui/src/panels/graph.rs b/crates/ui-egui/src/panels/graph.rs index 24df22616..35a745c81 100644 --- a/crates/ui-egui/src/panels/graph.rs +++ b/crates/ui-egui/src/panels/graph.rs @@ -48,9 +48,21 @@ struct KeyPt { t: f64, v: f64, sel: bool, + muted: bool, handles: Vec, } +/// A graph key's fill and edge colours: muted keys stay visible and selectable, dimmed. +fn key_colors(t: &Tokens, edge: Color32, k: &KeyPt) -> (Color32, Color32) { + let fill = match (k.muted, k.sel) { + (true, true) => t.keyframe_selected.gamma_multiply(0.5), + (true, false) => Color32::from_gray(0x70), + (false, true) => t.keyframe_selected, + (false, false) => Color32::from_gray(0xe0), + }; + (fill, if k.muted { edge.gamma_multiply(0.4) } else { edge }) +} + struct Curve<'a> { layer: &'a Layer, prop: &'a Property, @@ -153,7 +165,7 @@ fn curves<'a>(app: &EffectcraftApp, comp: &'a Comp, ectx: &EvalCtx, tm: TMap, pl let dt = e.influence * dur * if out_side { 1.0 } else { -1.0 }; let (hv, show) = if speed { (e.speed.abs(), true) } else { (k.value.components().get(d).copied().unwrap_or(0.0) + e.speed * dt, !spatial) }; let bezier = if out_side { k.out_interp == Interp::Bezier } else { k.in_interp == Interp::Bezier }; - if ks && show && (bezier || speed) { + if ks && !k.muted && show && (bezier || speed) { handles.push(Handle { out: out_side, t: kt + dt, v: hv, speed: e.speed, dur }); } } @@ -162,7 +174,7 @@ fn curves<'a>(app: &EffectcraftApp, comp: &'a Comp, ectx: &EvalCtx, tm: TMap, pl } else { k.value.components().get(d).copied().unwrap_or(0.0) }; - keys.push(KeyPt { idx: i, time: k.time, t: kt, v, sel: ks, handles }); + keys.push(KeyPt { idx: i, time: k.time, t: kt, v, sel: ks, muted: k.muted, handles }); } out.push(Curve { layer: l, prop: pr, dim: d, color, pts, keys, editable: !spatial }); } @@ -221,7 +233,7 @@ impl ButtonBar { let chosen = widgets::popup_list(ui, pid, pos, r, rows, 0, |ui| { let mut chosen = None; for (id, label, on, tip) in &self.hidden { - let item = ui.selectable_label(*on, label.as_str()).on_hover_text(tip.as_str()); + let item = ui.selectable_label(*on, crate::i18n::tr(label)).on_hover_text(crate::i18n::tr(tip)); app.auto.add(&format!("timeline.graph.more.{id}"), item.rect, tip); if item.clicked() { chosen = Some(id.clone()); @@ -251,14 +263,16 @@ struct BarBtn<'a> { fn text_button(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, bar: &mut ButtonBar, y: f32, btn: BarBtn) -> bool { let BarBtn { label, on, id, tip } = btn; let t = app.tokens; - let w = p.layout_no_wrap(label.to_string(), Tokens::ui(11.0), t.text).size().x + 14.0; + let (shown, shown_tip) = (crate::i18n::tr(label), crate::i18n::tr(tip)); + let w = p.layout_no_wrap(shown.to_string(), Tokens::ui(11.0), t.text).size().x + 14.0; // Once one button overflows, the rest follow it into the menu (they keep their order). if !bar.hidden.is_empty() || bar.x + w > bar.limit { + // The menu translates these English labels when it draws them; automation keeps them. bar.hidden.push((id.to_string(), label.to_string(), on, tip.to_string())); return bar.chosen.as_deref() == Some(id); } let r = Rect::from_min_size(pos2(bar.x, y - 9.0), vec2(w, 18.0)); - let resp = ui.interact(r, egui::Id::unique(("graph-btn", id)), Sense::click()).on_hover_text(tip); + let resp = ui.interact(r, egui::Id::unique(("graph-btn", id)), Sense::click()).on_hover_text(shown_tip); p.rect_filled( r, 3.0, @@ -270,7 +284,7 @@ fn text_button(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, b Color32::from_rgb(0x2c, 0x2c, 0x2c) }, ); - p.text(r.center(), Align2::CENTER_CENTER, label, Tokens::ui(11.0), if on { Color32::WHITE } else { t.text }); + p.text(r.center(), Align2::CENTER_CENTER, shown, Tokens::ui(11.0), if on { Color32::WHITE } else { t.text }); app.auto.add(&format!("timeline.graph.{id}"), r, tip); bar.x += w + 4.0; resp.clicked() @@ -522,9 +536,9 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte } } // The key itself. - let col = if k.sel { t.keyframe_selected } else { Color32::from_gray(0xe0) }; + let (col, edge) = key_colors(&t, c.color, k); pc.rect_filled(Rect::from_center_size(kp, vec2(7.0, 7.0)), 0.0, col); - pc.rect_stroke(Rect::from_center_size(kp, vec2(7.0, 7.0)), 0.0, Stroke::new(1.0, c.color), StrokeKind::Outside); + pc.rect_stroke(Rect::from_center_size(kp, vec2(7.0, 7.0)), 0.0, Stroke::new(1.0, edge), StrokeKind::Outside); let kr = Rect::from_center_size(kp, vec2(11.0, 11.0)); let kresp = ui.interact(kr, egui::Id::unique(("gkey", uid, d, k.idx)), Sense::click_and_drag()); app.auto.add(&format!("timeline.graph.key.{uid}.{d}.{}", k.idx), kr, &c.prop.name); diff --git a/crates/ui-egui/src/panels/layer_panel.rs b/crates/ui-egui/src/panels/layer_panel.rs index 5fb4d703c..8e4f3502f 100644 --- a/crates/ui-egui/src/panels/layer_panel.rs +++ b/crates/ui-egui/src/panels/layer_panel.rs @@ -104,7 +104,16 @@ fn render_tex( match cached { Some(c) if c.key == key => Some(c), _ => { - let ectx = EvalCtx { project: &app.session.project, comp_id: cid, comp, time, expr: app.session.expr.as_deref(), footage: None }; + let ectx = EvalCtx { + project: &app.session.project, + comp_id: cid, + comp, + time, + expr: app.session.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let mut r = Renderer::new(&app.session.project, &*app.session.footage, RenderOpts { scale, ..Default::default() }); r.expr = app.session.expr.as_deref(); r.cache = Some(&app.session.layer_cache); @@ -310,13 +319,17 @@ 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). + // Navigation (as the Composition panel): the wheel / pinch zooms around the pointer and a + // two-finger trackpad scroll pans 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()); + let scroll = super::viewer_scroll(&ctx); + if (scroll.zoom - 1.0).abs() > 3e-4 { + zoom_at(app, area, [lw, lh], hp, to_layer(hp), zoom as f32 * scroll.zoom); + } else if scroll.pan != egui::Vec2::ZERO { + app.ui.layer_zoom.get_or_insert(zoom as f32); + app.ui.layer_pan = [pan.x + scroll.pan.x, pan.y + scroll.pan.y]; } } // A middle-drag pans with any tool; painting and Roto Brush strokes don't run meanwhile. diff --git a/crates/ui-egui/src/panels/layer_styles_dialog.rs b/crates/ui-egui/src/panels/layer_styles_dialog.rs index 14507b868..713dfe6d4 100644 --- a/crates/ui-egui/src/panels/layer_styles_dialog.rs +++ b/crates/ui-egui/src/panels/layer_styles_dialog.rs @@ -24,10 +24,11 @@ pub struct LayerStyleState { checkpoint: Option, } +/// The project and undo history as a dialog found them, for a Cancel that rolls its live edits back. #[derive(Clone)] -struct Checkpoint { - project: std::sync::Arc, - history: effectcraft_engine::history::History, +pub(crate) struct Checkpoint { + pub(crate) project: std::sync::Arc, + pub(crate) history: effectcraft_engine::history::History, } impl std::fmt::Debug for Checkpoint { @@ -36,7 +37,7 @@ impl std::fmt::Debug for Checkpoint { } } -fn checkpoint(app: &mut EffectcraftApp) -> Checkpoint { +pub(crate) fn checkpoint(app: &mut EffectcraftApp) -> Checkpoint { app.session.history.merge_key = None; Checkpoint { project: app.session.project.clone(), history: app.session.history.clone() } } @@ -258,25 +259,32 @@ pub fn finish(app: &mut EffectcraftApp, ok: bool) { if let Some(checkpoint) = app.dialog_state.layer_style.checkpoint.take() && !ok { - let changed = !std::sync::Arc::ptr_eq(&app.session.project, &checkpoint.project); - if changed { - // Snapshot only when Cancel has something to restore: opening and cancelling without - // edits must not copy the whole TextDoc. - let edited = active_editor_snapshot(&app.session); - app.session.project = checkpoint.project; - app.session.history = checkpoint.history; - app.session.sanitize_after_replacement(edited.as_ref()); - app.session.bump(); - } else { - // No edit was committed, so nothing can have become invalid. In particular, do not - // call sanitize_state here: its text-edit cleanup clones the whole TextDoc. - app.session.history = checkpoint.history; - } + restore(app, checkpoint); } app.session.history.merge_key = None; app.dialog = None; } +/// Put the project and undo history back as `checkpoint` has them (nothing happens when nothing +/// changed since). +pub(crate) fn restore(app: &mut EffectcraftApp, checkpoint: Checkpoint) { + let changed = !std::sync::Arc::ptr_eq(&app.session.project, &checkpoint.project); + if changed { + // Snapshot only when Cancel has something to restore: opening and cancelling without + // edits must not copy the whole TextDoc. + let edited = active_editor_snapshot(&app.session); + app.session.project = checkpoint.project; + app.session.history = checkpoint.history; + app.session.sanitize_after_replacement(edited.as_ref()); + app.session.bump(); + } else { + // No edit was committed, so nothing can have become invalid. In particular, do not + // call sanitize_state here: its text-edit cleanup clones the whole TextDoc. + app.session.history = checkpoint.history; + } + app.session.history.merge_key = None; +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui-egui/src/panels/learn.rs b/crates/ui-egui/src/panels/learn.rs index a3c4a85f6..fcb98cce1 100644 --- a/crates/ui-egui/src/panels/learn.rs +++ b/crates/ui-egui/src/panels/learn.rs @@ -18,7 +18,7 @@ pub fn home_tab(app: &mut EffectcraftApp, ui: &mut egui::Ui, area: Rect) { p.text( pos2(x, y + 26.0), Align2::LEFT_CENTER, - "Hands-on tutorials that run inside EffectCraft. Do each step yourself, or press Show me.", + crate::i18n::tr("Hands-on tutorials that run inside EffectCraft. Do each step yourself, or press Show me."), Tokens::ui(11.5), t.text_faint, ); @@ -38,7 +38,7 @@ pub fn home_tab(app: &mut EffectcraftApp, ui: &mut egui::Ui, area: Rect) { p.circle_filled(badge.center(), 22.0, t.accent.gamma_multiply(0.25)); p.circle_stroke(badge.center(), 22.0, Stroke::new(1.5, t.accent)); p.text(badge.center() - vec2(0.0, 5.0), Align2::CENTER_CENTER, tut.steps.len().to_string(), Tokens::semibold(15.0), Color32::WHITE); - p.text(badge.center() + vec2(0.0, 10.0), Align2::CENTER_CENTER, "steps", Tokens::ui(9.0), t.text_dim); + p.text(badge.center() + vec2(0.0, 10.0), Align2::CENTER_CENTER, crate::i18n::tr("steps"), Tokens::ui(9.0), t.text_dim); let tx = badge.max.x + 16.0; p.text(pos2(tx, r.min.y + 22.0), Align2::LEFT_CENTER, &tut.title, Tokens::semibold(14.0), Color32::WHITE); p.text(pos2(tx, r.min.y + 40.0), Align2::LEFT_CENTER, format!("{} min", tut.minutes), Tokens::ui(11.0), t.text_faint); diff --git a/crates/ui-egui/src/panels/live_preview.rs b/crates/ui-egui/src/panels/live_preview.rs new file mode 100644 index 000000000..83d0d2836 --- /dev/null +++ b/crates/ui-egui/src/panels/live_preview.rs @@ -0,0 +1,129 @@ +//! Preview for the settings dialogs that edit something that already exists (Solid Settings on a +//! solid layer, Camera Settings, Light Settings, Composition Settings): After Effects' Preview +//! checkbox. Each change of the draft is applied to the project at once, so the canvas shows it +//! while it is made; OK keeps it and Cancel and Escape take it back. +//! +//! Every step goes back to the checkpoint taken when the dialog opened (the project and its undo +//! history) and then runs the dialog's command once with the draft's parameters. So the project is +//! always "as it opened, plus one command": OK leaves exactly one undo step, whatever was tried +//! meanwhile, and Cancel only has to put the checkpoint back. With Preview off nothing is applied +//! until OK, and switching it off takes back what was applied. +//! +//! While a preview is active the dialog opens at the lower right of the window, without the dark +//! backdrop, and the window behind it is not greyed out, so the canvas stays in view. + +use egui::vec2; +use serde_json::Value; + +use crate::panels::dialogs::Placement; +use crate::panels::layer_styles_dialog::{Checkpoint, checkpoint}; +use crate::panels::preview_merge::{self, Applied}; +use crate::{Dialog, EffectcraftApp}; + +/// Where a previewing dialog opens: the lower right, undimmed. +pub const PLACE: Placement = Placement { start: vec2(0.75, 0.8), dim: false }; + +/// The state of the one dialog being previewed (only one dialog is open at a time). +#[derive(Clone, Debug, Default)] +pub struct LivePreview { + /// The dialog this belongs to; a preview left over from another one is ignored. + dialog: Option

, + /// The Preview checkbox. + pub on: bool, + checkpoint: Option, + /// The draft's parameters when the dialog opened: nothing is applied while it equals them. + baseline: Option, + /// The command and parameters the preview last applied. + applied: Option<(&'static str, Value)>, + /// The project as that command left it, to tell edits made since from the preview's. + applied_at: Option, +} + +impl LivePreview { + fn active_for(&self, dialog: Option) -> bool { + self.checkpoint.is_some() && self.dialog.is_some() && self.dialog == dialog + } +} + +/// Start previewing for `dialog`, which has just opened on an existing item (Preview on). +pub fn begin(app: &mut EffectcraftApp, dialog: Dialog) { + app.dialog_state.live = LivePreview { dialog: Some(dialog), on: true, checkpoint: Some(checkpoint(app)), ..Default::default() }; +} + +/// The dialog that is open is previewing. +pub fn active(app: &EffectcraftApp) -> bool { + app.dialog_state.live.active_for(app.dialog) +} + +/// Where the open dialog goes: the lower right, undimmed, while it previews; else the middle. +pub fn place(app: &EffectcraftApp) -> Placement { + if active(app) { PLACE } else { Placement::default() } +} + +/// Keep the project in step with the draft: `command` with `params` is what OK would run. The +/// first call (the draft as it opened) only notes it. +pub fn sync(app: &mut EffectcraftApp, command: &'static str, params: Value) { + if !active(app) { + return; + } + let live = &app.dialog_state.live; + if live.baseline.is_none() { + app.dialog_state.live.baseline = Some(params); + return; + } + let want = live.on && live.baseline.as_ref() != Some(¶ms); + let is_applied = live.applied.is_some(); + if want { + if live.applied.as_ref().is_some_and(|(c, p)| *c == command && *p == params) { + return; + } + if is_applied { + put_back(app); + } + match app.session.execute(command, params.clone()) { + Ok(_) => { + app.dialog_state.live.applied = Some((command, params)); + app.dialog_state.live.applied_at = Some(preview_merge::applied(app)); + } + Err(e) => app.ui.status = e.to_string(), + } + } else if is_applied { + put_back(app); + } +} + +/// The project as the dialog found it. +fn put_back(app: &mut EffectcraftApp) { + if let Some(cp) = app.dialog_state.live.checkpoint.clone() { + let at = app.dialog_state.live.applied_at.take(); + // Only what the preview did is taken back: an edit made while the dialog was open stays. + preview_merge::rollback(app, cp, at.as_ref()); + } + app.dialog_state.live.applied = None; + app.dialog_state.live.applied_at = None; +} + +/// OK: the preview's result is the edit. The preview is first brought up to `command` with +/// `params`, the draft as OK found it (an edit made in the same frame as OK is not in the last +/// preview). Returns whether the project then already has the edit (the caller must not run the +/// command again); otherwise the caller runs it, as without a preview. +pub fn keep(app: &mut EffectcraftApp, command: &'static str, params: Value) -> bool { + sync(app, command, params); + let kept = active(app) && app.dialog_state.live.applied.is_some(); + app.dialog_state.live = LivePreview::default(); + kept +} + +/// Cancel and Escape: take back whatever the preview applied. +pub fn cancel(app: &mut EffectcraftApp) { + if active(app) { + put_back(app); + } + app.dialog_state.live = LivePreview::default(); +} + +/// The Preview checkbox, when the open dialog previews; `id` is its automation id. +pub fn checkbox(ui: &mut egui::Ui, auto: &mut crate::automation::Registry, on: &mut bool, id: &str) { + let r = ui.checkbox(on, crate::i18n::tr("Preview")); + auto.add(id, r.rect, "Preview"); +} diff --git a/crates/ui-egui/src/panels/misc.rs b/crates/ui-egui/src/panels/misc.rs index dfa9a42e0..2fec0e4c2 100644 --- a/crates/ui-egui/src/panels/misc.rs +++ b/crates/ui-egui/src/panels/misc.rs @@ -11,7 +11,8 @@ use crate::{EffectcraftApp, widgets}; pub fn placeholder(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect, p: PanelKind) { let t = app.tokens; - ui.painter().text(rect.center(), Align2::CENTER_CENTER, tr_args("{} — coming soon", &[&(p.title())]), Tokens::ui(12.0), t.text_faint); + let name = crate::i18n::label(app, "window.panel", p.title()); + ui.painter().text(rect.center(), Align2::CENTER_CENTER, tr_args("{} — coming soon", &[&name]), Tokens::ui(12.0), t.text_faint); } /// Preview panel: transport controls, the shortcut whose options are shown, and that @@ -42,7 +43,10 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { for (icon, cmd, tip) in buttons { let r = Rect::from_min_size(pos2(x, y), vec2(bw, 28.0)); let icon = if cmd == "playback.toggle" && app.playback.playing { Icon::Pause } else { icon }; - if widgets::icon_button(ui, r, icon, cmd == "playback.toggle" && app.playback.playing, &t, egui::Id::unique(("pv", cmd))).on_hover_text(tip).clicked() { + if widgets::icon_button(ui, r, icon, cmd == "playback.toggle" && app.playback.playing, &t, egui::Id::unique(("pv", cmd))) + .on_hover_text(tr(tip)) + .clicked() + { // The play button plays with the options of the shortcut shown below. let params = if cmd == "playback.toggle" { json!({"shortcut": current.id()}) } else { json!({}) }; let _ = crate::menus::invoke(app, &ctx, cmd, params); @@ -61,7 +65,7 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let mut yy = y + 46.0; // Shortcut. - label(yy, "Shortcut"); + label(yy, tr("Shortcut")); let names: Vec = PreviewShortcut::ALL.iter().map(|s| s.label().to_string()).collect(); if let Some(i) = dropdown_row( app, @@ -91,14 +95,14 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let row = if i < 4 { i / 2 } else { i - 2 }; let cx = x0 + col as f32 * 110.0 - 2.0; let cy = yy + row as f32 * 22.0; - if check_row(app, ui, &p, pos2(cx, cy), key, text, on) { + if check_row(app, ui, &p, pos2(cx, cy), key, tr(text), on) { changes.push(json!({"shortcut": current.id(), key: !on})); } } yy += 4.0 * 22.0 + 6.0; // Range (and the pre/post-roll of Play Around Current Time). - label(yy, "Range"); + label(yy, tr("Range")); let ranges: Vec = PreviewRange::ALL.iter().map(|r| r.label().to_string()).collect(); let range_ids = ["workArea", "workAreaExtended", "entireDuration", "aroundCurrentTime"]; if let Some(i) = dropdown_row( @@ -116,7 +120,7 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if o.range == PreviewRange::AroundCurrentTime { for (k, (key, text, v)) in [("preRoll", "Pre-roll", o.pre_roll), ("postRoll", "Post-roll", o.post_roll)].into_iter().enumerate() { let lx = xv + k as f32 * 100.0; - p.text(pos2(lx, yy), Align2::LEFT_CENTER, text, Tokens::ui(11.5), t.text_dim); + p.text(pos2(lx, yy), Align2::LEFT_CENTER, tr(text), Tokens::ui(11.5), t.text_dim); let (r, nv, _) = widgets::hot_number_at( ui, pos2(lx + 52.0, yy - 9.0), @@ -134,7 +138,7 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } // Play From. - label(yy, "Play From"); + label(yy, tr("Play From")); let froms: Vec = PlayFrom::ALL.iter().map(|f| f.label().to_string()).collect(); if let Some(i) = dropdown_row( app, @@ -151,7 +155,7 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // Frame Rate, Skip, Resolution. let comp_fps = app.session.active_comp().map(|c| c.frame_rate.as_f64()); - label(yy, "Frame Rate"); + label(yy, tr("Frame Rate")); const RATES: [f64; 12] = [8.0, 10.0, 12.0, 12.5, 15.0, 23.976, 24.0, 25.0, 29.97, 30.0, 50.0, 60.0]; let auto = format!("({:.2}) Auto", comp_fps.unwrap_or(0.0)); let mut rates = vec![auto.clone()]; @@ -180,7 +184,7 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { changes.push(json!({"shortcut": current.id(), "skip": nv.round() as u32})); } yy += 24.0; - label(yy, "Resolution"); + label(yy, tr("Resolution")); let ress: Vec = PreviewResolution::ALL.iter().map(|r| r.label().to_string()).collect(); let res_ids = ["auto", "full", "half", "third", "quarter", "custom"]; if let Some(i) = dropdown_row( @@ -209,7 +213,7 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } yy += 24.0; - if check_row(app, ui, &p, pos2(x0 - 2.0, yy), "fullScreen", "Full Screen", o.full_screen) { + if check_row(app, ui, &p, pos2(x0 - 2.0, yy), "fullScreen", tr("Full Screen"), o.full_screen) { changes.push(json!({"shortcut": current.id(), "fullScreen": !o.full_screen})); } yy += 24.0; @@ -217,7 +221,7 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // What stopping does. p.text(pos2(x0, yy), Align2::LEFT_CENTER, tr_args("On ({}) stop:", &[&(current.label())]), Tokens::ui(11.5), t.text_dim); yy += 20.0; - if check_row(app, ui, &p, pos2(x0 - 2.0, yy), "playCachedFrames", "If caching, play cached frames", o.play_cached_frames) { + if check_row(app, ui, &p, pos2(x0 - 2.0, yy), "playCachedFrames", tr("If caching, play cached frames"), o.play_cached_frames) { changes.push(json!({"shortcut": current.id(), "playCachedFrames": !o.play_cached_frames})); } yy += 22.0; diff --git a/crates/ui-egui/src/panels/preview_merge.rs b/crates/ui-egui/src/panels/preview_merge.rs new file mode 100644 index 000000000..ab41e3167 --- /dev/null +++ b/crates/ui-egui/src/panels/preview_merge.rs @@ -0,0 +1,204 @@ +//! Taking a dialog's Preview back without taking other edits with it. +//! +//! A previewing dialog (Camera, Light, Solid, Composition Settings, the Gradient Editor) puts the +//! project and undo history back to a checkpoint when it is cancelled or Preview goes off. If the +//! project changed in the meantime for another reason (a camera analysis finishing, a script, the +//! control channel), swapping the checkpoint in would throw that edit away. This module undoes +//! only what the preview did: a three-way merge of the checkpoint (before the preview), the project +//! right after the preview (`Applied`) and the project now. Whatever the preview did not touch +//! stays as it is now; what only the preview touched goes back to the checkpoint; what both touched +//! keeps the other edit. + +use std::collections::BTreeSet; +use std::sync::Arc; + +use effectcraft_engine::commands::text_edit::active_editor_snapshot; +use effectcraft_engine::project::{Comp, Item, ItemId, ItemKind, Layer, LayerId, Project}; + +use crate::EffectcraftApp; +use crate::panels::layer_styles_dialog::{Checkpoint, restore}; + +/// The project right after a preview was applied, and the undo depth then. +#[derive(Clone)] +pub(crate) struct Applied { + project: Arc, + undo_len: usize, +} + +impl std::fmt::Debug for Applied { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Applied").field("undo_len", &self.undo_len).finish_non_exhaustive() + } +} + +/// Note the project as the preview that has just been applied left it. +pub(crate) fn applied(app: &EffectcraftApp) -> Applied { + Applied { project: app.session.project.clone(), undo_len: app.session.history.undo.len() } +} + +/// Put the project back as `checkpoint` has it, minus nothing but the preview: when the project is +/// still what the preview made (or nothing says what that was) the checkpoint is simply restored. +pub(crate) fn rollback(app: &mut EffectcraftApp, checkpoint: Checkpoint, applied: Option<&Applied>) { + let Some(applied) = applied.filter(|a| !Arc::ptr_eq(&a.project, &app.session.project)) else { + restore(app, checkpoint); + return; + }; + let merged = merge(&checkpoint.project, &applied.project, &app.session.project); + // One step for what was done meanwhile, from the project before the dialog: Undo takes it back. + let label = app.session.history.undo.get(applied.undo_len).map(|(label, _)| label.clone()).unwrap_or_else(|| "Edit".to_string()); + let mut history = checkpoint.history; + history.undo.push((label, checkpoint.project)); + history.redo.clear(); + history.merge_key = None; + let edited = active_editor_snapshot(&app.session); + app.session.project = Arc::new(merged); + app.session.history = history; + app.session.sanitize_after_replacement(edited.as_ref()); + app.session.bump(); +} + +/// `now` without the changes between `before` and `after`, keeping any other change. +fn merge(before: &Project, after: &Project, now: &Project) -> Project { + let mut merged = now.clone(); + let ids: BTreeSet = before.items.keys().chain(after.items.keys()).chain(now.items.keys()).copied().collect(); + for id in ids { + match item(before.items.get(&id), after.items.get(&id), now.items.get(&id)) { + Some(it) => { + merged.items.insert(id, it); + } + None => { + merged.items.remove(&id); + } + } + } + merged +} + +/// The three-way choice for one value: untouched by the preview keeps `now`, touched only by the +/// preview goes back to `before`, touched by both is `both`. +fn pick(before: Option<&T>, after: Option<&T>, now: Option<&T>, both: impl FnOnce() -> Option) -> Option { + if after == before { + now.cloned() + } else if now == after { + before.cloned() + } else { + both() + } +} + +fn item(before: Option<&Item>, after: Option<&Item>, now: Option<&Item>) -> Option { + pick(before, after, now, || { + if let (Some(b), Some(a), Some(n)) = (before, after, now) + && let (ItemKind::Comp(bc), ItemKind::Comp(ac), ItemKind::Comp(nc)) = (&b.kind, &a.kind, &n.kind) + { + let mut merged = n.clone(); + // Name, folder and the like: only a preview that changed the item itself and nothing else + // is taken back; the comp's own contents are merged below. + merged.kind = ItemKind::Comp(Arc::new(comp(bc, ac, nc))); + return Some(merged); + } + now.cloned() + }) +} + +fn bare(c: &Comp) -> Comp { + let mut b = c.clone(); + b.layers.clear(); + b +} + +fn layer_of(c: &Comp, id: LayerId) -> Option<&Layer> { + c.layers.iter().find(|l| l.id == id) +} + +fn comp(before: &Comp, after: &Comp, now: &Comp) -> Comp { + // The comp's own settings, apart from its layers. + let mut merged = pick(Some(&bare(before)), Some(&bare(after)), Some(&bare(now)), || Some(bare(now))).unwrap_or_else(|| bare(now)); + let merge_layer = |id: LayerId| pick(layer_of(before, id), layer_of(after, id), layer_of(now, id), || layer_of(now, id).cloned()); + // Layers as they stand now, in their order, then the ones the preview removed put back. + let mut layers: Vec = now.layers.iter().filter_map(|l| merge_layer(l.id)).collect(); + for (at, l) in before.layers.iter().enumerate() { + if layer_of(now, l.id).is_none() + && let Some(back) = merge_layer(l.id) + { + layers.insert(at.min(layers.len()), back); + } + } + merged.layers = layers.into(); + merged +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Dialog; + use crate::panels::live_preview; + use effectcraft_engine::Session; + use serde_json::json; + + fn app_with_light() -> (EffectcraftApp, u64) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Scene", "width": 320, "height": 180, "duration": 1})).unwrap(); + let light = s.execute("layer.newLight", json!({"kind": "Point"})).unwrap()["layer"].as_u64().unwrap(); + (EffectcraftApp::new(s), light) + } + + fn name_of(app: &EffectcraftApp, layer: u64) -> String { + app.session.active_comp().unwrap().layer(LayerId(layer)).unwrap().name.clone() + } + + fn layer_count(app: &EffectcraftApp) -> usize { + app.session.active_comp().unwrap().layers.len() + } + + /// The preview renames the light; a null is added meanwhile (what a finished background job + /// would do). Cancel takes the rename back and keeps the null, with one undo step for it. + #[test] + fn cancelling_a_preview_keeps_an_edit_made_while_it_was_open() { + let (mut app, light) = app_with_light(); + let original = name_of(&app, light); + app.dialog = Some(Dialog::LightSettings); + live_preview::begin(&mut app, Dialog::LightSettings); + let rename = |name: &str| json!({"layer": light, "name": name}); + live_preview::sync(&mut app, "layer.rename", rename(&original)); + live_preview::sync(&mut app, "layer.rename", rename("Previewed")); + assert_eq!(name_of(&app, light), "Previewed"); + let layers = layer_count(&app); + app.session.execute("layer.newNull", json!({})).unwrap(); + live_preview::cancel(&mut app); + assert_eq!(name_of(&app, light), original, "the preview is taken back"); + assert_eq!(layer_count(&app), layers + 1, "the other edit stays"); + assert!(app.session.undo()); + assert_eq!(layer_count(&app), layers, "and has its own undo step"); + assert_eq!(name_of(&app, light), original); + } + + /// With nothing else done meanwhile the checkpoint is restored as before. + #[test] + fn cancelling_an_undisturbed_preview_restores_the_checkpoint() { + let (mut app, light) = app_with_light(); + let original = name_of(&app, light); + let steps = app.session.history.undo.len(); + app.dialog = Some(Dialog::LightSettings); + live_preview::begin(&mut app, Dialog::LightSettings); + live_preview::sync(&mut app, "layer.rename", json!({"layer": light, "name": original})); + live_preview::sync(&mut app, "layer.rename", json!({"layer": light, "name": "Previewed"})); + live_preview::cancel(&mut app); + assert_eq!(name_of(&app, light), original); + assert_eq!(app.session.history.undo.len(), steps); + } + + /// An item both the preview and another edit changed keeps the other edit. + #[test] + fn a_layer_both_edits_changed_keeps_the_other_edit() { + let (mut app, light) = app_with_light(); + let original = name_of(&app, light); + app.dialog = Some(Dialog::LightSettings); + live_preview::begin(&mut app, Dialog::LightSettings); + live_preview::sync(&mut app, "layer.rename", json!({"layer": light, "name": original})); + live_preview::sync(&mut app, "layer.rename", json!({"layer": light, "name": "Previewed"})); + app.session.execute("layer.rename", json!({"layer": light, "name": "Renamed elsewhere"})).unwrap(); + live_preview::cancel(&mut app); + assert_eq!(name_of(&app, light), "Renamed elsewhere"); + } +} diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 1944f333f..6367acb0b 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -812,7 +812,7 @@ fn footer(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, foot: (Icon::NewComp, "newComp", "Create a new Composition"), ] { let r = Rect::from_min_size(pos2(x, foot.min.y + 3.0), vec2(22.0, 22.0)); - let resp = widgets::icon_button(ui, r, icon, false, &t, egui::Id::unique(("pfoot", id))).on_hover_text(tip); + let resp = widgets::icon_button(ui, r, icon, false, &t, egui::Id::unique(("pfoot", id))).on_hover_text(crate::i18n::tr(tip)); app.auto.add(&format!("project.{id}"), r, tip); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, crate::i18n::tr(tip))); if resp.clicked() { diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index c35108937..a7563a6ff 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -70,7 +70,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let project = app.session.project.clone(); let expr = app.session.expr.clone(); let now = app.session.time(); - let ectx = EvalCtx { project: &project, comp_id: cid, comp: &comp, time: now, expr: expr.as_deref(), footage: None }; + let ectx = EvalCtx { + project: &project, + comp_id: cid, + comp: &comp, + time: now, + expr: expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let x0 = rect.min.x + PAD; let w = rect.width() - 2.0 * PAD; let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("props-scroll"), rect); @@ -200,6 +209,8 @@ fn triangle(p: &egui::Painter, c: egui::Pos2, left: bool, col: Color32) { #[allow(clippy::too_many_arguments)] fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, layer: &Layer, pr: &Property, ectx: &EvalCtx, r: Rect, actions: &mut Actions) { let t = app.tokens; + // The values themselves: red while an expression drives the property (#1306). + let vt = t.for_value(pr.has_expression()); let cy = r.center().y; let uid = pr.uid; let lid = layer.id.0; @@ -276,7 +287,7 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, match &value { Value::Scalar(v) if pr.name == "Rotation" || pr.name.ends_with(" Rotation") => { let (_, deg) = super::fx_widgets::split_angle(*v); - let (rr, dr, nv) = super::fx_widgets::angle_field(ui, pos2(vx, cy - 9.0), egui::Id::unique(("props-v", uid)), *v, decimals(deg), &t); + let (rr, dr, nv) = super::fx_widgets::angle_field(ui, pos2(vx, cy - 9.0), egui::Id::unique(("props-v", uid)), *v, decimals(deg), &vt); app.auto.add(&format!("{base}.value"), dr, &pr.name); app.auto.add(&format!("{base}.revolutions"), rr, &pr.name); if let Some(nv) = nv { @@ -291,7 +302,7 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, egui::Id::unique(("props-v", uid)), *v, &widgets::HotSpec { speed: 0.5, range, decimals: decimals(*v), suffix }, - &t, + &vt, ); app.auto.add(&format!("{base}.value"), vr, &pr.name); if let Some(nv) = nv { @@ -323,7 +334,7 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, egui::Id::unique(("props-v", uid, d)), c[d], &widgets::HotSpec { speed: 1.0, range: (-1e9, 1e9), decimals: decimals(c[d]), suffix }, - &t, + &vt, ); app.auto.add(&format!("{base}.value.{d}"), vr, &pr.name); if let Some(nv) = nv { diff --git a/crates/ui-egui/src/panels/render_queue.rs b/crates/ui-egui/src/panels/render_queue.rs index 5e39a30c2..321224c64 100644 --- a/crates/ui-egui/src/panels/render_queue.rs +++ b/crates/ui-egui/src/panels/render_queue.rs @@ -451,6 +451,22 @@ fn queue_drop(app: &EffectcraftApp, ui: &egui::Ui, rect: Rect, p: &egui::Painter } } +/// Elapsed and estimated remaining seconds at UI time `now`. The job reports them as frames +/// finish; between reports they count on from the last one, as a clock does, so a slow frame +/// doesn't read as a stalled render (#2593). +fn live_times(ctx: &egui::Context, pr: &effectcraft_engine::render_queue::JobState, now: f64) -> (f64, Option) { + let report = (pr.current, pr.done, pr.item_elapsed.to_bits()); + let since = ctx.data_mut(|d| { + let seen = d.get_temp_mut_or_insert_with(egui::Id::unique("rq-progress-seen"), || (report, now)); + if seen.0 != report { + *seen = (report, now); + } + seen.1 + }); + let dt = if pr.finished { 0.0 } else { (now - since).max(0.0) }; + (pr.item_elapsed + dt, pr.remaining.map(|r| (r - dt).max(0.0))) +} + /// The Current Render strip inside `top`: title, elapsed / remaining time, Render, Stop, Notify /// and the progress bar. fn current_render_strip(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, top: Rect, rq: &RqView, actions: &mut Vec) { @@ -461,11 +477,15 @@ fn current_render_strip(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::P let cur_item = progress.as_ref().and_then(|p| p.current).and_then(|id| queue.iter().find(|i| i.id == id)); let cur_name = cur_item.and_then(|i| app.session.project.item(i.comp)).map(|i| i.name.clone()); if let Some(pr) = &progress { - let rem = pr.remaining.map(hms).unwrap_or_else(|| "–".into()); + let (elapsed, remaining) = live_times(ui.ctx(), pr, ui.input(|i| i.time)); + if rendering { + ui.ctx().request_repaint_after(std::time::Duration::from_millis(500)); + } + let rem = remaining.map(hms).unwrap_or_else(|| "–".into()); p.text( pos2(top.max.x - 12.0, top.min.y + 16.0), Align2::RIGHT_CENTER, - format!("Elapsed: {} Est. Remain: {rem}", hms(pr.item_elapsed)), + format!("Elapsed: {} Est. Remain: {rem}", hms(elapsed)), Tokens::ui(11.5), t.text_dim, ); @@ -474,7 +494,7 @@ fn current_render_strip(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::P let render_b = Rect::from_min_size(pos2(top.max.x - 12.0 - bw, top.min.y + 34.0), vec2(bw, 26.0)); let stop_b = Rect::from_min_size(pos2(render_b.min.x - 8.0 - bw, render_b.min.y), vec2(bw, 26.0)); let can_render = !rendering && queue.iter().any(RenderQueueItem::is_queued) && !available.is_empty(); - if widgets::text_button(ui, render_b, "Render", can_render, &t, egui::Id::unique("rq-render")).clicked() && can_render { + if widgets::text_button(ui, render_b, crate::i18n::tr("Render"), can_render, &t, egui::Id::unique("rq-render")).clicked() && can_render { actions.push(("renderQueue.render", json!({"wait": false}))); } app.auto.add("renderQueue.render", render_b, "Render"); @@ -487,7 +507,7 @@ fn current_render_strip(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::P p.text(pos2(nb.max.x + 5.0, nb.center().y), Align2::LEFT_CENTER, tr("Notify"), Tokens::ui(11.5), t.text_dim); app.auto.add("renderQueue.notify", nb, if notify { "Notify: on" } else { "Notify: off" }); if rendering { - if widgets::text_button(ui, stop_b, "Stop", false, &t, egui::Id::unique("rq-stop")).clicked() { + if widgets::text_button(ui, stop_b, crate::i18n::tr("Stop"), false, &t, egui::Id::unique("rq-stop")).clicked() { actions.push(("renderQueue.stop", json!({}))); } app.auto.add("renderQueue.stop", stop_b, "Stop"); @@ -903,4 +923,26 @@ mod tests { assert_eq!(super::utc_stamp(u64::MAX), "584554051223-11-09 07:00:15"); assert_eq!(super::hms(3725.4), "1:02:05"); } + + /// #2593: between progress reports the times count on from the last report, and a new + /// report starts the count again. + #[test] + fn render_times_tick_between_reports() { + let ctx = egui::Context::default(); + let mut pr = effectcraft_engine::render_queue::JobState { + current: Some(1), + done: 2, + total: 10, + item_elapsed: 20.0, + remaining: Some(80.0), + ..Default::default() + }; + assert_eq!(super::live_times(&ctx, &pr, 100.0), (20.0, Some(80.0))); + assert_eq!(super::live_times(&ctx, &pr, 103.0), (23.0, Some(77.0))); + assert_eq!(super::live_times(&ctx, &pr, 300.0).1, Some(0.0), "never below zero"); + (pr.done, pr.item_elapsed, pr.remaining) = (3, 31.0, Some(72.0)); + assert_eq!(super::live_times(&ctx, &pr, 301.0), (31.0, Some(72.0))); + pr.finished = true; + assert_eq!(super::live_times(&ctx, &pr, 400.0), (31.0, Some(72.0)), "a finished job stands still"); + } } diff --git a/crates/ui-egui/src/panels/roto_tool.rs b/crates/ui-egui/src/panels/roto_tool.rs index 0d9eefe45..ca3c56ac5 100644 --- a/crates/ui-egui/src/panels/roto_tool.rs +++ b/crates/ui-egui/src/panels/roto_tool.rs @@ -69,7 +69,16 @@ pub fn draw_overlay( let ov = match cached { Some(c) if c.key == key => Some(c), _ => { - let ectx = EvalCtx { project: &app.session.project, comp_id: cid, comp, time, expr: app.session.expr.as_deref(), footage: None }; + let ectx = EvalCtx { + project: &app.session.project, + comp_id: cid, + comp, + time, + expr: app.session.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let mut r = Renderer::new(&app.session.project, &*app.session.footage, RenderOpts { scale, ..Default::default() }); r.expr = app.session.expr.as_deref(); r.cache = Some(&app.session.layer_cache); diff --git a/crates/ui-egui/src/panels/script_console.rs b/crates/ui-egui/src/panels/script_console.rs index affd46317..7a2cecd30 100644 --- a/crates/ui-egui/src/panels/script_console.rs +++ b/crates/ui-egui/src/panels/script_console.rs @@ -102,7 +102,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { p.text( out_rect.center(), Align2::CENTER_CENTER, - "JavaScript with the After Effects scripting object model — try app.project.numItems", + crate::i18n::tr("JavaScript with the After Effects scripting object model — try app.project.numItems"), Tokens::ui(12.0), t.text_faint, ); diff --git a/crates/ui-egui/src/panels/scriptui_view.rs b/crates/ui-egui/src/panels/scriptui_view.rs index d12309b02..14460ed28 100644 --- a/crates/ui-egui/src/panels/scriptui_view.rs +++ b/crates/ui-egui/src/panels/scriptui_view.rs @@ -395,7 +395,11 @@ fn mouse_events(ui: &egui::Ui, origin: egui::Pos2, area: Rect, win: u32, root: & return; } let mut st: MouseTrack = ui.data(|d| d.get_temp(id)).unwrap_or_default(); - let (latest, events, mods, time) = ui.input(|i| (i.pointer.latest_pos(), i.events.clone(), i.modifiers, i.time)); + let (latest, events, mods, time, held) = ui.input(|i| { + let held = |b: egui::PointerButton, bit: u8| if i.pointer.button_down(b) { bit } else { 0 }; + let held = held(egui::PointerButton::Primary, 1) | held(egui::PointerButton::Secondary, 2) | held(egui::PointerButton::Middle, 4); + (i.pointer.latest_pos(), i.events.clone(), i.modifiers, i.time, held) + }); let ctx = ui.ctx(); let layer = ui.layer_id(); let target = |p: egui::Pos2| -> Option<&Widget> { @@ -414,7 +418,7 @@ fn mouse_events(ui: &egui::Ui, origin: egui::Pos2, area: Rect, win: u32, root: & w.id, json!({ "type": ty, "x": p.x - at.x, "y": p.y - at.y, "screenX": p.x, "screenY": p.y, - "button": button, "detail": detail, + "button": button, "buttons": held, "detail": detail, "altKey": mods.alt, "ctrlKey": mods.ctrl, "shiftKey": mods.shift, "metaKey": mods.mac_cmd, }), )); @@ -431,10 +435,15 @@ fn mouse_events(ui: &egui::Ui, origin: egui::Pos2, area: Rect, win: u32, root: & } st.hover = now_id; } - if let Some((w, p)) = now - && st.at != Some(p) - { - send(w, "mousemove", p, 0, 0, acts); + // While a button is held, the control it was pressed on keeps getting the moves, inside or + // outside the window, as in After Effects (#2584). + let captured = st.down.and_then(|(c, _)| root.find(c)); + if let Some(p) = latest.filter(|p| st.at != Some(*p)) { + match (captured, now) { + (Some(c), _) => send(c, "mousemove", p, 0, 0, acts), + (None, Some((w, _))) => send(w, "mousemove", p, 0, 0, acts), + (None, None) => {} + } } st.at = latest; // Presses and releases. @@ -446,7 +455,9 @@ fn mouse_events(ui: &egui::Ui, origin: egui::Pos2, area: Rect, win: u32, root: & egui::PointerButton::Secondary => 2, _ => continue, }; - let Some(w) = target(*pos) else { + // A release goes to the control the button was pressed on, wherever the pointer is. + let pressed_on = st.down.filter(|(_, db)| !*pressed && *db == b).and_then(|(c, _)| root.find(c)); + let Some(w) = pressed_on.or_else(|| target(*pos)) else { if !pressed { st.down = None; } @@ -460,7 +471,8 @@ fn mouse_events(ui: &egui::Ui, origin: egui::Pos2, area: Rect, win: u32, root: & send(w, "mousedown", *pos, b, st.clicks, acts); } else { send(w, "mouseup", *pos, b, st.clicks, acts); - if st.down == Some((w.id, b)) && b == 0 && w.enabled && w.mouse.iter().any(|m| m == "click") { + let over = target(*pos).is_some_and(|t| t.id == w.id); + if over && st.down == Some((w.id, b)) && b == 0 && w.enabled && w.mouse.iter().any(|m| m == "click") { // The click with the pointer's details, after mousedown / mouseup, in place of // the plain click the control reported. acts.retain(|a| !matches!(a, Act::Click(aw, ac) if *aw == win && *ac == w.id)); diff --git a/crates/ui-egui/src/panels/shortcut_editor.rs b/crates/ui-egui/src/panels/shortcut_editor.rs index 2721c6750..60b908069 100644 --- a/crates/ui-egui/src/panels/shortcut_editor.rs +++ b/crates/ui-egui/src/panels/shortcut_editor.rs @@ -49,6 +49,8 @@ const PANEL_COLOR: Color32 = Color32::from_rgb(0x3f, 0xa8, 0x6c); pub fn open(app: &mut EffectcraftApp) { app.dialog_state.shortcuts = EditorState::default(); + // Scripts copied into the Scripts folder since the table was built are listed too. + app.session.scripts_changed(); app.dialog = Some(Dialog::Shortcuts); } diff --git a/crates/ui-egui/src/panels/templates.rs b/crates/ui-egui/src/panels/templates.rs index ff8778a15..5a1ac35a0 100644 --- a/crates/ui-egui/src/panels/templates.rs +++ b/crates/ui-egui/src/panels/templates.rs @@ -62,14 +62,14 @@ pub fn home_tab(app: &mut EffectcraftApp, ui: &mut egui::Ui, area: Rect) { p.text( pos2(x, y + 26.0), Align2::LEFT_CENTER, - "Start from a ready-made project. Opening a template makes an untitled copy.", + crate::i18n::tr("Start from a ready-made project. Opening a template makes an untitled copy."), Tokens::ui(11.5), t.text_faint, ); // Save the open project as a user template. let can_save = app.session.project.comps().next().is_some(); let sr = Rect::from_min_size(pos2(area.max.x - 210.0, y - 4.0), vec2(210.0, 28.0)); - let save = widgets::text_button(ui, sr, "Save Current as Template…", false, &t, egui::Id::unique("home-templates-save")).clicked(); + let save = widgets::text_button(ui, sr, crate::i18n::tr("Save Current as Template…"), false, &t, egui::Id::unique("home-templates-save")).clicked(); app.auto.add("home.templates.saveCurrent", sr, "Save Current as Template…"); if save { if can_save { diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index 6a36fbbc9..2e7e62a08 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -3,7 +3,7 @@ //! otherwise they apply to the whole selected text layer. use crate::i18n::tr; -use effectcraft_engine::keyframe::{BaselineOption, Composer, Direction, FigureStyle, FigureWidth, Justify, Kerning, TextDoc}; +use effectcraft_engine::keyframe::{BaselineOption, Composer, Direction, FigureStyle, FigureWidth, Justify, Kerning, LineJoin, TextDoc}; use effectcraft_engine::render::EvalCtx; use egui::{Align2, Rect, Sense, pos2, vec2}; use serde_json::json; @@ -57,7 +57,16 @@ pub fn text_target(app: &EffectcraftApp) -> Option { .iter() .filter_map(|id| comp.layer(*id)) .find(|l| matches!(l.source, effectcraft_engine::project::LayerSource::Text))?; - let ectx = EvalCtx { project: &app.session.project, comp_id: cid, comp, time: app.session.time(), expr: app.session.expr.as_deref(), footage: None }; + let ectx = EvalCtx { + project: &app.session.project, + comp_id: cid, + comp, + time: app.session.time(), + expr: app.session.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; effectcraft_engine::render::text::source_text(&ectx, layer).map(|d| TextTarget { layer: layer.id.0, doc: d, range: None }) } @@ -182,6 +191,20 @@ fn stroke_order_field(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: Grid, doc: } } +/// Line Join of the stroke: Miter / Round / Bevel (grid row 5, under the stroke width). +fn line_join_field(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, g: Grid, doc: &TextDoc, actions: &mut Vec) { + let Grid { x0, y, w, enabled } = g; + let bw = (w - 2.0 * 4.0) / 3.0; + for (i, (key, label, join)) in + [("miter", "Miter", LineJoin::Miter), ("round", "Round", LineJoin::Round), ("bevel", "Bevel", LineJoin::Bevel)].into_iter().enumerate() + { + let r = Rect::from_min_size(pos2(x0 + i as f32 * (bw + 4.0), y + 5.0 * 28.0), vec2(bw, 22.0)); + if toggle(app, ui, p, r, &format!("character.lineJoin.{key}"), (label, doc.line_join == join, false)) && enabled { + actions.push(json!({"lineJoin": key})); + } + } +} + /// The Character panel for the text being edited (or the selected text layer): font family and /// style, size, leading, tracking, stroke, scale, baseline shift and other numeric fields, kerning, /// fill/stroke colours with eyedroppers, style toggles, OpenType features and variable-font axes. @@ -224,7 +247,8 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { numeric_fields(app, ui, &p, grid, &doc, &mut actions); kerning_field(app, ui, &p, grid, &doc, &mut actions); stroke_order_field(app, ui, grid, &doc, &mut actions); - y += 5.0 * 28.0 + 8.0; + line_join_field(app, ui, &p, grid, &doc, &mut actions); + y += 6.0 * 28.0 + 8.0; // Fill / stroke swatches with their enable boxes. for (i, (key, label, on, c)) in [("fill", "Fill", doc.apply_fill, doc.fill), ("stroke", "Stroke", doc.apply_stroke, doc.stroke)].into_iter().enumerate() { let fx = x0 + i as f32 * (w / 2.0); @@ -630,6 +654,17 @@ struct FontSearch { } /// Shared font menu for Character and Properties: search, recent fonts and previews. +/// Finish a font rescan started from the font menu and report how many faces it added. +/// While the scan is still walking font folders, ask for another frame so the menu updates. +pub(crate) fn poll_font_rescan(app: &mut EffectcraftApp, ctx: &egui::Context) { + if effectcraft_engine::text::fonts::rescan_busy() { + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + } + if let Some(n) = effectcraft_engine::text::fonts::poll_rescan() { + app.ui.status = format!("{} +{n}", tr("Rescan Fonts")); + } +} + pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui::Id, rect: Rect, current: &str, auto: &str, enabled: bool) -> Option { let state_id = id.with("search-state"); app.auto.add(auto, rect, "Font family"); @@ -700,6 +735,15 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui: let boxed = widgets::checkbox(ui, box_rect, only_variable, &t, id.with("variable-check")); ui.painter().text(label_rect.left_center(), Align2::LEFT_CENTER, tr("Variable fonts only"), Tokens::ui(11.5), t.text); app_ref.auto.add(&format!("{auto}.variable"), row.rect, "Variable fonts only"); + // #1089: fonts installed after launch appear without restarting. The button is its own + // row: the popup is only 180 px wide, so a control pinned to the filter row covers + // "Variable fonts only". The scan itself runs off the UI thread (`poll_font_rescan`). + let rescan_h = 22.0; + let (resc, _) = ui.allocate_exact_size(vec2(ui.available_width(), rescan_h), egui::Sense::hover()); + if widgets::text_button(ui, resc, tr("Rescan Fonts"), false, &t, id.with("rescan")).clicked() && effectcraft_engine::text::fonts::start_rescan() { + app_ref.ui.status = format!("{}…", tr("Rescan Fonts")); + } + app_ref.auto.add(&format!("{auto}.rescan"), resc, "Rescan Fonts"); let toggled = row.clicked() || boxed.clicked(); if toggled { only_variable = !only_variable; diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index af0cf0e89..9f308e4be 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -158,11 +158,13 @@ fn with_expr_rows(rows: Vec, comp: &Comp, tl: &crate::state::TimelineState, } /// A pick-whip drag in progress: (kind, layer, prop uid, start point). The kinds are the -/// layer's parent pick whip, an expression's and a property's (Parent & Link column). +/// layer's parent pick whip, an expression's, a property's (Parent & Link column) and the layer's +/// track matte pick whip. type PickWhip = (u8, u64, u64, Pos2); const WHIP_PARENT: u8 = 0; const WHIP_EXPR: u8 = 1; const WHIP_PROP: u8 = 2; +const WHIP_MATTE: u8 = 3; /// The `egui::Id` under which a pick-whip drag in progress ([`PickWhip`]) is kept during the /// drag. @@ -170,6 +172,11 @@ fn pick_whip_id() -> egui::Id { egui::Id::unique("tl-pickwhip") } +/// The layers a layer pick whip (parent, track matte) links, taken as its drag starts. +fn whip_layers_id() -> egui::Id { + egui::Id::unique("tl-pickwhip-layers") +} + /// The automation id, label and hover hint of a pick whip. struct WhipText<'a> { auto: &'a str, @@ -193,7 +200,15 @@ fn pick_whip( app.auto.add(auto, rect, label); if resp.drag_started() { let state: PickWhip = (kind, layer, prop, rect.center()); - ui.ctx().data_mut(|d| d.insert_temp(pick_whip_id(), state)); + let selected = &app.session.state.selected_layers; + let layers = match (kind, app.session.active_comp()) { + (WHIP_MATTE, Some(comp)) => video_targets(comp, LayerId(layer), selected), + _ => edited_layers(LayerId(layer), selected), + }; + ui.ctx().data_mut(|d| { + d.insert_temp(pick_whip_id(), state); + d.insert_temp(whip_layers_id(), layers); + }); } resp } @@ -1309,6 +1324,20 @@ fn layer_drag_id() -> egui::Id { egui::Id::unique("tl-layer-drag") } +/// An effect, mask, shape item or text animator being dragged to a new place among its +/// siblings (#2210). +#[derive(Clone)] +struct GroupDrag { + comp: ItemId, + owner: egui::Id, + layer: LayerId, + uid: u64, +} + +fn group_drag_id() -> egui::Id { + egui::Id::unique("tl-group-drag") +} + /// A keyframe drag in progress: where the pointer started, the grabbed key's comp time /// (seconds), how many frames the selected keys have moved so far and, for a Quick Offset /// (Ctrl/Cmd+Alt), how many layers it staggers. @@ -1400,8 +1429,9 @@ fn switch_gesture(ui: &egui::Ui, resp: &egui::Response, row: Rect, switch: &'sta (a.min(b) < row.max.y && a.max(b) >= row.min.y).then_some((d.value, d.merge)) } -/// The layers an edit made on `layer`'s row applies to (a switch, its label, its blending mode): -/// the selection when the row is selected, else that layer alone, as in After Effects. +/// The layers an edit made on `layer`'s row applies to (a switch, its label, its blending mode, +/// its parent or track matte, moving its bar): the selection when the row is selected, else that +/// layer alone, as in After Effects (#1260). fn edited_layers(layer: LayerId, selected: &[LayerId]) -> Vec { if selected.contains(&layer) { selected.iter().map(|l| l.0).collect() } else { vec![layer.0] } } @@ -1427,9 +1457,10 @@ fn switch_targets(comp: &Comp, project: &Project, selected: &[LayerId], layer: u edited_layers(LayerId(layer), selected).into_iter().filter(|l| comp.layer(LayerId(*l)).is_some_and(|l| switch_applies(l, name, project))).collect() } -/// The layers a Modes column edit on `layer`'s row (its blending mode, Preserve Transparency) -/// acts on: [`edited_layers`] that have video. -fn mode_targets(comp: &Comp, layer: LayerId, selected: &[LayerId]) -> Vec { +/// The layers a Modes column edit (blending mode, Preserve Transparency) or a Track Matte edit +/// on `layer`'s row acts on: [`edited_layers`] that have video. Selected nulls, cameras and +/// lights (no Modes or Track Matte on their rows) are left out. +fn video_targets(comp: &Comp, layer: LayerId, selected: &[LayerId]) -> Vec { edited_layers(layer, selected).into_iter().filter(|l| comp.layer(LayerId(*l)).is_some_and(|l| l.source.is_av())).collect() } @@ -1497,15 +1528,15 @@ fn shift_range(ctx: &egui::Context, cid: ItemId, comp: &Comp, rows: &[Row], sele let anchor = ctx.data(|d| d.get_temp::(anchor_id(cid))).or_else(|| selected.first().copied())?; let a = comp.layers.iter().position(|l| l.id == anchor)?; let b = comp.layers.iter().position(|l| l.id == layer)?; - let span = comp.layers.get(a.min(b)..=a.max(b))?; - Some(span.iter().filter(|l| !l.switches.locked && rows.iter().any(|r| r.layer == l.id)).map(|l| l.id.0).collect()) + let span = comp.layers.iter().skip(a.min(b)).take(a.max(b) - a.min(b) + 1); + Some(span.filter(|l| !l.switches.locked && rows.iter().any(|r| r.layer == l.id)).map(|l| l.id.0).collect()) } /// Set on the frame the reorder drag is released. /// Where layers dropped at height `py` land among the visible layer rows: above the first row /// whose middle is below it (its index in `layer_rows`), else below the last row. Returns that /// and the y of the line that shows it (`top` without rows). -fn insertion(layer_rows: &[(Rect, LayerId)], py: f32, top: f32) -> (Option, f32) { +fn insertion(layer_rows: &[(Rect, T)], py: f32, top: f32) -> (Option, f32) { let at = layer_rows.iter().position(|(r, _)| py < r.center().y); let y = match at { Some(i) => layer_rows.get(i).map_or(top, |(r, _)| r.min.y), @@ -1540,7 +1571,7 @@ fn replaceable_item(it: &effectcraft_engine::project::Item) -> bool { /// The Alt/Option-drag target: the layer under the pointer can take this item as its source, and /// the item wouldn't put the composition inside itself. -fn replace_source_target(app: &EffectcraftApp, cid: ItemId, item: ItemId, lid: LayerId, comp: &Comp) -> bool { +pub(crate) fn replace_source_target(app: &EffectcraftApp, cid: ItemId, item: ItemId, lid: LayerId, comp: &Comp) -> bool { app.session.project.item(item).is_some_and(replaceable_item) && !app.session.project.comp_contains(item, cid) && comp @@ -1741,7 +1772,7 @@ fn layout(app: &mut EffectcraftApp, ui: &egui::Ui, rect: Rect, cid: ItemId, comp fn empty_timeline(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; let ctx = ui.ctx().clone(); - ui.painter().text(rect.center(), Align2::CENTER_CENTER, "(no composition)", Tokens::ui(12.0), t.text_faint); + ui.painter().text(rect.center(), Align2::CENTER_CENTER, crate::i18n::tr("(no composition)"), Tokens::ui(12.0), t.text_faint); // Dropping footage into an empty Timeline should create its first composition, // just like dropping onto the Project panel's New Composition button (#649). if let Some(crate::panels::DragPayload::Item(dragged)) = egui::DragAndDrop::payload::(&ctx).as_deref() @@ -1848,7 +1879,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { pick_whip_edge_scroll(app, g); let snap_project = app.session.project.clone(); let snap_expr = app.session.expr.clone(); - let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time: g.time, expr: snap_expr.as_deref(), footage: None }; + let ectx = EvalCtx { + project: &snap_project, + comp_id: cid, + comp: &comp, + time: g.time, + expr: snap_expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let mut rw = begin_rows(app, ui, tl); let g = &mut rw; draw_rows(app, ui, g, &ectx); @@ -1859,6 +1899,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { alt_scale_and_graph(app, ui, g, &ectx); graph_empty_area(ui, g); layer_reorder_drag(ui, g); + group_reorder_drag(ui, g); quick_offset_follow(g); key_drag_follow(app, g); key_box_select(app, ui, g); @@ -2172,7 +2213,7 @@ fn header_search_and_switches(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &T let comp = g.comp.clone(); let sr = Rect::from_min_size(pos2(tc_rect.max.x + 14.0, top + 12.0), vec2((left_w - 420.0).clamp(120.0, 220.0), 22.0)); let mut search = app.ui.timeline.search.clone(); - widgets::search_field(ui, sr, &mut search, "Search", &t); + widgets::search_field(ui, sr, &mut search, crate::i18n::tr("Search"), &t); app.ui.timeline.search = search; app.auto.add("timeline.search", sr, "Search"); let mut bx = rect.min.x + left_w - 10.0; @@ -3013,7 +3054,7 @@ fn layer_mode_cell(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: let opts: Vec = BlendMode::ALL.iter().flat_map(|m| if m.ends_group() { vec![m.label().to_string(), "-".to_string()] } else { vec![m.label().to_string()] }).collect(); if let Some(i) = widgets::popup_menu(ui, pop, mr.left_bottom(), &opts, opts.iter().position(|o| o == layer.blend_mode.label())) { - actions.push(("layer.setBlendMode".into(), json!({"layers": mode_targets(&comp, layer.id, selected), "mode": opts[i]}))); + actions.push(("layer.setBlendMode".into(), json!({"layers": video_targets(&comp, layer.id, selected), "mode": opts[i]}))); } let tr = Rect::from_center_size(pos2(cw.mode + 76.0 + 6.0, cy), vec2(14.0, 14.0)); app.auto.add(&format!("timeline.layer.{}.preserveTransparency", layer.id.0), tr, "Preserve Transparency"); @@ -3023,7 +3064,7 @@ fn layer_mode_cell(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: actions.push(( "layer.setSwitch".into(), json!({ - "layers": mode_targets(&comp, layer.id, selected), + "layers": video_targets(&comp, layer.id, selected), "switch": "preserveTransparency", "value": !layer.preserve_transparency }), @@ -3037,10 +3078,25 @@ fn layer_matte_cell(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: let comp = g.tl.comp.clone(); let cw = &g.tl.cw; let actions = &mut g.tl.actions; + let lp = g.lp.clone(); + let selected = &g.selected; let layer = rc.layer; let cy = rc.cy; - // Track matte: layer picker + kind. - let mr = Rect::from_min_size(pos2(cw.trkmat + 2.0, cy - 9.0), vec2(104.0, 18.0)); + // Track matte: a pick whip, then the layer picker with the mode. Like the parent, they set + // the matte of every selected layer when the row is selected (#1260). + pick_whip( + app, + ui, + &lp, + Rect::from_center_size(pos2(cw.trkmat + 9.0, cy), vec2(15.0, 15.0)), + (WHIP_MATTE, layer.id.0, 0), + WhipText { + auto: &format!("timeline.layer.{}.trackMattePickWhip", layer.id.0), + label: "Track Matte pick whip", + hint: crate::i18n::tr("Track Matte pick whip: drag onto a layer"), + }, + ); + let mr = Rect::from_min_size(pos2(cw.trkmat + 20.0, cy - 9.0), vec2(88.0, 18.0)); let label = match layer.track_matte { Some(m) => comp.layer(m.layer).map(|l| format!("{} {}", comp.index_of(l.id).unwrap_or(0), l.name)).unwrap_or("None".into()), None => "No Track Matte".into(), @@ -3070,7 +3126,7 @@ fn layer_matte_cell(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: 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)) { + if let Some(a) = opts.get(i).and_then(|o| track_matte_pick(&video_targets(&comp, layer.id, selected), &ids, i, o)) { actions.push(a); } } @@ -3087,6 +3143,7 @@ fn layer_parent_cell(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc let cw = &g.tl.cw; let actions = &mut g.tl.actions; let lp = g.lp.clone(); + let selected = &g.selected; let layer = rc.layer; let cy = rc.cy; pick_whip( @@ -3118,8 +3175,10 @@ fn layer_parent_cell(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc } } if let Some(i) = widgets::popup_menu(ui, pop, pr_rect.left_bottom(), &popts, None) { - let par = if i == 0 { serde_json::Value::Null } else { json!(cands[i - 2].id.0) }; - actions.push(("layer.setParent".into(), json!({"layers": [layer.id.0], "parent": par}))); + let par = cands.get(i.wrapping_sub(2)).map(|l| l.id.0); + // (The new parent itself, if selected, stays as it is.) + let layers: Vec = edited_layers(layer.id, selected).into_iter().filter(|l| Some(*l) != par).collect(); + actions.push(("layer.setParent".into(), json!({"layers": layers, "parent": par}))); } } @@ -3287,7 +3346,6 @@ fn layer_bar_drag(g: &mut Rw, rc: &RowCtx<'_>, bars: &BarResps, quick: bool) { let selected = &g.selected; let ui_actions = &mut g.ui_actions; let layer = rc.layer; - let is_sel = rc.is_sel; let drag_key = format!("bar-{}", layer.id.0); for (resp, kind) in [(&body, "move"), (&lin, "in"), (&lout, "out")] { if kind == "move" && quick { @@ -3303,7 +3361,7 @@ fn layer_bar_drag(g: &mut Rw, rc: &RowCtx<'_>, bars: &BarResps, quick: bool) { 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 targets = json!(edited_layers(layer.id, selected)); let params = match kind { "move" => json!({"layers": targets, "delta": d, "merge": drag_key}), "in" => json!({"layers": targets, "inDelta": d, "merge": drag_key}), @@ -3516,6 +3574,7 @@ fn group_mask_mode(ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) { /// Group row click and double-click. fn group_row_click(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) { + let cid = g.tl.cid; let cw = &g.tl.cw; let actions = &mut g.tl.actions; let ui_actions = &mut g.ui_actions; @@ -3524,10 +3583,23 @@ fn group_row_click(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: let indent = rc.indent; let RowKind::Group { uid, name, fx, .. } = &rc.row.kind else { return }; let gr_rect = Rect::from_min_max(pos2(indent + 8.0, r.min.y), pos2(cw.switches, r.max.y)); - let gr = ui.interact(gr_rect, egui::Id::unique(("grow", uid)), Sense::click()); + // Effects, masks, shape items and animators drag to a new place among their siblings. + let movable = !layer.switches.locked && layer.props.find_group(*uid).is_some_and(PropGroup::is_instance); + let gr = ui.interact(gr_rect, egui::Id::unique(("grow", uid)), if movable { Sense::click_and_drag() } else { Sense::click() }); + if movable && gr.drag_started_by(egui::PointerButton::Primary) { + ui.ctx().data_mut(|d| d.insert_temp(group_drag_id(), GroupDrag { comp: cid, owner: gr.id, layer: layer.id, uid: *uid })); + } app.auto.add(&format!("timeline.group.{uid}.name"), gr_rect, name); if gr.clicked() && uid & (WAVE_BIT | GEOM_BIT) == 0 { - actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "selectKeys": false}))); + // Shift or Cmd/Ctrl adds the group to the selection, or takes a selected one out, as + // property rows and Effect Controls headers do (#2552: several effects and styles for + // one animation preset). + let add = ui.input(|i| i.modifiers.shift || i.modifiers.command); + if add && app.session.state.selected_props.contains(&(layer.id, *uid)) { + actions.push(("prop.deselect".into(), json!({"layer": layer.id.0, "prop": uid}))); + } else { + actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "selectKeys": false, "add": add}))); + } } if gr.double_clicked() { if fx.is_some() { @@ -3558,7 +3630,7 @@ fn waveform_row(app: &mut EffectcraftApp, g: &Rw, rc: &RowCtx<'_>) { let RowKind::Waveform { item, .. } = &rc.row.kind else { return }; lp.rect_filled(left, 0.0, if ri.is_multiple_of(2) { t.row } else { t.row_alt }); let indent = cw.name + 6.0 + 14.0 * row.depth as f32; - lp.text(pos2(indent + 10.0, cy), Align2::LEFT_CENTER, "Waveform", Tokens::ui(12.0), t.text); + lp.text(pos2(indent + 10.0, cy), Align2::LEFT_CENTER, crate::i18n::tr("Waveform"), Tokens::ui(12.0), t.text); let wr = Rect::from_min_max(pos2(graph_x0, r.min.y), r.max); gp.rect_filled(wr, 0.0, t.tl_bg); app.auto.add(&format!("timeline.layer.{}.waveform", layer.id.0), wr, "Waveform"); @@ -3568,7 +3640,7 @@ fn waveform_row(app: &mut EffectcraftApp, g: &Rw, rc: &RowCtx<'_>) { gp.text( pos2(tm.x(layer.in_point.seconds()).max(wr.min.x) + 6.0, wr.center().y), Align2::LEFT_CENTER, - "Building waveform…", + crate::i18n::tr("Building waveform…"), Tokens::ui(11.0), t.text_faint, ); @@ -4047,6 +4119,7 @@ fn key_context_menu(app: &mut EffectcraftApp, g: &mut Rw, rc: &RowCtx<'_>, prop: ("Keyframe Interpolation…", "keys.interpolation", json!({})), ("Keyframe Velocity…", "keys.velocity", json!({})), ("Toggle Hold Keyframe", "keys.toggleHold", json!({})), + (if k.muted { "Unmute Keyframes" } else { "Mute Keyframes" }, "keys.setMuted", json!({"muted": !k.muted})), ("Rove Across Time", "keys.interpolation", json!({"roving": !k.roving})), ("-", "", json!({})), ("Easy Ease", "keys.easyEase", json!({})), @@ -4139,6 +4212,8 @@ fn key_paint(g: &Rw, rc: &RowCtx<'_>, k: &effectcraft_engine::keyframe::Keyframe None if ks => t.keyframe_selected, None => t.keyframe, }; + // Muted keys stay visible and editable but no longer drive the value. + let kcol = if k.muted { kcol.gamma_multiply(0.35) } else { kcol }; if k.roving { // Roving keys: a small dot (their time follows their neighbours). gp.circle_filled(c, 2.5, kcol); @@ -4260,6 +4335,7 @@ fn pick_whip_drag(g: &mut Rw) { let t = g.tl.t; let graph_x0 = g.tl.graph_x0; let ctx = g.tl.ctx.clone(); + let comp = g.tl.comp.clone(); let actions = &mut g.tl.actions; let hit_rows = &mut g.hit_rows; let dim_hits = &mut g.dim_hits; @@ -4272,7 +4348,7 @@ fn pick_whip_drag(g: &mut Rw) { let target = hit_rows.iter().find(|(r, _)| r.contains(ptr)).map(|(r, row)| (*r, row.clone())); // Shown this pass or the last one (Effect Controls may draw after the Timeline). let pass = ctx.cumulative_pass_nr(); - let elsewhere = (target.is_none() && kind != WHIP_PARENT) + let elsewhere = (target.is_none() && !matches!(kind, WHIP_PARENT | WHIP_MATTE)) .then(|| ctx.data(|d| d.get_temp::(whip_targets_id()))) .flatten() .filter(|(p, _)| p.saturating_add(1) >= pass) @@ -4283,10 +4359,13 @@ fn pick_whip_drag(g: &mut Rw) { fg.rect_stroke(*tr, 0.0, Stroke::new(1.0, t.accent), StrokeKind::Inside); } if ctx.input(|i| !i.pointer.any_down()) { - ctx.data_mut(|d| { + let whip_layers = ctx.data_mut(|d| { d.remove::(pick_whip_id()); d.remove::(whip_targets_id()); + d.remove_temp::>(whip_layers_id()) }); + // The layers a layer pick whip links, less the one it was dropped on. + let linked = |onto: u64| -> Vec { whip_layers.clone().unwrap_or_else(|| vec![src_layer]).into_iter().filter(|l| *l != onto).collect() }; let into = (kind == WHIP_EXPR).then(|| ctx.data_mut(|d| d.remove_temp::(whip_into_id(src_prop)))).flatten(); // The property dropped on, and the value (dimension) if on one of them: `position[0]`. let dim = |fields: &[Rect]| fields.iter().position(|r| r.contains(ptr)); @@ -4299,7 +4378,11 @@ fn pick_whip_drag(g: &mut Rw) { }; match (kind, target, prop_target) { (WHIP_PARENT, Some((_, row)), _) if row.layer.0 != src_layer => { - actions.push(("layer.setParent".into(), json!({"layers": [src_layer], "parent": row.layer.0}))); + actions.push(("layer.setParent".into(), json!({"layers": linked(row.layer.0), "parent": row.layer.0}))); + } + // Each layer keeps its own matte mode. + (WHIP_MATTE, Some((_, row)), _) if row.layer.0 != src_layer && comp.layer(row.layer).is_some_and(|l| l.source.is_av()) => { + actions.push(("layer.setTrackMatte".into(), json!({"layers": linked(row.layer.0), "matte": row.layer.0, "kind": "preserve"}))); } // The property pick whip replaces the expression with the reference (or adds one), // as the expression pick whip does when its expression isn't being edited. @@ -4430,6 +4513,50 @@ fn layer_reorder_drag(ui: &mut egui::Ui, g: &mut Rw) { // Quick Offset of layer bars: the last selected layer moves by whole frames with the pointer } +/// Group reorder drag (#2210): a line where the dragged effect, mask or shape item lands among +/// its siblings; the drop moves it there (`prop.moveGroup`). Escape cancels. +fn group_reorder_drag(ui: &mut egui::Ui, g: &mut Rw) { + let ctx = g.tl.ctx.clone(); + let comp = g.tl.comp.clone(); + let Some(drag) = ctx.data(|d| d.get_temp::(group_drag_id())) else { return }; + let (down, released, escape) = + ctx.input(|i| (i.pointer.primary_down(), i.pointer.button_released(egui::PointerButton::Primary), i.key_pressed(egui::Key::Escape))); + let dropped = ctx.drag_stopped_id() == Some(drag.owner) && released; + let parent = comp.layer(drag.layer).and_then(|l| l.props.parent_of(drag.uid)); + let valid = ui.is_enabled() && drag.comp == g.tl.cid && parent.is_some() && !escape && (dropped || (down && ctx.dragged_id() == Some(drag.owner))); + if dropped || !valid { + ctx.data_mut(|d| d.remove::(group_drag_id())); + } + let Some(parent) = parent.filter(|_| valid) else { return }; + let siblings: Vec = parent.children.iter().map(Node::uid).collect(); + let rows: Vec<(Rect, u64)> = g + .hit_rows + .iter() + .filter_map(|(r, row)| match &row.kind { + RowKind::Group { uid, .. } if row.layer == drag.layer && siblings.contains(uid) => Some((*r, *uid)), + _ => None, + }) + .collect(); + let Some(py) = ui.input(|i| i.pointer.interact_pos().or(i.pointer.latest_pos())).map(|p| p.y) else { return }; + if rows.is_empty() { + return; + } + let (at, line_y) = insertion(&rows, py, g.tl.rows_rect.min.y); + if !dropped { + ctx.set_cursor_icon(egui::CursorIcon::Grabbing); + g.tl.p.with_clip_rect(g.tl.rows_rect).line_segment([pos2(g.tl.rect.min.x, line_y), pos2(g.tl.graph_x0, line_y)], Stroke::new(2.0, g.tl.t.accent)); + return; + } + // The sibling the drop lands before (or after, below the last visible one). + let Some(&(_, anchor)) = at.map_or(rows.last(), |i| rows.get(i)) else { return }; + let from = siblings.iter().position(|u| *u == drag.uid); + let rest: Vec = siblings.iter().copied().filter(|u| *u != drag.uid).collect(); + let to = if anchor == drag.uid { from } else { rest.iter().position(|u| *u == anchor).map(|i| i + usize::from(at.is_none())) }; + if let Some(to) = to.filter(|to| Some(*to) != from) { + g.tl.actions.push(("prop.moveGroup".into(), json!({"layer": drag.layer.0, "prop": drag.uid, "index": to + 1}))); + } +} + /// Quick Offset of layer bars follows the pointer. fn quick_offset_follow(g: &mut Rw) { let t = g.tl.t; @@ -5012,6 +5139,7 @@ const SHAPE_ADD_ITEMS: &[(&str, &str)] = &[ ("trim", "Trim Paths"), ("twist", "Twist"), ("wiggle", "Wiggle Paths"), + ("wiggler", "Wiggle Transform"), ("zigzag", "Zig Zag"), ]; @@ -5141,19 +5269,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)> { +/// The command for the Track Matte menu item `i` (`choice`) of a row, setting `layers`' mattes, +/// whose menu lists "No Track Matte", a separator, the matte `candidates`, a separator and the +/// modes. A new matte layer keeps each layer's mode (the matte itself, if among them, is left +/// out); a mode without a matte layer takes the layer above, like Layer ▸ Track Matte (#864: it +/// used to do nothing). +fn track_matte_pick(layers: &[u64], 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}))); + return Some(("layer.setTrackMatte".into(), json!({"layers": layers, "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 layers: Vec = layers.iter().copied().filter(|l| *l != m.0).collect(); + return (!layers.is_empty()).then(|| ("layer.setTrackMatte".into(), json!({"layers": layers, "matte": m.0, "kind": "preserve"}))); } let k = MatteKind::ALL.into_iter().find(|k| k.label() == choice)?; - Some(("layer.trackMatte".into(), json!({"layers": [layer.id.0], "op": kind_name(k)}))) + Some(("layer.trackMatte".into(), json!({"layers": layers, "op": kind_name(k)}))) } fn kind_name(k: MatteKind) -> &'static str { @@ -5486,7 +5616,8 @@ fn value_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>, fn scalar_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>, actions: &mut Vec<(String, serde_json::Value)>, value: &Value) { let Value::Scalar(v) = value else { return }; let (layer, prop, at, uid) = (ve.layer, ve.prop, ve.at, ve.uid); - let t = app.tokens; + // The values themselves: red while an expression drives the property (#1306). + let vt = app.tokens.for_value(prop.has_expression()); let (x, y) = (at.x, at.y - 9.0); let (min, max, dec, speed) = match &prop.ui { ParamUi::Slider { min, max, decimals, .. } => (*min, *max, *decimals as usize, widgets::slider_scrub_speed(&prop.ui)), @@ -5494,7 +5625,7 @@ fn scalar_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_> _ => (-1e9, 1e9, 1, 1.0), }; if matches!(prop.ui, ParamUi::Angle) { - let (rr, dr, nv) = super::fx_widgets::angle_field(ui, pos2(x, y), egui::Id::unique(("v", uid, 0)), *v, 1, &t); + let (rr, dr, nv) = super::fx_widgets::angle_field(ui, pos2(x, y), egui::Id::unique(("v", uid, 0)), *v, 1, &vt); app.auto.add(&format!("timeline.prop.{uid}.value"), dr, &prop.name); app.auto.add(&format!("timeline.prop.{uid}.revolutions"), rr, &prop.name); if let Some(nv) = nv { @@ -5510,7 +5641,7 @@ fn scalar_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_> egui::Id::unique(("v", uid, 0)), *v, &widgets::HotSpec { speed, range: (min, max), decimals: dec.max(1), suffix }, - &t, + &vt, ); app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name); if let Some(nv) = nv { @@ -5523,6 +5654,7 @@ fn scalar_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_> fn vector_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>, actions: &mut Vec<(String, serde_json::Value)>, value: &Value) -> Vec { let (p, layer, prop, at, uid) = (ve.p, ve.layer, ve.prop, ve.at, ve.uid); let t = app.tokens; + let vt = t.for_value(prop.has_expression()); let (mut x, y) = (at.x, at.y - 9.0); let is_3d = layer.is_3d(); let mut dims = vec![]; @@ -5555,7 +5687,7 @@ fn vector_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_> egui::Id::unique(("v", uid, d)), c[d], &widgets::HotSpec { speed: if pct { 0.5 } else { 1.0 }, range, decimals: 1, suffix }, - &t, + &vt, ); app.auto.add(&format!("timeline.prop.{uid}.value.{d}"), r, &prop.name); dims.push(r); @@ -5578,7 +5710,7 @@ fn vector_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_> } x = r.max.x + if d + 1 < n { 2.0 } else { 0.0 }; if d + 1 < n { - p.text(pos2(x, at.y), Align2::LEFT_CENTER, ",", Tokens::ui(12.0), t.hot_text); + p.text(pos2(x, at.y), Align2::LEFT_CENTER, ",", Tokens::ui(12.0), vt.hot_text); x += 6.0; } } @@ -5653,7 +5785,7 @@ fn enum_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>, ve.set(actions, json!(ni)); } } else { - p.text(pos2(x, at.y), Align2::LEFT_CENTER, fmt_num(*i as f64, 0), Tokens::ui(12.0), t.hot_text); + p.text(pos2(x, at.y), Align2::LEFT_CENTER, fmt_num(*i as f64, 0), Tokens::ui(12.0), t.for_value(prop.has_expression()).hot_text); } } diff --git a/crates/ui-egui/src/panels/timeline/tests.rs b/crates/ui-egui/src/panels/timeline/tests.rs index d05787a18..12259abf7 100644 --- a/crates/ui-egui/src/panels/timeline/tests.rs +++ b/crates/ui-egui/src/panels/timeline/tests.rs @@ -79,7 +79,7 @@ fn a_track_matte_mode_without_a_matte_takes_the_layer_above() { 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"); + let (id, params) = track_matte_pick(&[find(s, name).id.0], &candidates, i, choice).expect("a menu item is an action"); s.execute(&id, params) }; pick(&mut s, "Chain Bottom", 5, "Alpha Inverted Matte").unwrap(); @@ -97,7 +97,7 @@ fn a_track_matte_mode_without_a_matte_takes_the_layer_above() { 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"); + assert!(track_matte_pick(&[bottom.id.0], &candidates, 1, "-").is_none(), "a separator does nothing"); } /// #957: vector footage gets the Continuously Rasterize switch; raster footage does not. diff --git a/crates/ui-egui/src/panels/tracker.rs b/crates/ui-egui/src/panels/tracker.rs index 3edde09df..dac9f7d2b 100644 --- a/crates/ui-egui/src/panels/tracker.rs +++ b/crates/ui-egui/src/panels/tracker.rs @@ -626,7 +626,7 @@ pub fn apply_dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { super::dialogs::modal(ctx, "Motion Tracker Apply Options", vec2(380.0, 170.0), t, |ui| { ui.horizontal(|ui| { ui.label(crate::i18n::tr("Apply Dimensions:")); - let labels = ["X and Y", "X only", "Y only"]; + let labels = [crate::i18n::tr("X and Y"), crate::i18n::tr("X only"), crate::i18n::tr("Y only")]; let r = craft_ui::ComboBox::from_id_salt("track-apply-dims") .selected_text(labels[d.dims.min(2)]) .show_ui(ui, |ui| { diff --git a/crates/ui-egui/src/panels/unsaved.rs b/crates/ui-egui/src/panels/unsaved.rs index b5fc582b2..7befef4e2 100644 --- a/crates/ui-egui/src/panels/unsaved.rs +++ b/crates/ui-egui/src/panels/unsaved.rs @@ -16,7 +16,16 @@ use crate::theme::Tokens; use crate::{Dialog, EffectcraftApp}; /// Commands that replace or close the open project (Quit is asked when the window closes). -const CLOSING: &[&str] = &["file.newProject", "file.open", "file.openRecent", "file.closeProject", "file.revert", "file.openDemoProject", "templates.create"]; +const CLOSING: &[&str] = &[ + "file.newProject", + "file.open", + "file.openRecent", + "file.closeProject", + "file.revert", + "file.openDemoProject", + "templates.create", + "file.runShutdownScripts", +]; /// The command waiting on the prompt, and whether the user already answered it. #[derive(Clone, Debug, Default)] @@ -239,6 +248,23 @@ mod tests { assert!(app.session.is_dirty()); } + #[test] + fn shutdown_scripts_prompt_before_discarding_unsaved_work() { + let (mut app, ctx) = app(); + modified(&mut app); + let result = crate::menus::invoke(&mut app, &ctx, "file.runShutdownScripts", json!({})).unwrap(); + assert_eq!(result, json!({"dialog": "unsavedChanges"})); + assert_eq!(app.session.project.comps().count(), 1); + assert!(app.session.is_dirty()); + answer_with(&mut app, &ctx, Answer::Cancel); + assert_eq!(app.session.project.comps().count(), 1); + assert!(app.session.is_dirty()); + crate::menus::invoke(&mut app, &ctx, "file.runShutdownScripts", json!({})).unwrap(); + answer_with(&mut app, &ctx, Answer::Discard); + assert_eq!(app.session.project.comps().count(), 0); + assert!(!app.session.is_dirty() && app.dialog.is_none()); + } + #[test] fn closing_a_modified_project_asks_and_cancel_keeps_it() { let (mut app, ctx) = app(); diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index bcae99bc5..8c619c9c1 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -178,7 +178,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { phase_frame(app, &mut v); let snap_project = app.session.project.clone(); let snap_expr = app.session.expr.clone(); - let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time: v.time, expr: snap_expr.as_deref(), footage: None }; + let ectx = EvalCtx { + project: &snap_project, + comp_id: cid, + comp: &comp, + time: v.time, + expr: snap_expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; phase_guides(app, &ectx, &mut v); phase_layers(app, ui, &ectx, &mut v); phase_scene(app, &ectx, &mut v); @@ -935,11 +944,14 @@ fn phase_hover(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, resp } }; ctx.set_cursor_icon(cursor); - // Scroll wheel zooms around the pointer. - let scroll = ui.input(|i| i.smooth_scroll_delta.y + i.zoom_delta().ln() * 300.0); - if scroll.abs() > 0.1 && area.contains(hp) { - let k = (scroll / 300.0).exp(); - let nz = (zoom * k).clamp(0.01, 32.0); + // The mouse wheel and a trackpad pinch zoom around the pointer; a two-finger trackpad + // scroll pans. + let scroll = super::viewer_scroll(&ctx); + if scroll.pan != egui::Vec2::ZERO && area.contains(hp) { + app.ui.viewer.pan = [app.ui.viewer.pan[0] + scroll.pan.x, app.ui.viewer.pan[1] + scroll.pan.y]; + } + if (scroll.zoom - 1.0).abs() > 3e-4 && area.contains(hp) { + let nz = (zoom * scroll.zoom).clamp(0.01, 32.0); let before = map.to_comp(hp); let new_origin = hp - vec2(before[0] as f32 * nz, before[1] as f32 * nz); let new_center = new_origin + vec2(cw * nz / 2.0, ch * nz / 2.0); @@ -1252,6 +1264,39 @@ fn phase_dragstart(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, v.puppet_layers = puppet_layers; } +/// Auto-pan (#999): a mask vertex, tangent or Pen drag held at (or past) the edge of the view +/// pans it, as in After Effects, so the point moves on past what was visible. Returns the map +/// of the panned view. +fn edge_pan(app: &mut EffectcraftApp, ctx: &egui::Context, g: &Gesture, map: ViewerMap, pos: Pos2) -> ViewerMap { + if !matches!(g, Gesture::Vertices { .. } | Gesture::Tangent { .. } | Gesture::Pen { .. }) { + return map; + } + let d = edge_pan_speed(map.area, pos) * ctx.input(|i| i.stable_dt).min(0.1); + if d == egui::Vec2::ZERO { + return map; + } + app.ui.viewer.pan = [app.ui.viewer.pan[0] + d.x, app.ui.viewer.pan[1] + d.y]; + ctx.request_repaint(); + ViewerMap { origin: map.origin + d, ..map } +} + +/// How fast (points per second, per axis) the view pans under a drag at `pos`: within 24 points +/// of an edge of `area`, or past it, toward that edge and faster the further out. +fn edge_pan_speed(area: Rect, pos: Pos2) -> egui::Vec2 { + const ZONE: f32 = 24.0; + let axis = |p: f32, lo: f32, hi: f32| { + let depth = if p < lo + ZONE { + lo + ZONE - p + } else if p > hi - ZONE { + hi - ZONE - p + } else { + 0.0 + }; + (depth / ZONE).clamp(-3.0, 3.0) * 400.0 + }; + vec2(axis(pos.x, area.min.x, area.max.x), axis(pos.y, area.min.y, area.max.y)) +} + fn phase_drag(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, resp: &egui::Response, v: &mut Vw<'_>) { let t = v.t; let zoom = v.zoom; @@ -1262,6 +1307,7 @@ fn phase_drag(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, resp: let painter = v.painter.clone(); let gesture: Option = ui.data(|d| d.get_temp(gid)); if let (Some(g), Some(pos)) = (gesture.clone(), resp.interact_pointer_pos()) { + let map = edge_pan(app, &ctx, &g, map, pos); let cpt = map.to_comp(pos); let merge = format!("viewer-drag-{}", ui.data(|d| d.get_temp::(egui::Id::unique("viewer-drag-n")).unwrap_or(0))); match g { @@ -1352,8 +1398,9 @@ fn phase_drag(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, resp: } } Gesture::Vertices { start, src, applied, inv, layer } => { - // The grabbed vertex snaps (to other layers' vertices and features, guides, grid). - let want = [cpt[0] - start[0], cpt[1] - start[1]]; + // The grabbed vertex snaps (to other layers' vertices and features, guides, grid); + // Shift keeps it to one axis, as a layer move does (#1964). + let want = one_axis([cpt[0] - start[0], cpt[1] - start[1]], mods.shift); let c = vt::snap(app, &ctx, ectx, &map, &[layer], &[[src[0] + want[0], src[1] + want[1]]], mods); let total = [want[0] + c[0], want[1] + c[1]]; let d = inv.apply_vec(gv2(total[0] - applied[0], total[1] - applied[1])); @@ -1808,7 +1855,7 @@ use geometry::{ pub(crate) use geometry::{from_effect_space, fx2c, l2c, layer_outline, layer_quad}; use gesture::{Gesture, HANDLE, ROTO_HINT, pen_id, roi_drag, roi_handles, vertex_grab_radius}; use shapes::{create_shape, draw_shape_ghost, shape_drag}; -pub(crate) use shapes::{layer_at, layers_at, pick, selectable_layers}; +pub(crate) use shapes::{layer_at, layer_at_including_locked, layers_at, pick, selectable_layers}; #[cfg(test)] use tools::constrain_45; use tools::{add_motion_key, axis_at, axis_drag, drag_axes, draw_rigs, parent_world_inverse, pen_cursor, pen_cursor_icon, pen_press, select_motion_key}; diff --git a/crates/ui-egui/src/panels/viewer/frame.rs b/crates/ui-egui/src/panels/viewer/frame.rs index ac467ecd5..65095b399 100644 --- a/crates/ui-egui/src/panels/viewer/frame.rs +++ b/crates/ui-egui/src/panels/viewer/frame.rs @@ -9,13 +9,13 @@ pub(super) fn empty_state(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rec let c = rect.center(); p.text(c - vec2(0.0, 40.0), Align2::CENTER_CENTER, crate::i18n::tr("Composition"), Tokens::semibold(16.0), t.text); let b1 = Rect::from_center_size(c + vec2(0.0, 0.0), vec2(220.0, 30.0)); - if widgets::text_button(ui, b1, "New Composition", true, &t, egui::Id::unique("empty-newcomp")).clicked() { + if widgets::text_button(ui, b1, crate::i18n::tr("New Composition"), true, &t, egui::Id::unique("empty-newcomp")).clicked() { crate::panels::dialogs::open_new_comp(app); } app.auto.add("viewer.empty.newComp", b1, "New Composition"); let b2 = Rect::from_center_size(c + vec2(0.0, 40.0), vec2(220.0, 30.0)); - if widgets::text_button(ui, b2, "New Composition From Footage", false, &t, egui::Id::unique("empty-fromfootage")).clicked() { - let _ = crate::menus::invoke(app, &ui.ctx().clone(), "file.import", json!({})); + if widgets::text_button(ui, b2, crate::i18n::tr("New Composition From Footage"), false, &t, egui::Id::unique("empty-fromfootage")).clicked() { + let _ = crate::menus::invoke(app, &ui.ctx().clone(), "file.import", json!({"newComp": true})); } app.auto.add("viewer.empty.import", b2, "New Composition From Footage"); // A Project item dropped on the empty viewer creates the first composition, diff --git a/crates/ui-egui/src/panels/viewer/shapes.rs b/crates/ui-egui/src/panels/viewer/shapes.rs index 395219a05..0cd579883 100644 --- a/crates/ui-egui/src/panels/viewer/shapes.rs +++ b/crates/ui-egui/src/panels/viewer/shapes.rs @@ -4,14 +4,17 @@ use super::{ pos2, }; -/// Visible, unlocked layers eligible for viewer hit tests, in front-to-back order. +/// Visible layers eligible for viewer hit tests, locked or not, in front-to-back order. /// Solo scope matches the renderer: any soloed AV layer activates it at every time (as in After /// Effects), even when hidden, locked or outside its in-out range. -pub(crate) fn selectable_layers(comp: &Comp, time: Tick) -> impl Iterator { +fn hit_testable_layers(comp: &Comp, time: Tick) -> impl Iterator { let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.source.is_av()); - comp.layers - .iter() - .filter(move |l| l.is_active_at(time) && l.switches.video && !l.switches.locked && !l.is_camera() && !l.is_light() && (!any_solo || l.switches.solo)) + comp.layers.iter().filter(move |l| l.is_active_at(time) && l.switches.video && !l.is_camera() && !l.is_light() && (!any_solo || l.switches.solo)) +} + +/// Visible, unlocked layers eligible for viewer hit tests, in front-to-back order. +pub(crate) fn selectable_layers(comp: &Comp, time: Tick) -> impl Iterator { + hit_testable_layers(comp, time).filter(|l| !l.switches.locked) } /// Selectable layers under a comp point, front to back. @@ -24,6 +27,18 @@ pub(crate) fn layer_at<'a>(ectx: &EvalCtx<'a>, cpt: [f64; 2]) -> Option<&'a Laye layers_at(ectx, cpt).next() } +/// Topmost visible layer under a comp point, locked or not. An Alt-drop uses it so a locked layer in +/// front is refused rather than skipped, which would replace the layer behind it. +pub(crate) fn layer_at_including_locked<'a>(ectx: &EvalCtx<'a>, cpt: [f64; 2]) -> Option<&'a Layer> { + // A layer scaled to nothing has a degenerate quad that `point_in_quad` accepts everywhere. + hit_testable_layers(ectx.comp, ectx.time).find(|l| layer_quad(ectx, l).is_some_and(|(_, q, _)| quad_area(&q) > 1e-9 && point_in_quad(cpt, &q))) +} + +/// Area of a quad (shoelace formula); zero when its corners coincide or are collinear. +fn quad_area(q: &[[f64; 2]; 4]) -> f64 { + (0..4).map(|i| q[i][0] * q[(i + 1) % 4][1] - q[(i + 1) % 4][0] * q[i][1]).sum::().abs() / 2.0 +} + /// The layer a press at a comp point acts on, selected (Shift toggles the topmost layer). As in /// After Effects, a selected layer under the point comes before the layers in front of it, so it /// can be dragged where they cover it; otherwise the topmost layer (#230). @@ -202,6 +217,8 @@ pub(super) fn create_shape(app: &mut EffectcraftApp, tool: Tool, a: [f64; 2], b: time: app.session.time(), expr: None, footage: None, + property_overrides: &[], + media_overrides: &[], }; let inv = l2c(&ectx, &l).0.inverse().unwrap_or(Mat3::IDENTITY); let p0 = inv.apply(gv2(cx - w / 2.0, cy - h / 2.0)); diff --git a/crates/ui-egui/src/panels/viewer/tests.rs b/crates/ui-egui/src/panels/viewer/tests.rs index cb5bffe65..f9d3ef2f4 100644 --- a/crates/ui-egui/src/panels/viewer/tests.rs +++ b/crates/ui-egui/src/panels/viewer/tests.rs @@ -139,3 +139,15 @@ fn gizmo_segments_clip_at_the_near_plane() { assert!(s[1][0] > 10.0 && s[1][0].is_finite(), "toward the camera it grows, never flips: {s:?}"); assert!(project_segment(&pc, vec3(0.0, 0.0, -200.0), vec3(0.0, 0.0, -300.0)).is_none()); } + +/// #999: a vertex drag pans the view only near or past the edge, toward it, faster further out. +#[test] +fn edge_pan_speed_moves_toward_the_edge() { + let area = Rect::from_min_max(pos2(100.0, 50.0), pos2(900.0, 650.0)); + assert_eq!(edge_pan_speed(area, pos2(500.0, 350.0)), egui::Vec2::ZERO); + let left = edge_pan_speed(area, pos2(105.0, 350.0)); + assert!(left.x > 0.0 && left.y == 0.0, "near the left edge the view moves right to show more: {left:?}"); + assert!(edge_pan_speed(area, pos2(20.0, 350.0)).x > left.x, "faster past the edge"); + let corner = edge_pan_speed(area, pos2(950.0, 700.0)); + assert!(corner.x < 0.0 && corner.y < 0.0, "{corner:?}"); +} diff --git a/crates/ui-egui/src/panels/viewer/view.rs b/crates/ui-egui/src/panels/viewer/view.rs index 30b3f4d00..59e3e513d 100644 --- a/crates/ui-egui/src/panels/viewer/view.rs +++ b/crates/ui-egui/src/panels/viewer/view.rs @@ -44,7 +44,16 @@ pub(super) fn aux_views( // 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 ectx = EvalCtx { + project: &snap_project, + comp_id: cid, + comp, + time: t, + expr: snap_expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let selected = app.session.state.selected_layers.clone(); let mut picked: Option<(usize, View3D)> = None; for (i, (r, v)) in aux.into_iter().enumerate() { diff --git a/crates/ui-egui/src/panels/viewer_drop.rs b/crates/ui-egui/src/panels/viewer_drop.rs index 44364a49d..ac56d9acb 100644 --- a/crates/ui-egui/src/panels/viewer_drop.rs +++ b/crates/ui-egui/src/panels/viewer_drop.rs @@ -1,7 +1,8 @@ //! Dropping onto the Composition viewer: footage, comps and solids from the //! Project panel and files from the Media Browser become layers centred where they are dropped //! (#85), and an effect or animation preset from Effects & Presets goes on the layer under the -//! pointer, outlined while it is dragged (#88). Agents do the same with `layer.addItem {position}`, +//! pointer, outlined while it is dragged (#88). Alt/Option-dropping a Project item replaces the source of +//! the layer under the pointer instead, as in the Timeline (#2173). Agents do the same with `layer.addItem {position}`, //! `mediaBrowser.import {position}`, `effect.apply {layers}` and the preset commands. use effectcraft_engine::project::{ItemId, ItemKind}; @@ -10,7 +11,7 @@ use egui::{Rect, Stroke, StrokeKind, vec2}; use serde_json::{Value, json}; use super::DragPayload; -use super::viewer::{ViewerMap, layer_at, layer_quad}; +use super::viewer::{ViewerMap, layer_at, layer_at_including_locked, layer_quad}; use crate::EffectcraftApp; /// While something is dragged over the viewer, show where it goes; drop it on release. @@ -23,6 +24,9 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &egui::Ui, painter: &egui::Pain let Some(ptr) = ctx.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(map.area)) else { return }; let stroke = Stroke::new(2.0, app.tokens.accent); let at = map.to_comp(ptr); + // Alt/Option-drop of a Project item replaces the source of the layer under the pointer (After + // Effects), as in the Timeline; it never adds a layer. + let alt_item = ctx.input(|i| i.modifiers.alt) && matches!(payload.as_ref(), DragPayload::Item(_)); let actions: Vec<(&str, Value)> = match payload.as_ref() { DragPayload::Effect(effect) => layer_at(ectx, at) .map(|l| { @@ -42,6 +46,7 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &egui::Ui, painter: &egui::Pain }) .into_iter() .collect(), + DragPayload::Item(item) if alt_item => alt_replace(app, ectx, painter, map, stroke, at, *item).into_iter().collect(), DragPayload::Item(item) => { // The layer's frame, centred on the pointer. if let Some([w, h]) = app.session.project.item(ItemId(*item)).and_then(|it| frame_size(&it.kind, ectx.comp.pixel_aspect)) { @@ -55,7 +60,7 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &egui::Ui, painter: &egui::Pain DragPayload::Property { .. } => vec![], }; // Layer drops outline the target layer; the others outline the whole viewer. - let layer_drop = matches!(payload.as_ref(), DragPayload::Effect(_) | DragPayload::Preset { .. }); + let layer_drop = alt_item || matches!(payload.as_ref(), DragPayload::Effect(_) | DragPayload::Preset { .. }); if !layer_drop && !actions.is_empty() { painter.rect_stroke(map.area, 0.0, stroke, StrokeKind::Inside); } @@ -80,3 +85,21 @@ fn frame_size(kind: &ItemKind, comp_pixel_aspect: f64) -> Option<[f32; 2]> { let stretch = if comp_pixel_aspect > 0.0 && pixel_aspect > 0.0 { pixel_aspect / comp_pixel_aspect } else { 1.0 }; (w > 0 && h > 0).then_some([(w as f64 * stretch) as f32, h as f32]) } + +/// The `layer.replaceSource` for an Alt/Option-drop of Project item `item` at comp point `at`: the +/// topmost layer there, outlined, if it can take the item as its source (a locked one cannot). +fn alt_replace( + app: &EffectcraftApp, + ectx: &EvalCtx, + painter: &egui::Painter, + map: &ViewerMap, + stroke: Stroke, + at: [f64; 2], + item: u64, +) -> Option<(&'static str, Value)> { + let layer = layer_at_including_locked(ectx, at).filter(|l| super::timeline::replace_source_target(app, ectx.comp_id, ItemId(item), l.id, ectx.comp))?; + if let Some((_, q, _)) = layer_quad(ectx, layer) { + painter.add(egui::Shape::closed_line(q.iter().map(|c| map.to_screen(*c)).collect(), stroke)); + } + Some(("layer.replaceSource", json!({"layers": [layer.id.0], "item": item, "fixExpressions": false}))) +} diff --git a/crates/ui-egui/src/panels/viewer_overlays.rs b/crates/ui-egui/src/panels/viewer_overlays.rs index ef4549db6..05884abce 100644 --- a/crates/ui-egui/src/panels/viewer_overlays.rs +++ b/crates/ui-egui/src/panels/viewer_overlays.rs @@ -360,31 +360,36 @@ pub(crate) fn motion_path( } let v = k.value.as_vec3(); let s = to_scr(v); - // Auto-Bezier keys show handles on a side the path leaves straight too (a two-key path's - // ends), to drag it into a curve; Linear keys (no tangents) show none. - let (tin, tout) = if k.spatial_auto { - effectcraft_engine::keyframe::spatial_handles(&pos.keys, i) - } else { - effectcraft_engine::keyframe::spatial_tangents(&pos.keys, i) - }; - for (out, tg) in [(false, tin), (true, tout)] { - let has = (out && i + 1 < pos.keys.len()) || (!out && i > 0); - if !has || (tg[0] == 0.0 && tg[1] == 0.0) { - continue; + let selected = sel.contains(&KeyRef { layer: l.id, prop: pos.uid, time: k.time }); + // A muted position key is off the motion path: no tangents, and a dimmed point. + let kc = if k.muted { col.gamma_multiply(if selected { 0.55 } else { 0.35 }) } else { col }; + if !k.muted { + // Auto-Bezier keys show handles on a side the path leaves straight too (a two-key path's + // ends), to drag it into a curve; Linear keys (no tangents) show none. + let (tin, tout) = if k.spatial_auto { + effectcraft_engine::keyframe::spatial_handles(&pos.keys, i) + } else { + effectcraft_engine::keyframe::spatial_tangents(&pos.keys, i) + }; + for (out, tg) in [(false, tin), (true, tout)] { + let has = (out && i + 1 < pos.keys.len()) || (!out && i > 0); + if !has || (tg[0] == 0.0 && tg[1] == 0.0) { + continue; + } + let h = to_scr([v[0] + tg[0], v[1] + tg[1], v[2] + tg[2]]); + painter.line_segment([s, h], Stroke::new(1.0, kc)); + painter.circle_filled(h, 3.0, kc); + let side = if out { "out" } else { "in" }; + app.auto.add(&format!("viewer.motionPath.{}.{i}.{side}", l.id.0), Rect::from_center_size(h, vec2(8.0, 8.0)), "Spatial tangent"); + hits.push(KeyHit { layer: l.id, prop: pos.uid, time: k.time, pos: h, tangent: Some(out), value: v, p2c }); } - let h = to_scr([v[0] + tg[0], v[1] + tg[1], v[2] + tg[2]]); - painter.line_segment([s, h], Stroke::new(1.0, col)); - painter.circle_filled(h, 3.0, col); - let side = if out { "out" } else { "in" }; - app.auto.add(&format!("viewer.motionPath.{}.{i}.{side}", l.id.0), Rect::from_center_size(h, vec2(8.0, 8.0)), "Spatial tangent"); - hits.push(KeyHit { layer: l.id, prop: pos.uid, time: k.time, pos: h, tangent: Some(out), value: v, p2c }); } let r = Rect::from_center_size(s, vec2(7.0, 7.0)); - if sel.contains(&KeyRef { layer: l.id, prop: pos.uid, time: k.time }) { - painter.rect_filled(r, 0.0, col); + if selected { + painter.rect_filled(r, 0.0, kc); } else { painter.rect_filled(r, 0.0, Color32::from_black_alpha(140)); - painter.rect_stroke(r, 0.0, Stroke::new(1.0, col), StrokeKind::Inside); + painter.rect_stroke(r, 0.0, Stroke::new(1.0, kc), StrokeKind::Inside); } app.auto.add(&format!("viewer.motionPath.{}.{i}", l.id.0), r.expand(2.0), "Position keyframe"); hits.push(KeyHit { layer: l.id, prop: pos.uid, time: k.time, pos: s, tangent: None, value: v, p2c }); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 227866bcc..aa1b30350 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -129,6 +129,16 @@ impl Tokens { pub fn grad_top(&self, c: Color32) -> Color32 { if self.gradients { c.lerp_to_gamma(Color32::WHITE, 0.06) } else { c } } + + /// The tokens a property's value fields use (#1306): with an expression driving the property + /// (`expression`, [`Property::has_expression`]) its values show in the expression colour, red + /// as in After Effects, hovered and dragged too; otherwise these tokens. For the values only: + /// the name, stopwatch and swatches keep the ordinary tokens. + /// + /// [`Property::has_expression`]: effectcraft_engine::project::Property::has_expression + pub fn for_value(self, expression: bool) -> Tokens { + if expression { Tokens { hot_text: self.expr_text, accent_hover: self.expr_text, ..self } } else { self } + } } impl Tokens { diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 58383fe43..ac3368b2e 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -602,7 +602,7 @@ pub fn popup_menu(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, options: &[String] pub fn layer_picker( ui: &mut Ui, rect: Rect, - layers: &[effectcraft_engine::project::Layer], + layers: &effectcraft_engine::project::Layers, current: Option, t: &Tokens, id: egui::Id, diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 1dcf02da0..7d38e3089 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -8,6 +8,7 @@ mod ui_agent_audit; mod ui_angles; mod ui_audio_scrub; mod ui_azerty_shortcuts; +mod ui_batch_links; mod ui_batch_switches; mod ui_blank_context_menus; mod ui_camera_tracker; @@ -15,11 +16,14 @@ mod ui_character_eyedropper; mod ui_color_hex; mod ui_comp_presets; mod ui_control_modifiers; +mod ui_dialog_preview; mod ui_dock_resize; mod ui_drag_drop; mod ui_effect_controls; mod ui_effect_drop_highlight; mod ui_effect_names; +mod ui_env_light_gpu; +mod ui_expression_values; mod ui_font_search; mod ui_form_fit; mod ui_frame_step; @@ -80,6 +84,7 @@ mod ui_tracker; mod ui_unreadable_footage; mod ui_video_preview_off; mod ui_viewer; +mod ui_viewer_trackpad; mod ui_viewers; mod ui_visual_review; mod ui_web_gpu_storage; diff --git a/crates/ui-egui/tests/it/ui_batch_links.rs b/crates/ui-egui/tests/it/ui_batch_links.rs new file mode 100644 index 000000000..5b2cea090 --- /dev/null +++ b/crates/ui-egui/tests/it/ui_batch_links.rs @@ -0,0 +1,209 @@ +//! Parent and Track Matte for several layers at once (#1260): on a selected layer's row, the +//! Timeline's Parent and Track Matte menus and pick whips set every selected layer; on an +//! unselected row, that layer alone. One undo step each; each layer keeps its own matte mode. + +use effectcraft_engine::Session; +use effectcraft_engine::project::{LayerId, MatteKind}; +use effectcraft_ui_egui::EffectcraftApp; +use egui::{Event, Modifiers, PointerButton, Pos2, Rect, pos2, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable as _; +use serde_json::json; + +/// Solids A, B, C, P (moved and scaled, a parent) and the mattes X and Y; stacked Y, X, P, C, B, A +/// from the top. The Track Matte column is shown. +fn harness() -> (Harness<'static, EffectcraftApp>, [u64; 6]) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Links", "width": 320, "height": 180, "duration": 4})).unwrap(); + let ids = ["A", "B", "C", "P", "X", "Y"] + .map(|n| s.execute("layer.newSolid", json!({"name": n, "color": "#406080", "width": 40, "height": 40})).unwrap()["layer"].as_u64().unwrap()); + s.execute("prop.set", json!({"layer": ids[3], "path": "transform/position", "value": [60, 40]})).unwrap(); + s.execute("prop.set", json!({"layer": ids[3], "path": "transform/scale", "value": [50, 50]})).unwrap(); + s.history.undo.clear(); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.state_mut().ui.timeline.show_modes = true; + h.state_mut().ui.timeline.columns_w = 900.0; + h.run_steps(3); + (h, ids) +} + +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>, at: Pos2, pressed: bool) { + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); +} + +fn click(h: &mut Harness<'_, EffectcraftApp>, at: Pos2) { + h.event(Event::PointerMoved(at)); + h.step(); + press(h, at, true); + h.step(); + press(h, at, false); + h.run_steps(3); +} + +/// Open the menu at automation id `dropdown` and choose `item` in it. +fn choose(h: &mut Harness<'_, EffectcraftApp>, dropdown: &str, item: &str) { + let menu = rect(h, dropdown); + // Other rows' menus show their choice as well ("No Track Matte"): the entry is a node the + // opened menu added. + let shown: Vec = h.query_all_by_label(item).map(|n| n.rect()).collect(); + click(h, menu.center()); + // (The menu may open above it to fit the window: the entry nearest to it.) + let off = |r: &Rect| (r.center().y - menu.center().y).abs(); + let at = h.query_all_by_label(item).map(|n| n.rect()).filter(|r| !shown.contains(r)).min_by(|a, b| off(a).total_cmp(&off(b))); + let at = at.unwrap_or_else(|| panic!("no {item} in the {dropdown} menu")).center(); + click(h, at); +} + +/// Drag the pick whip `whip` onto layer `onto`'s row. +fn whip(h: &mut Harness<'_, EffectcraftApp>, whip: &str, onto: u64) { + let (from, to) = (rect(h, whip).center(), rect(h, &format!("timeline.layer.{onto}.row")).center()); + h.event(Event::PointerMoved(from)); + h.step(); + press(h, from, true); + h.step(); + for i in 1..=10 { + h.event(Event::PointerMoved(from + (to - from) * (i as f32 / 10.0))); + h.step(); + } + press(h, to, false); + h.run_steps(3); +} + +fn select(h: &mut Harness<'_, EffectcraftApp>, ids: &[u64]) { + h.state_mut().session.execute("layer.select", json!({"layers": ids})).unwrap(); + h.run_steps(2); +} + +fn parent(h: &Harness<'_, EffectcraftApp>, id: u64) -> Option { + h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().parent.map(|p| p.0) +} + +fn position(h: &Harness<'_, EffectcraftApp>, id: u64) -> [f64; 3] { + h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().props.prop("transform/position").unwrap().value.as_vec3() +} + +fn matte(h: &Harness<'_, EffectcraftApp>, id: u64) -> Option<(u64, MatteKind)> { + h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().track_matte.map(|m| (m.layer.0, m.kind)) +} + +/// The project and its undo steps as they are now, to compare with after an undo or a refusal. +fn mark(h: &Harness<'_, EffectcraftApp>) -> (std::sync::Arc, usize) { + (h.state().session.project.clone(), h.state().session.history.undo.len()) +} + +fn unchanged(h: &Harness<'_, EffectcraftApp>, m: &(std::sync::Arc, usize)) -> bool { + h.state().session.project.same_content(&m.0) && h.state().session.history.undo.len() == m.1 +} + +#[test] +fn parent_menu_and_pick_whip_parent_the_selected_layers() { + let (mut h, [a, b, c, p, ..]) = harness(); + let start = position(&h, a); + select(&mut h, &[a, b]); + let before = mark(&h); + // The Parent menu on A's (selected) row parents A and B, kept where they are. + choose(&mut h, &format!("timeline.layer.{a}.parent"), "3. P"); + assert_eq!([parent(&h, a), parent(&h, b), parent(&h, c)], [Some(p), Some(p), None]); + assert!(position(&h, a) != start && position(&h, a) == position(&h, b), "both re-expressed in P's space"); + assert_eq!(h.state().session.history.undo.len(), before.1 + 1, "one undo step"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + h.run_steps(2); + assert!(unchanged(&h, &before), "one undo puts both back"); + // The pick whip of an unselected row parents that layer alone. + whip(&mut h, &format!("timeline.layer.{c}.pickWhip"), p); + assert_eq!([parent(&h, a), parent(&h, b), parent(&h, c)], [None, None, Some(p)]); + // A selected row's whip parents the selection, less the parent itself if selected. + select(&mut h, &[a, b, p]); + whip(&mut h, &format!("timeline.layer.{a}.pickWhip"), p); + assert_eq!([parent(&h, a), parent(&h, b), parent(&h, p)], [Some(p), Some(p), None]); + // A cycle (P under A, while A is under P) is refused as a whole. + select(&mut h, &[p, c]); + let before = mark(&h); + choose(&mut h, &format!("timeline.layer.{p}.parent"), "6. A"); + assert!(unchanged(&h, &before), "the refused parenting changed nothing"); + assert_eq!([parent(&h, p), parent(&h, c)], [None, Some(p)]); +} + +#[test] +fn track_matte_menu_and_pick_whip_set_the_selected_layers_mattes() { + let (mut h, [a, b, c, _, x, y]) = harness(); + h.state_mut().session.execute("layer.setTrackMatte", json!({"layer": a, "matte": x, "kind": "luma"})).unwrap(); + h.state_mut().session.execute("layer.setTrackMatte", json!({"layer": b, "matte": x, "kind": "alpha"})).unwrap(); + select(&mut h, &[a, b]); + let before = mark(&h); + // A new matte layer for both; each keeps its mode; C (not selected) keeps none. + choose(&mut h, &format!("timeline.layer.{a}.trackMatte"), "1. Y"); + assert_eq!([matte(&h, a), matte(&h, b), matte(&h, c)], [Some((y, MatteKind::Luma)), Some((y, MatteKind::Alpha)), None]); + assert!(!h.state().session.active_comp().unwrap().layer(LayerId(y)).unwrap().switches.video, "Y is hidden as a matte"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + h.run_steps(2); + assert!(unchanged(&h, &before), "one undo puts both mattes and Y's visibility back"); + // A mode for both, then No Track Matte for both. + choose(&mut h, &format!("timeline.layer.{b}.trackMatte"), "Luma Inverted Matte"); + assert_eq!([matte(&h, a), matte(&h, b)], [Some((x, MatteKind::LumaInverted)), Some((x, MatteKind::LumaInverted))]); + choose(&mut h, &format!("timeline.layer.{a}.trackMatte"), "No Track Matte"); + assert_eq!([matte(&h, a), matte(&h, b)], [None, None]); + // The track matte pick whip: a selected row's sets the selection's, an unselected row's + // its own. + whip(&mut h, &format!("timeline.layer.{a}.trackMattePickWhip"), y); + assert_eq!([matte(&h, a), matte(&h, b), matte(&h, c)], [Some((y, MatteKind::Alpha)), Some((y, MatteKind::Alpha)), None]); + whip(&mut h, &format!("timeline.layer.{c}.trackMattePickWhip"), x); + assert_eq!([matte(&h, a), matte(&h, c)], [Some((y, MatteKind::Alpha)), Some((x, MatteKind::Alpha))]); +} + +fn video(h: &Harness<'_, EffectcraftApp>, id: u64) -> bool { + h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().switches.video +} + +/// A locked layer in the selection stays selected, and keeps its parent, its transform and its +/// matte while another selected row's menus and pick whips change the rest. +#[test] +fn locked_selected_layers_keep_their_parent_and_matte() { + let (mut h, [a, b, c, _, x, _]) = harness(); + select(&mut h, &[a, b]); + // B alone (a click on its Lock switch locks the whole selection). + h.state_mut().session.execute("layer.setSwitch", json!({"layers": [b], "switch": "lock", "value": true})).unwrap(); + h.run_steps(2); + let selected: Vec = h.state().session.state.selected_layers.iter().map(|l| l.0).collect(); + assert!(h.state().session.active_comp().unwrap().layer(LayerId(b)).unwrap().switches.locked && selected == vec![a, b], "{selected:?}"); + let b_position = position(&h, b); + choose(&mut h, &format!("timeline.layer.{a}.parent"), "3. P"); + whip(&mut h, &format!("timeline.layer.{a}.pickWhip"), c); + choose(&mut h, &format!("timeline.layer.{a}.trackMatte"), "1. Y"); + whip(&mut h, &format!("timeline.layer.{a}.trackMattePickWhip"), x); + assert_eq!((parent(&h, a), matte(&h, a)), (Some(c), Some((x, MatteKind::Alpha))), "A changed each time"); + assert_eq!((parent(&h, b), matte(&h, b), position(&h, b)), (None, None, b_position), "locked B didn't"); +} + +/// Selected nulls (no Track Matte on their rows) get no matte from another row's menu or pick +/// whip: at the top of the comp one used to refuse the whole mode change (nothing above it to +/// take as a matte), at the bottom it took the layer above as its matte and hid it. +#[test] +fn selected_nulls_get_no_matte() { + let (mut h, [a, b, _, _, x, y]) = harness(); + h.state_mut().session.execute("layer.setTrackMatte", json!({"layers": [a, b], "matte": x, "kind": "alpha"})).unwrap(); + let n = h.state_mut().session.execute("layer.newNull", json!({"name": "N"})).unwrap()["layer"].as_u64().unwrap(); + select(&mut h, &[a, b, n]); + choose(&mut h, &format!("timeline.layer.{a}.trackMatte"), "Luma Matte"); + assert_eq!([matte(&h, a), matte(&h, b), matte(&h, n)], [Some((x, MatteKind::Luma)), Some((x, MatteKind::Luma)), None], "a null on top"); + h.state_mut().session.execute("layer.arrange", json!({"layers": [n], "to": "back"})).unwrap(); + select(&mut h, &[a, b, n]); + choose(&mut h, &format!("timeline.layer.{a}.trackMatte"), "Alpha Inverted Matte"); + assert_eq!( + [matte(&h, a), matte(&h, b), matte(&h, n)], + [Some((x, MatteKind::AlphaInverted)), Some((x, MatteKind::AlphaInverted)), None], + "a null at the bottom" + ); + assert!(video(&h, a), "A wasn't hidden as the null's matte"); + whip(&mut h, &format!("timeline.layer.{a}.trackMattePickWhip"), y); + assert_eq!( + [matte(&h, a), matte(&h, b), matte(&h, n)], + [Some((y, MatteKind::AlphaInverted)), Some((y, MatteKind::AlphaInverted)), None], + "the pick whip too" + ); +} diff --git a/crates/ui-egui/tests/it/ui_camera_tracker.rs b/crates/ui-egui/tests/it/ui_camera_tracker.rs index 2ab27fd6e..f7d349342 100644 --- a/crates/ui-egui/tests/it/ui_camera_tracker.rs +++ b/crates/ui-egui/tests/it/ui_camera_tracker.rs @@ -171,8 +171,8 @@ fn viewer_zooms_and_pans_over_the_track_points() { let zoom = h.state().ui.viewer.zoom; h.input_mut().events.push(egui::Event::PointerMoved(at)); h.input_mut().events.push(egui::Event::MouseWheel { - unit: egui::MouseWheelUnit::Point, - delta: egui::vec2(0.0, 120.0), + unit: egui::MouseWheelUnit::Line, + delta: egui::vec2(0.0, 3.0), modifiers: Default::default(), phase: egui::TouchPhase::Move, source: egui::MouseWheelSource::Wheel, diff --git a/crates/ui-egui/tests/it/ui_comp_presets.rs b/crates/ui-egui/tests/it/ui_comp_presets.rs index 0b0b30720..9e71f8fd7 100644 --- a/crates/ui-egui/tests/it/ui_comp_presets.rs +++ b/crates/ui-egui/tests/it/ui_comp_presets.rs @@ -83,3 +83,23 @@ fn a_preset_name_cannot_be_a_built_in_one() { assert!(h.state().auto.find("dialog.comp.presetName").is_none()); assert!(preset_names(&h).is_empty()); } + +/// Cancel and OK stay where they are when switching between Basic, Advanced and 3D Renderer, +/// at any interface scale: the tabs' heights are measured, not assumed. +#[test] +fn composition_settings_buttons_stay_put_between_tabs() { + for zoom in [1.0, 1.3] { + let mut h = harness(); + h.ctx.set_zoom_factor(zoom); + h.run_steps(3); + open_settings(&mut h); + let rects = |h: &Harness<'_, EffectcraftApp>| { + ["dialog.comp.ok", "dialog.comp.cancel", "dialog.comp.name"].map(|id| h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).rect) + }; + let basic = rects(&h); + for tab in ["advanced", "renderer", "basic"] { + click(&mut h, &format!("dialog.comp.tab.{tab}")); + assert_eq!(rects(&h), basic, "{tab} at {zoom}x"); + } + } +} diff --git a/crates/ui-egui/tests/it/ui_dialog_preview.rs b/crates/ui-egui/tests/it/ui_dialog_preview.rs new file mode 100644 index 000000000..ce5f2803e --- /dev/null +++ b/crates/ui-egui/tests/it/ui_dialog_preview.rs @@ -0,0 +1,303 @@ +//! Preview in the settings dialogs that edit something that exists (After Effects' Preview +//! checkbox): Solid Settings on a solid layer, Camera Settings, Light Settings and Composition +//! Settings. A change of the draft reaches the project at once, so the canvas shows it while it is +//! made; OK keeps it in one undo step, Cancel and Escape take it back, and with Preview off nothing +//! is applied until OK. The window behind a previewing dialog is not dimmed, and the dialog opens +//! at the lower right; the same dialogs creating something new keep the middle. + +use effectcraft_engine::Session; +use effectcraft_engine::project::{ItemKind, LayerId, Resolution}; +use effectcraft_ui_egui::{Dialog, EffectcraftApp, menus}; +use egui::{Event, Key, Modifiers, PointerButton, Pos2, pos2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::{Value, json}; + +fn harness(setup: impl FnOnce(&mut Session)) -> Harness<'static, EffectcraftApp> { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 10})).unwrap(); + setup(&mut s); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + h +} + +fn open(h: &mut Harness<'_, EffectcraftApp>, id: &str, params: Value) { + let ctx = h.ctx.clone(); + menus::invoke(h.state_mut(), &ctx, id, params).unwrap(); + h.run_steps(3); +} + +fn center(h: &Harness<'_, EffectcraftApp>, id: &str) -> Pos2 { + let r = h.state().auto.find(id).unwrap_or_else(|| panic!("no automation id {id}")).rect; + pos2(r[0] + r[2] / 2.0, r[1] + r[3] / 2.0) +} + +fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) { + let p = center(h, id); + h.input_mut().events.push(Event::PointerMoved(p)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +fn escape(h: &mut Harness<'_, EffectcraftApp>) { + h.input_mut().events.push(Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.input_mut().events.push(Event::Key { key: Key::Escape, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +fn undo_len(h: &Harness<'_, EffectcraftApp>) -> usize { + h.state().session.history.undo.len() +} + +fn solid_size(h: &Harness<'_, EffectcraftApp>, layer: u64) -> [u32; 2] { + let s = &h.state().session; + let l = s.active_comp().unwrap().layer(LayerId(layer)).unwrap(); + let effectcraft_engine::project::LayerSource::Solid { item } = l.source else { panic!("not a solid") }; + match &s.project.item(item).unwrap().kind { + ItemKind::Solid(so) => [so.width, so.height], + k => panic!("{k:?}"), + } +} + +fn scalar(h: &Harness<'_, EffectcraftApp>, layer: u64, path: &str) -> f64 { + let s = &h.state().session; + let l = s.active_comp().unwrap().layer(LayerId(layer)).unwrap(); + l.props.prop(path).unwrap().value_at(l.layer_time(s.time())).as_f64() +} + +fn comp_width(h: &Harness<'_, EffectcraftApp>) -> u32 { + h.state().session.active_comp().unwrap().width +} + +fn solid_setup(s: &mut Session) { + s.execute("layer.newSolid", json!({"name": "Box", "color": "#ff0000", "width": 100, "height": 100})).unwrap(); +} + +fn solid_layer(h: &Harness<'_, EffectcraftApp>) -> u64 { + h.state().session.active_comp().unwrap().layers[0].id.0 +} + +#[test] +fn solid_settings_previews_live_and_cancel_and_escape_take_it_back() { + let mut h = harness(solid_setup); + let l = solid_layer(&h); + let undo = undo_len(&h); + open(&mut h, "layer.settings", json!({"layer": l})); + assert_eq!(h.state().dialog, Some(Dialog::SolidSettings)); + assert!(h.state().auto.find("dialog.solid.preview").is_some(), "a Preview checkbox"); + h.state_mut().dialog_state_mut().solid_size = [200, 150]; + h.run_steps(3); + assert_eq!(solid_size(&h, l), [200, 150], "the canvas shows the new size before OK"); + h.state_mut().dialog_state_mut().solid_size = [240, 120]; + h.run_steps(3); + assert_eq!(solid_size(&h, l), [240, 120]); + assert_eq!(undo_len(&h), undo + 1, "however much was tried, the project is the original plus one command"); + click(&mut h, "dialog.solid.cancel"); + assert_eq!(h.state().dialog, None); + assert_eq!(solid_size(&h, l), [100, 100], "Cancel puts it back"); + assert_eq!(undo_len(&h), undo, "and leaves no undo step"); + // Escape does the same. + open(&mut h, "layer.settings", json!({"layer": l})); + h.state_mut().dialog_state_mut().solid_size = [200, 150]; + h.run_steps(3); + assert_eq!(solid_size(&h, l), [200, 150]); + escape(&mut h); + assert_eq!(h.state().dialog, None); + assert_eq!(solid_size(&h, l), [100, 100]); + assert_eq!(undo_len(&h), undo); +} + +#[test] +fn solid_settings_ok_keeps_the_preview_as_one_undo_step() { + let mut h = harness(solid_setup); + let l = solid_layer(&h); + let undo = undo_len(&h); + open(&mut h, "layer.settings", json!({"layer": l})); + h.state_mut().dialog_state_mut().solid_size = [200, 150]; + h.run_steps(3); + h.state_mut().dialog_state_mut().solid_size = [220, 130]; + h.run_steps(3); + click(&mut h, "dialog.solid.ok"); + assert_eq!(h.state().dialog, None); + assert_eq!(solid_size(&h, l), [220, 130]); + assert_eq!(undo_len(&h), undo + 1, "one undo step"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(solid_size(&h, l), [100, 100], "undone in one go"); +} + +#[test] +fn with_preview_off_nothing_is_applied_until_ok() { + let mut h = harness(solid_setup); + let l = solid_layer(&h); + let undo = undo_len(&h); + open(&mut h, "layer.settings", json!({"layer": l})); + h.state_mut().dialog_state_mut().solid_size = [200, 150]; + h.run_steps(3); + assert_eq!(solid_size(&h, l), [200, 150]); + click(&mut h, "dialog.solid.preview"); + assert!(!h.state().dialog_state().live.on); + assert_eq!(solid_size(&h, l), [100, 100], "switching Preview off takes the preview back"); + assert_eq!(undo_len(&h), undo); + h.state_mut().dialog_state_mut().solid_size = [260, 140]; + h.run_steps(3); + assert_eq!(solid_size(&h, l), [100, 100], "nothing is applied while editing"); + click(&mut h, "dialog.solid.ok"); + assert_eq!(solid_size(&h, l), [260, 140], "OK applies the draft"); + assert_eq!(undo_len(&h), undo + 1); +} + +#[test] +fn camera_settings_preview() { + let mut h = harness(|s| { + s.execute("layer.newCamera", json!({})).unwrap(); + }); + let l = solid_layer(&h); + let before = scalar(&h, l, "cameraOptions/zoom"); + let undo = undo_len(&h); + open(&mut h, "layer.cameraSettings", json!({"layer": l})); + assert_eq!(h.state().dialog, Some(Dialog::CameraSettings)); + assert!(h.state().auto.find("dialog.camera.preview").is_some()); + h.state_mut().dialog_state_mut().camera.zoom = before + 500.0; + h.run_steps(3); + assert!((scalar(&h, l, "cameraOptions/zoom") - (before + 500.0)).abs() < 1e-6, "the zoom changes live"); + assert_eq!(undo_len(&h), undo + 1); + click(&mut h, "dialog.camera.cancel"); + assert!((scalar(&h, l, "cameraOptions/zoom") - before).abs() < 1e-6, "Cancel puts the zoom back"); + assert_eq!(undo_len(&h), undo); + open(&mut h, "layer.cameraSettings", json!({"layer": l})); + h.state_mut().dialog_state_mut().camera.zoom = before + 300.0; + h.run_steps(3); + click(&mut h, "dialog.camera.ok"); + assert!((scalar(&h, l, "cameraOptions/zoom") - (before + 300.0)).abs() < 1e-6, "OK keeps it"); + assert_eq!(undo_len(&h), undo + 1); +} + +#[test] +fn light_settings_preview() { + let mut h = harness(|s| { + s.execute("layer.newLight", json!({"kind": "Point"})).unwrap(); + }); + let l = solid_layer(&h); + let before = scalar(&h, l, "lightOptions/intensity"); + let undo = undo_len(&h); + open(&mut h, "layer.lightSettings", json!({"layer": l})); + assert_eq!(h.state().dialog, Some(Dialog::LightSettings)); + assert!(h.state().auto.find("dialog.light.preview").is_some()); + h.state_mut().dialog_state_mut().light.intensity = before + 37.0; + h.run_steps(3); + assert!((scalar(&h, l, "lightOptions/intensity") - (before + 37.0)).abs() < 1e-6, "the intensity changes live"); + escape(&mut h); + assert_eq!(h.state().dialog, None); + assert!((scalar(&h, l, "lightOptions/intensity") - before).abs() < 1e-6, "Escape puts it back"); + assert_eq!(undo_len(&h), undo); +} + +#[test] +fn composition_settings_preview() { + let mut h = harness(|_| {}); + let before = comp_width(&h); + let undo = undo_len(&h); + open(&mut h, "comp.settings", json!({})); + assert_eq!(h.state().dialog, Some(Dialog::CompSettings)); + assert!(h.state().auto.find("dialog.comp.preview").is_some()); + h.state_mut().dialog_state_mut().comp.width = before + 80; + h.run_steps(3); + assert_eq!(comp_width(&h), before + 80, "the comp is resized live"); + click(&mut h, "dialog.comp.cancel"); + assert_eq!(comp_width(&h), before, "Cancel puts the size back"); + assert_eq!(undo_len(&h), undo); + open(&mut h, "comp.settings", json!({})); + h.state_mut().dialog_state_mut().comp.width = before + 40; + h.run_steps(3); + click(&mut h, "dialog.comp.ok"); + assert_eq!(comp_width(&h), before + 40, "OK keeps it"); + assert_eq!(undo_len(&h), undo + 1, "in one undo step"); +} + +/// The Resolution is no part of the Composition Settings command, so a preview never applies it: +/// OK has to, also when it keeps a preview of another field. +#[test] +fn composition_settings_ok_after_a_preview_still_applies_the_resolution() { + let mut h = harness(|_| {}); + let before = comp_width(&h); + open(&mut h, "comp.settings", json!({})); + h.state_mut().dialog_state_mut().comp.width = before + 80; + h.run_steps(3); + assert_eq!(comp_width(&h), before + 80, "the preview applied the width"); + h.state_mut().dialog_state_mut().comp.resolution = Resolution::Half; + h.run_steps(3); + click(&mut h, "dialog.comp.ok"); + assert_eq!(comp_width(&h), before + 80); + assert_eq!(h.state().session.active_comp().unwrap().resolution, Resolution::Half, "OK applied the Resolution"); +} + +/// An edit made in the same frame as OK (Enter) is not in the last preview yet: OK brings the +/// project up to the draft it finds instead of keeping the older preview. +#[test] +fn ok_in_the_frame_of_an_edit_keeps_the_edit_not_the_older_preview() { + let mut h = harness(solid_setup); + let l = solid_layer(&h); + open(&mut h, "layer.settings", json!({"layer": l})); + h.state_mut().dialog_state_mut().solid_size = [200, 150]; + h.run_steps(3); + assert_eq!(solid_size(&h, l), [200, 150]); + h.state_mut().dialog_state_mut().solid_size = [240, 120]; + h.input_mut().events.push(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.step(); + h.run_steps(2); + assert_eq!(h.state().dialog, None); + assert_eq!(solid_size(&h, l), [240, 120], "OK kept the draft it found, not the earlier preview"); +} + +/// A previewing dialog opens at the lower right so the canvas stays in view; the same dialogs +/// creating something new keep the middle and have no Preview. +#[test] +fn previewing_dialogs_open_at_the_lower_right_and_new_ones_in_the_middle() { + let mut h = harness(solid_setup); + let l = solid_layer(&h); + open(&mut h, "layer.settings", json!({"layer": l})); + let ok = center(&h, "dialog.solid.ok"); + assert!(ok.x > 800.0 && ok.y > 500.0, "Solid Settings on a layer is in the lower right: {ok:?}"); + click(&mut h, "dialog.solid.cancel"); + open(&mut h, "layer.newSolid", json!({})); + assert_eq!(h.state().dialog, Some(Dialog::SolidSettings)); + assert!(h.state().auto.find("dialog.solid.preview").is_none(), "a new solid has no Preview"); + let ok = center(&h, "dialog.solid.ok"); + assert!(ok.x < 1000.0, "New Solid stays in the middle: {ok:?}"); +} + +fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) { + h.input_mut().events.push(Event::PointerMoved(from)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + for k in 1..=6 { + h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (k as f32 / 6.0))); + h.step(); + } + h.input_mut().events.push(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +/// A previewing dialog opens at the lower right, but it can be dragged by its title to wherever +/// the canvas needs to be seen, and the preview keeps working from there. +#[test] +fn a_previewing_dialog_can_be_dragged_by_its_title() { + let mut h = harness(solid_setup); + let l = solid_layer(&h); + open(&mut h, "layer.settings", json!({"layer": l})); + let ok = center(&h, "dialog.solid.ok"); + let title = h.get_by_label("Solid Settings").rect().center(); + drag(&mut h, title, title + egui::vec2(-500.0, -300.0)); + let moved = center(&h, "dialog.solid.ok"); + assert!(moved.x < ok.x - 300.0 && moved.y < ok.y - 150.0, "the dialog followed the drag: {ok:?} -> {moved:?}"); + // Still previewing, and the position is kept from frame to frame. + h.state_mut().dialog_state_mut().solid_size = [200, 150]; + h.run_steps(3); + assert_eq!(solid_size(&h, l), [200, 150]); + assert_eq!(center(&h, "dialog.solid.ok"), moved, "it stays where it was put"); +} diff --git a/crates/ui-egui/tests/it/ui_drag_drop.rs b/crates/ui-egui/tests/it/ui_drag_drop.rs index e405bf777..5608dfcf3 100644 --- a/crates/ui-egui/tests/it/ui_drag_drop.rs +++ b/crates/ui-egui/tests/it/ui_drag_drop.rs @@ -124,6 +124,44 @@ fn alt_dragging_a_project_item_replaces_the_layer_source() { assert_eq!(layer.name, "Top", "the layer keeps its name"); } +/// Alt/Option-dropping a Project item on the Composition viewer replaces the source of the layer +/// under the pointer, as in the Timeline, instead of adding a layer (#2173). +#[test] +fn alt_dropping_a_project_item_on_the_viewer_replaces_the_layer_source() { + use effectcraft_engine::project::{ItemId, LayerId, LayerSource}; + let (mut h, clip) = harness(); + let item = rect(&h, &format!("project.item.{clip}.name")).center(); + let to = comp_to_screen(&h.ctx, [160.0, 90.0]).unwrap(); + // The three solids overlap completely, so the topmost one is under the pointer. + let (top, middle, bottom) = (layer_id(&h, "Top"), layer_id(&h, "Middle"), layer_id(&h, "Bottom")); + let source = |h: &Harness<'_, EffectcraftApp>, id: u64| h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().source.clone(); + let (middle_before, bottom_before) = (source(&h, middle), source(&h, bottom)); + let before = stack(&h); + drag(&mut h, item, to, Modifiers::ALT); + assert_eq!(stack(&h), before, "no layer is added"); + assert_eq!(source(&h, top), LayerSource::Comp { item: ItemId(clip) }, "the layer under the pointer takes the new source"); + assert_eq!(source(&h, middle), middle_before, "the layer below it keeps its source"); + assert_eq!(source(&h, bottom), bottom_before, "the bottom layer keeps its source"); +} + +/// A locked layer under the pointer refuses the Alt-drop; the layer behind it is not replaced +/// instead (#2173). +#[test] +fn alt_dropping_on_a_locked_layer_in_the_viewer_replaces_nothing() { + use effectcraft_engine::project::LayerId; + let (mut h, clip) = harness(); + let item = rect(&h, &format!("project.item.{clip}.name")).center(); + let to = comp_to_screen(&h.ctx, [160.0, 90.0]).unwrap(); + let top = layer_id(&h, "Top"); + h.state_mut().session.execute("layer.setSwitch", json!({"layers": [top], "switch": "locked", "value": true})).unwrap(); + h.run_steps(3); + let sources = |h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layers.iter().map(|l| l.source.clone()).collect::>(); + let before = sources(&h); + drag(&mut h, item, to, Modifiers::ALT); + assert_eq!(sources(&h), before, "no layer takes the new source"); + assert!(h.state().session.active_comp().unwrap().layer(LayerId(top)).unwrap().switches.locked); +} + /// Ctrl+Alt+/ (Cmd+Opt+/ on macOS) replaces every selected layer's source with the Project panel /// selection (#991). #[test] @@ -198,7 +236,7 @@ fn dragging_selected_project_items_adds_every_item_to_the_viewer() { let to = comp_to_screen(&h.ctx, [80.0, 45.0]).unwrap(); drag(&mut h, from, to, Modifiers::NONE); assert_eq!(stack(&h), ["Clip", "Other", "Top", "Middle", "Bottom"]); - for layer in &h.state().session.active_comp().unwrap().layers[..2] { + for layer in h.state().session.active_comp().unwrap().layers.iter().take(2) { let Some(KV::Vec3(p)) = layer.props.prop("transform/position").map(|p| p.value.clone()) else { panic!("no position") }; let tol = 1.0 / last_fit(&h.ctx) as f64 + 1e-6; assert!((p[0] - 80.0).abs() <= tol && (p[1] - 45.0).abs() <= tol, "{p:?}"); @@ -594,6 +632,27 @@ fn import_dialog_offers_the_image_sequence_option() { let _ = std::fs::remove_dir_all(&dir); } +/// #2573: the empty Composition viewer's New Composition From Footage imports the picked file +/// (through the Import Image Sequence question a numbered still asks) and makes a composition +/// of it, as in After Effects. +#[test] +fn new_composition_from_footage_makes_a_comp_of_the_picked_file() { + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(Session::default())); + h.run_steps(3); + let dir = frames("fromfootage", 2); + let pick = dir.join("frame_0002.png").to_string_lossy().to_string(); + h.state_mut().session.importer = Some(Arc::new(Stills)); + h.state_mut().hooks.pick_files = Some(Box::new(move |_: &[&str]| vec![pick.clone()])); + click_id(&mut h, "viewer.empty.import"); + assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::Form)); + click_id(&mut h, "form.ok"); + step_until(&mut h, |h| h.state().session.active_comp().is_some()); + let comp = h.state().session.active_comp().unwrap(); + assert_eq!((comp.width, comp.height), (64, 32)); + assert_eq!(comp.layers.iter().map(|l| l.name.as_str()).collect::>(), ["frame_0002.png"]); + let _ = std::fs::remove_dir_all(&dir); +} + /// #297: a folder of frames dropped on the window arrives as one image sequence, and Interpret /// Footage shows its frames and Start Timecode (missing frames always show colour bars, as in /// After Effects: there is no Missing Frames choice). #431: dropped files import as stills, one diff --git a/crates/ui-egui/tests/it/ui_env_light_gpu.rs b/crates/ui-egui/tests/it/ui_env_light_gpu.rs new file mode 100644 index 000000000..171cc5728 --- /dev/null +++ b/crates/ui-egui/tests/it/ui_env_light_gpu.rs @@ -0,0 +1,79 @@ +//! The Environment light through the app's GPU compositor (egui_kittest with wgpu): adding one +//! visibly changes the viewer's GPU frames (#2436). Skips without a GPU adapter (CI). + +use effectcraft_engine::Session; +use effectcraft_engine::project::Renderer; +use effectcraft_ui_egui::EffectcraftApp; +use egui_kittest::Harness; +use serde_json::json; + +fn settle(h: &mut Harness<'_, EffectcraftApp>) { + for _ in 0..600 { + h.step(); + if h.state().frames.inflight() == 0 && h.state().frames.last_ms.lock().map(|v| *v > 0.0).unwrap_or(false) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + for _ in 0..4 { + h.step(); + } +} + +/// egui_kittest's wgpu setup on the adapter the headless compositor picked, so the viewer's GPU +/// compositor attaches (see ui_gpu.rs). +fn same_adapter(name: String) -> eframe::egui_wgpu::WgpuSetup { + let mut setup = egui_kittest::wgpu::default_wgpu_setup(); + if let eframe::egui_wgpu::WgpuSetup::CreateNew(c) = &mut setup { + let fallback = c.native_adapter_selector.take(); + c.native_adapter_selector = Some(std::sync::Arc::new(move |adapters, surface| { + let named = |a: &&eframe::wgpu::Adapter| format!("{} ({:?})", a.get_info().name, a.get_info().backend) == name; + match (adapters.iter().find(named), &fallback) { + (Some(a), _) => Ok(a.clone()), + (None, Some(f)) => f(adapters, surface), + (None, None) => adapters.first().cloned().ok_or_else(|| "No adapter found".to_owned()), + } + })); + } + setup +} + +/// A red Environment light at full intensity lights the scene on the GPU compositor too: the +/// viewer's GPU frames change when the light is added. +#[test] +fn environment_light_lights_the_scene_on_the_gpu() { + let Some(probe) = effectcraft_gpu::Gpu::headless() else { + eprintln!("no GPU adapter: skipping"); + return; + }; + let adapter = effectcraft_engine::render::Accelerator::name(&probe); + eprintln!("headless compositor adapter: {adapter}"); + let gl = adapter.ends_with("(Gl)"); + drop(probe); + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + s.execute("comp.open", json!({"comp": "3D Showcase"})).unwrap(); + s.execute("time.set", json!({"time": 5.0})).unwrap(); + s.execute("comp.settings", json!({"renderer": "classic3D"})).unwrap(); + let mut h = Harness::builder() + .with_size(egui::vec2(1400.0, 900.0)) + .with_pixels_per_point(1.0) + .wgpu_setup(same_adapter(adapter)) + .build_eframe(|_| EffectcraftApp::new(s)); + settle(&mut h); + if gl && h.state().gpu_adapter().is_none() { + eprintln!("egui-wgpu's GL device has WebGL2 limits: the viewer stays on the CPU, skipping"); + return; + } + assert!(h.state().gpu_adapter().is_some(), "GPU compositor on egui-wgpu's device"); + let before = h.state_mut().viewer_pixels().expect("GPU frame before adding the light").pixels.clone(); + + h.state_mut().session.execute("layer.newLight", json!({"kind": "Environment", "color": [1.0, 0.0, 0.0], "intensity": 100.0})).unwrap(); + assert_eq!(h.state().session.active_comp().unwrap().renderer, Renderer::Advanced3D); + settle(&mut h); + let after = h.state_mut().viewer_pixels().expect("GPU frame after adding the light").pixels.clone(); + + assert_eq!(before.len(), after.len()); + let diff = before.iter().zip(&after).filter(|(a, b)| a != b).count(); + assert!(diff > 500, "a red Environment light at 100 % changed only {diff} GPU pixels"); +} diff --git a/crates/ui-egui/tests/it/ui_expression_values.rs b/crates/ui-egui/tests/it/ui_expression_values.rs new file mode 100644 index 000000000..b797e6cf6 --- /dev/null +++ b/crates/ui-egui/tests/it/ui_expression_values.rs @@ -0,0 +1,296 @@ +//! #1306 (from #1072): a property driven by an expression shows its value in red, the expression +//! colour, in the Timeline, Effect Controls and the Properties panel, as in After Effects: scalars, +//! every part of a vector (with its separator) and both parts of an angle (with its sign), hovered +//! and dragged too, in dark and light themes. A disabled, removed or empty expression shows the +//! ordinary colour again. The tests read the colour the value text is painted with (the frame's +//! text shapes: the numbers are painted with an override colour, the separator and sign as plain +//! text), and require every expected part to be there. + +use effectcraft_engine::Session; +use effectcraft_engine::project::{LayerId, PropGroup}; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::dock::PanelKind; +use effectcraft_ui_egui::theme::ThemeKind; +use egui::epaint::Shape; +use egui::{Color32, Event, Modifiers, PointerButton, Pos2, Rect, pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +const TIMELINE: &str = "timeline.prop"; +const EFFECT_CONTROLS: &str = "effectControls.prop"; +const PROPERTIES: &str = "properties.prop"; + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Kind { + /// Two components (X, Y). + Vector, + /// Revolutions, sign and degrees. + Angle, + Scalar, +} + +/// A property whose value the tests check, and the panels that must show it. +#[derive(Clone, Copy, Debug)] +struct Checked { + uid: u64, + kind: Kind, + panels: &'static [&'static str], +} + +/// A comp with one solid and the Transform effect on it. +struct Ids { + layer: u64, + /// Transform Position, Rotation and Opacity (Timeline and Properties panel), and the Transform + /// effect's Position, Rotation and Opacity (Effect Controls). + driven: [Checked; 6], + /// Transform Scale: no expression. + scale: Checked, +} + +fn setup() -> (Harness<'static, EffectcraftApp>, Ids) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "E", "width": 320, "height": 180, "duration": 4})).unwrap(); + let l = s.execute("layer.newSolid", json!({"color": "#406080"})).unwrap()["layer"].as_u64().unwrap(); + let fx = s.execute("effect.apply", json!({"layers": [l], "effect": "Transform"})).unwrap()["effects"][0].as_u64().unwrap(); + let layer = s.active_comp().unwrap().layer(LayerId(l)).unwrap().clone(); + let uid = |path: &str| layer.props.prop(path).unwrap_or_else(|| panic!("no {path}")).uid; + let g: &PropGroup = layer.props.find_group(fx).unwrap(); + let fuid = |m: &str| g.get(m).unwrap_or_else(|| panic!("no effect {m}")).uid; + const LAYER: &[&str] = &[TIMELINE, PROPERTIES]; + const EFFECT: &[&str] = &[EFFECT_CONTROLS]; + let c = |uid, kind, panels| Checked { uid, kind, panels }; + let ids = Ids { + layer: l, + driven: [ + c(uid("transform/position"), Kind::Vector, LAYER), + c(uid("transform/rotation"), Kind::Angle, LAYER), + c(uid("transform/opacity"), Kind::Scalar, LAYER), + c(fuid("position"), Kind::Vector, EFFECT), + c(fuid("rotation"), Kind::Angle, EFFECT), + c(fuid("opacity"), Kind::Scalar, EFFECT), + ], + scale: c(uid("transform/scale"), Kind::Vector, LAYER), + }; + let transform = layer.props.sub("transform").unwrap().uid; + s.state.selected_layers = vec![LayerId(l)]; + let mut app = EffectcraftApp::new(s); + app.show_panel(PanelKind::EffectControls); + app.ui.timeline.open_layers.insert(l); + app.ui.timeline.open_groups.insert(transform); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| app); + h.run_steps(4); + (h, ids) +} + +fn set_expression(h: &mut Harness<'_, EffectcraftApp>, ids: &Ids, prop: u64, p: &serde_json::Value) { + let mut p = p.clone(); + p["layer"] = json!(ids.layer); + p["prop"] = json!(prop); + h.state_mut().session.execute("prop.setExpression", p).unwrap(); +} + +fn set_theme(h: &mut Harness<'_, EffectcraftApp>, theme: ThemeKind) { + let ctx = h.ctx.clone(); + h.state_mut().set_theme(&ctx, theme); + h.run_steps(3); +} + +fn rect_of(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}: the part isn't shown")); + Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3])) +} + +/// The area of `c`'s value in `panel`, every expected part required: both components of a vector, +/// the revolutions and degrees of an angle (the separator and sign lie between them). +fn value_area(h: &Harness<'_, EffectcraftApp>, panel: &str, c: Checked) -> Rect { + let uid = c.uid; + match c.kind { + Kind::Vector => rect_of(h, &format!("{panel}.{uid}.value.0")).union(rect_of(h, &format!("{panel}.{uid}.value.1"))), + Kind::Angle => rect_of(h, &format!("{panel}.{uid}.revolutions")).union(rect_of(h, &format!("{panel}.{uid}.value"))), + Kind::Scalar => rect_of(h, &format!("{panel}.{uid}.value")), + } +} + +/// The text painted inside `area` in the last frame, with its colour. +fn texts(h: &Harness<'_, EffectcraftApp>, area: Rect) -> Vec<(String, Color32)> { + fn walk(s: &Shape, area: Rect, out: &mut Vec<(String, Color32)>) { + match s { + Shape::Vec(v) => v.iter().for_each(|s| walk(s, area, out)), + Shape::Text(t) if area.expand(1.0).contains_rect(t.visual_bounding_rect()) && !t.galley.text().trim().is_empty() => { + out.push((t.galley.text().trim().to_string(), t.override_text_color.unwrap_or(t.fallback_color))); + } + _ => {} + } + } + let mut out = vec![]; + for c in &h.output().shapes { + walk(&c.shape, area, &mut out); + } + out +} + +/// `c`'s value in `panel` shows every part, all in `want`: two numbers and (in the Timeline) the +/// `,` of a vector, the revolutions, sign and degrees of an angle, the number of a scalar. +fn assert_value(h: &Harness<'_, EffectcraftApp>, panel: &str, c: Checked, want: Color32, what: &str) { + let shown = texts(h, value_area(h, panel, c)); + let numbers = shown.iter().filter(|(s, _)| s.chars().any(|ch| ch.is_ascii_digit())).count(); + let has = |text: &str| shown.iter().any(|(s, _)| s == text); + let ctx = format!("{what}: {:?} @{} in {panel}: {shown:?}", c.kind, c.uid); + match c.kind { + Kind::Vector => { + assert!(numbers >= 2, "{ctx}: both components"); + if panel == TIMELINE { + assert!(has(","), "{ctx}: the separator"); + } + } + Kind::Angle => { + assert!(numbers >= 2, "{ctx}: revolutions and degrees"); + assert!(has("+") || has("-"), "{ctx}: the sign"); + } + Kind::Scalar => assert!(numbers >= 1, "{ctx}: the number"), + } + assert!(shown.iter().all(|(_, col)| *col == want), "{ctx}: want {want:?}"); +} + +/// Every checked property in every panel it belongs to. +fn assert_all(h: &Harness<'_, EffectcraftApp>, checked: &[Checked], want: Color32, what: &str) { + for c in checked { + for panel in c.panels { + assert_value(h, panel, *c, want, what); + } + } +} + +/// Expression-driven values are red in every panel, in both themes; Scale (none) stays blue. +#[test] +fn expression_driven_values_are_red_in_every_panel() { + let (mut h, ids) = setup(); + for c in ids.driven { + set_expression(&mut h, &ids, c.uid, &json!({"expression": "value"})); + } + for theme in [ThemeKind::Dark, ThemeKind::Light] { + set_theme(&mut h, theme); + let t = h.state().tokens; + assert_ne!(t.expr_text, t.hot_text); + assert_all(&h, &ids.driven, t.expr_text, &format!("{theme:?} expression")); + assert_all(&h, &[ids.scale], t.hot_text, &format!("{theme:?} no expression")); + } +} + +/// Disabled, removed or whitespace-only: the ordinary colour; enabled again: red. Every part of +/// every value is checked again after each change, in both themes. +#[test] +fn expression_state_changes_switch_the_colour() { + let (mut h, ids) = setup(); + let states = [ + (json!({"expression": "value"}), true, "enabled"), + (json!({"enabled": false}), false, "disabled"), + (json!({"enabled": true}), true, "enabled again"), + (json!({"expression": ""}), false, "removed"), + (json!({"expression": "value"}), true, "set again"), + (json!({"expression": " "}), false, "whitespace only"), + ]; + for theme in [ThemeKind::Dark, ThemeKind::Light] { + set_theme(&mut h, theme); + let t = h.state().tokens; + for (p, red, what) in &states { + for c in ids.driven { + set_expression(&mut h, &ids, c.uid, p); + } + h.run_steps(3); + let want = if *red { t.expr_text } else { t.hot_text }; + assert_all(&h, &ids.driven, want, &format!("{theme:?} {what}")); + assert_all(&h, &[ids.scale], t.hot_text, &format!("{theme:?} {what}: Scale")); + } + } +} + +fn hover(h: &mut Harness<'_, EffectcraftApp>, at: Pos2) { + h.input_mut().events.push(Event::PointerMoved(at)); + h.run_steps(2); +} + +/// Hovering a value (an ordinary one takes the hover accent) keeps an expression-driven one red, +/// in every panel and both themes. +#[test] +fn a_hovered_expression_value_stays_red() { + let (mut h, ids) = setup(); + let opacity = ids.driven.iter().filter(|c| c.kind == Kind::Scalar).copied().collect::>(); + for c in &opacity { + set_expression(&mut h, &ids, c.uid, &json!({"expression": "value"})); + } + h.run_steps(3); + for theme in [ThemeKind::Dark, ThemeKind::Light] { + set_theme(&mut h, theme); + let t = h.state().tokens; + for c in &opacity { + for panel in c.panels { + let area = value_area(&h, panel, *c); + hover(&mut h, area.center()); + let at = h.ctx.input(|i| i.pointer.hover_pos()); + assert!(at.is_some_and(|p| area.contains(p)), "{theme:?} {panel}: the pointer is over the value"); + assert_value(&h, panel, *c, t.expr_text, &format!("{theme:?} hovered")); + } + } + // An ordinary value does take the hover accent. + let area = value_area(&h, TIMELINE, ids.scale); + let x = rect_of(&h, &format!("{TIMELINE}.{}.value.0", ids.scale.uid)).center(); + hover(&mut h, x); + let shown = texts(&h, area); + assert!(shown.iter().any(|(_, c)| *c == t.accent_hover), "{theme:?} an ordinary hovered value: {shown:?}"); + hover(&mut h, pos2(5.0, 5.0)); + } +} + +/// Dragging (scrubbing) an expression-driven value keeps it red while the drag is on, in the +/// Timeline and Effect Controls. +#[test] +fn a_dragged_expression_value_stays_red() { + let (mut h, ids) = setup(); + let opacity = ids.driven.iter().filter(|c| c.kind == Kind::Scalar).copied().collect::>(); + for c in &opacity { + set_expression(&mut h, &ids, c.uid, &json!({"expression": "value"})); + } + h.run_steps(3); + let t = h.state().tokens; + for (c, panel) in [(opacity[0], TIMELINE), (opacity[1], EFFECT_CONTROLS)] { + let at = value_area(&h, panel, c).center(); + 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(); + for k in 1..=4 { + h.input_mut().events.push(Event::PointerMoved(at + vec2(3.0 * k as f32, 0.0))); + h.step(); + } + assert!(h.ctx.dragged_id().is_some(), "{panel}: a drag is on"); + assert_value(&h, panel, c, t.expr_text, &format!("{panel} dragged")); + let end = at + vec2(12.0, 0.0); + h.input_mut().events.push(Event::PointerButton { pos: end, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + hover(&mut h, pos2(5.0, 5.0)); + } +} + +/// Separate Dimensions: X Position and Y Position are separate scalars, so an expression on X Position +/// turns only X red, in the Timeline and Properties panel and both themes; Y Position stays blue. +#[test] +fn separated_position_colours_each_dimension_on_its_own() { + let (mut h, ids) = setup(); + h.state_mut().session.execute("prop.separateDimensions", json!({"layer": ids.layer, "value": true})).unwrap(); + let uid = |h: &Harness<'_, EffectcraftApp>, path: &str| { + let s = &h.state().session; + s.active_comp().unwrap().layer(LayerId(ids.layer)).unwrap().props.prop(path).unwrap_or_else(|| panic!("no {path}")).uid + }; + const LAYER: &[&str] = &[TIMELINE, PROPERTIES]; + let x = Checked { uid: uid(&h, "transform/positionX"), kind: Kind::Scalar, panels: LAYER }; + let y = Checked { uid: uid(&h, "transform/positionY"), kind: Kind::Scalar, panels: LAYER }; + set_expression(&mut h, &ids, x.uid, &json!({"expression": "value"})); + h.run_steps(3); + for theme in [ThemeKind::Dark, ThemeKind::Light] { + set_theme(&mut h, theme); + let t = h.state().tokens; + assert_all(&h, &[x], t.expr_text, &format!("{theme:?} X Position (expression)")); + assert_all(&h, &[y], t.hot_text, &format!("{theme:?} Y Position (none)")); + } +} diff --git a/crates/ui-egui/tests/it/ui_font_search.rs b/crates/ui-egui/tests/it/ui_font_search.rs index 141d8ec58..77984cf6b 100644 --- a/crates/ui-egui/tests/it/ui_font_search.rs +++ b/crates/ui-egui/tests/it/ui_font_search.rs @@ -256,3 +256,24 @@ fn font_menu_grows_back_after_an_empty_search() { click(&mut h, "character.font.option.JetBrains Mono"); assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().font, "JetBrains Mono", "a click on a row picks the font"); } + +/// #1089: the font menu can re-run the system scan without restarting the app, and the press +/// does not also flip the "Variable fonts only" filter. +#[test] +fn font_menu_rescan_button_adds_fonts_installed_later() { + let mut s = Session::default(); + s.execute("comp.new", json!({"width": 320, "height": 180})).unwrap(); + s.execute("layer.newText", json!({"text": "Keep this text", "font": "Inter"})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(move |_| EffectcraftApp::new(s)); + h.state_mut().show_panel(PanelKind::Character); + h.run_steps(3); + click(&mut h, "character.font"); + h.run_steps(3); + assert!(h.state().auto.find("character.font.rescan").is_some(), "the filter row offers a rescan"); + click(&mut h, "character.font.rescan"); + assert!(h.state().ui.status.contains("Rescan Fonts"), "the status reports the rescan: {}", h.state().ui.status); + // The popup stays open, the variable filter did not flip, and the list still has the bundled families. + assert!(h.state().auto.find("character.font.search").is_some(), "the popup stays open"); + let rows = h.state().auto.query("character.font.option."); + assert!(rows.iter().any(|r| r.label == "Inter"), "{:?}", rows.iter().map(|r| &r.label).collect::>()); +} diff --git a/crates/ui-egui/tests/it/ui_gradient_editor.rs b/crates/ui-egui/tests/it/ui_gradient_editor.rs index fa8c7b73f..f5ee8634b 100644 --- a/crates/ui-egui/tests/it/ui_gradient_editor.rs +++ b/crates/ui-egui/tests/it/ui_gradient_editor.rs @@ -197,7 +197,8 @@ fn stop_edits_ok_sets_the_gradient_in_one_undo_step() { let from = center(&h, "dialog.gradient.opacityStop.1"); let to = along(&h, "dialog.gradient.opacityStop.1", 0.5); drag(&mut h, from, to); - assert_eq!(undo_len(&h), undo, "nothing is set while editing"); + assert_eq!(undo_len(&h), undo + 1, "the live preview is one merged undo step while editing"); + assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 3, "the property shows the edit before OK"); click(&mut h, "dialog.gradient.ok"); assert_eq!(h.state().dialog, None); let g = gradient(&colors_prop(&h, l, colors).value); @@ -403,11 +404,13 @@ fn location_moves_past_a_neighbour_and_the_fields_follow_the_stop() { assert!(!shown.iter().any(|v| (v - 70.0).abs() < 1.0), "{shown:?}"); } -/// A layer deleted while the editor is open: OK reports it, without a crash. +/// A layer deleted while the editor is open, Preview off: OK reports the failed set, without a +/// crash. #[test] fn a_layer_deleted_while_editing_is_reported() { let (mut h, l, colors) = setup(&[]); open_editor(&mut h, colors); + click(&mut h, "dialog.gradient.preview"); let p = along(&h, "dialog.gradient.addColor", 0.5); click_at(&mut h, p); h.state_mut().session.execute("edit.clear", json!({"layers": [l]})).unwrap(); @@ -417,6 +420,98 @@ fn a_layer_deleted_while_editing_is_reported() { assert!(!h.state().ui.status.is_empty(), "the failed set is reported"); } +/// The same with Preview on: the preview's set fails at the next edit and says so, and OK closes +/// the editor without a crash. +#[test] +fn a_layer_deleted_during_the_preview_is_reported_at_the_next_edit() { + let (mut h, l, colors) = setup(&[]); + open_editor(&mut h, colors); + h.state_mut().session.execute("edit.clear", json!({"layers": [l]})).unwrap(); + h.run_steps(2); + let p = along(&h, "dialog.gradient.addColor", 0.5); + click_at(&mut h, p); + assert!(!h.state().ui.status.is_empty(), "the failed preview set is reported"); + click(&mut h, "dialog.gradient.ok"); + assert_eq!(h.state().dialog, None); +} + +/// Preview (on by default, as in After Effects): edits reach the property as they are made, in one +/// undo step, and Cancel and Escape roll them back to how the editor opened. +#[test] +fn the_preview_applies_edits_live_and_cancel_rolls_them_back() { + let (mut h, l, colors) = setup(&[]); + let before = colors_prop(&h, l, colors).value.clone(); + let undo = undo_len(&h); + open_editor(&mut h, colors); + assert!(h.state().gradient_draft().preview, "Preview starts on"); + let p = along(&h, "dialog.gradient.addColor", 0.25); + click_at(&mut h, p); + assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 3, "the new stop shows at once"); + let p = along(&h, "dialog.gradient.addColor", 0.75); + click_at(&mut h, p); + assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 4); + assert_eq!(undo_len(&h), undo + 1, "both edits are one undo step"); + click(&mut h, "dialog.gradient.cancel"); + assert_eq!(h.state().dialog, None); + assert_eq!(colors_prop(&h, l, colors).value, before, "Cancel puts the gradient back"); + assert_eq!(undo_len(&h), undo, "and leaves no undo step"); + // Escape does the same. + open_editor(&mut h, colors); + let p = along(&h, "dialog.gradient.addColor", 0.5); + click_at(&mut h, p); + assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 3); + key(&mut h, Key::Escape); + assert_eq!(h.state().dialog, None); + assert_eq!(colors_prop(&h, l, colors).value, before); + assert_eq!(undo_len(&h), undo); +} + +/// An edit made in the same frame as OK is not in the last preview: OK sets the draft it finds. +#[test] +fn ok_in_the_frame_of_an_edit_sets_the_draft_not_the_older_preview() { + let (mut h, l, colors) = setup(&[]); + open_editor(&mut h, colors); + let p = along(&h, "dialog.gradient.addColor", 0.25); + click_at(&mut h, p); + assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 3, "the preview has three stops"); + h.state_mut().gradient_draft_mut().add(Track::Color, 0.75); + let ctx = h.ctx.clone(); + effectcraft_ui_egui::panels::gradient_editor::finish(h.state_mut(), true, &ctx); + assert_eq!(h.state().dialog, None); + assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 4, "OK sets the draft it found"); +} + +/// With Preview off nothing is set until OK, and switching it off takes back what it applied. +#[test] +fn with_preview_off_nothing_is_set_until_ok() { + let (mut h, l, colors) = setup(&[]); + let before = colors_prop(&h, l, colors).value.clone(); + let undo = undo_len(&h); + open_editor(&mut h, colors); + let p = along(&h, "dialog.gradient.addColor", 0.25); + click_at(&mut h, p); + assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 3, "previewed"); + click(&mut h, "dialog.gradient.preview"); + assert!(!h.state().gradient_draft().preview); + assert_eq!(colors_prop(&h, l, colors).value, before, "switching Preview off takes the preview back"); + assert_eq!(undo_len(&h), undo); + let p = along(&h, "dialog.gradient.addColor", 0.75); + click_at(&mut h, p); + assert_eq!(colors_prop(&h, l, colors).value, before, "nothing is set while editing"); + click(&mut h, "dialog.gradient.ok"); + assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 4, "OK sets the draft"); + assert_eq!(undo_len(&h), undo + 1, "in one undo step"); +} + +/// The editor opens at the lower right of the window, so the canvas it edits stays in view. +#[test] +fn the_editor_opens_at_the_lower_right() { + let (mut h, _, colors) = setup(&[]); + open_editor(&mut h, colors); + let ok = center(&h, "dialog.gradient.ok"); + assert!(ok.x > 800.0 && ok.y > 500.0, "the editor's OK button is in the lower right of the 1600 × 1000 window: {ok:?}"); +} + /// `prop.editGradient` by id (agents, MCP, the control channel) is an error for a target that /// isn't a gradient property, from the engine and through the UI, and opens nothing. #[test] diff --git a/crates/ui-egui/tests/it/ui_layer_panel_zoom.rs b/crates/ui-egui/tests/it/ui_layer_panel_zoom.rs index 1aabdd2bc..73ed95cbd 100644 --- a/crates/ui-egui/tests/it/ui_layer_panel_zoom.rs +++ b/crates/ui-egui/tests/it/ui_layer_panel_zoom.rs @@ -107,8 +107,8 @@ fn wheel_zooms_around_the_pointer_and_hand_tool_pans() { 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), + unit: egui::MouseWheelUnit::Line, + delta: vec2(0.0, 3.0), modifiers: Default::default(), phase: egui::TouchPhase::Move, source: egui::MouseWheelSource::Wheel, @@ -176,3 +176,33 @@ fn roto_brush_in_the_composition_panel_says_where_it_works() { assert_eq!(h.state().ui.layer_panel, Some(box_id(&h))); assert_eq!(h.state().ui.focused, PanelKind::Layer); } + +/// A two-finger trackpad scroll pans the layer with any tool and a pinch zooms around the pointer. +#[test] +fn trackpad_scroll_pans_and_pinch_zooms_around_the_pointer() { + let mut h = harness(); + let canvas = rect(&h, "layerPanel.canvas"); + 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(60.0, -30.0), + modifiers: Default::default(), + phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Unknown, + }); + h.run_steps(30); + let panned = rect(&h, "layerPanel.canvas"); + let moved = panned.min - canvas.min; + assert!((moved.x - 60.0).abs() < 1.5 && (moved.y + 30.0).abs() < 1.5, "the scroll pans: {moved:?}"); + assert!((panned.width() - canvas.width()).abs() < 0.5, "a scroll doesn't zoom: {} → {}", canvas.width(), panned.width()); + // Pinch: zooms, keeping the point under the pointer. + let at = panned.min + panned.size() * 0.25; + h.input_mut().events.push(Event::PointerMoved(at)); + h.input_mut().events.push(Event::Zoom(1.5)); + h.run_steps(3); + let zoomed = rect(&h, "layerPanel.canvas"); + assert!((zoomed.width() / panned.width() - 1.5).abs() < 0.01, "the pinch zooms: {} → {}", panned.width(), zoomed.width()); + let still = zoomed.min + zoomed.size() * 0.25; + assert!(still.distance(at) < 2.0, "the point under the pointer stays put: {at:?} vs {still:?}"); +} diff --git a/crates/ui-egui/tests/it/ui_light_settings.rs b/crates/ui-egui/tests/it/ui_light_settings.rs index fe1392be1..aac090802 100644 --- a/crates/ui-egui/tests/it/ui_light_settings.rs +++ b/crates/ui-egui/tests/it/ui_light_settings.rs @@ -177,13 +177,33 @@ fn falloff_and_shadow_options_show_only_for_lights_that_have_them() { invoke(&mut h, "layer.newLight", json!({})); for k in LightKind::ALL { choose(&mut h, k); - let shown = !matches!(k, LightKind::Ambient | LightKind::Environment); - for label in ["Falloff", "Casts Shadows", "Shadow Darkness"] { - assert_eq!(h.query_by_label(label).is_some(), shown, "{label} for {}", k.label()); + // Falloff is distance attenuation: image-based Environment lights and Ambient have none. + let falloff = !matches!(k, LightKind::Ambient | LightKind::Environment); + assert_eq!(h.query_by_label("Falloff").is_some(), falloff, "Falloff for {}", k.label()); + // Cast Shadows shows for every light but Ambient, Environment included (#2436). + let shadows = k != LightKind::Ambient; + for label in ["Casts Shadows", "Shadow Darkness"] { + assert_eq!(h.query_by_label(label).is_some(), shadows, "{label} for {}", k.label()); } } } +/// #2436: an Environment light created through the dialog keeps its Cast Shadows, which the +/// engine stores on the layer. +#[test] +fn an_environment_light_takes_cast_shadows_from_the_dialog() { + let mut h = harness(); + invoke(&mut h, "layer.newLight", json!({})); + choose(&mut h, LightKind::Environment); + click_id(&mut h, "dialog.light.casts"); + ok(&mut h); + let id = last_layer(&h); + assert_eq!(kind(&h, id), LightKind::Environment); + let l = layer(&h, id); + assert!(l.props.prop("lightOptions/castsShadows").unwrap().value.as_bool(), "the dialog's checkbox reached the layer"); + assert!(l.props.prop("lightOptions/shadowDarkness").is_some() && l.props.prop("lightOptions/falloff").is_none()); +} + #[test] fn changing_an_environment_light_to_spot_undoes_to_its_source_and_rotation() { let mut h = harness(); diff --git a/crates/ui-egui/tests/it/ui_menus.rs b/crates/ui-egui/tests/it/ui_menus.rs index 38dcd15bc..424b75c48 100644 --- a/crates/ui-egui/tests/it/ui_menus.rs +++ b/crates/ui-egui/tests/it/ui_menus.rs @@ -65,6 +65,47 @@ fn shown(h: &Harness<'_, EffectcraftApp>, label: &str) -> bool { h.query_by_label_contains(label).is_some() } +#[test] +fn browse_presets_clears_search_and_scrolls_to_the_visible_presets_list() { + use effectcraft_ui_egui::dock::PanelKind; + + let mut h = harness(); + let ctx = h.ctx.clone(); + let favorites: Vec = effectcraft_engine::effects::all().iter().map(|effect| effect.id.to_string()).collect(); + assert!(!favorites.is_empty(), "the test needs registered effects"); + let recent: Vec = favorites.iter().take(8).cloned().collect(); + + h.state_mut().session.prefs.effects_favorites = favorites; + h.state_mut().ui.effects_recent = recent; + h.state_mut().ui.effects_open.extend(["favorites".into(), "recent".into()]); + h.state_mut().ui.effects_search = "no matching animation preset".into(); + h.state_mut().ui.effects_view.presets = true; + + let managed = effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "effect.manage", json!({})); + assert!(managed.is_ok(), "{managed:?}"); + assert_eq!(h.state().ui.focused, PanelKind::EffectsPresets); + assert_eq!(h.state().ui.effects_search, "no matching animation preset", "Manage Effects keeps its existing search behavior"); + assert!(h.state().ui.effects_view.presets, "Manage Effects keeps the existing presets setting"); + + h.run_steps(3); + assert!(h.state().auto.find("effects.category.presets").is_none(), "the unmatched search hides Animation Presets"); + h.state_mut().ui.effects_view.presets = false; + + let result = effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "anim.browsePresets", json!({})); + + assert!(result.is_ok(), "{result:?}"); + assert_eq!(h.state().ui.focused, PanelKind::EffectsPresets); + assert!(h.state().ui.effects_search.is_empty()); + assert!(h.state().ui.effects_view.presets); + assert!(h.state().ui.effects_open.contains("presets")); + + h.run_steps(3); + assert!( + h.state().auto.find("effects.category.presets").is_some(), + "Animation Presets must be visible after Browse Presets, even below expanded Favorites and Recently Used" + ); +} + /// Space (or any shortcut) while a menu is open goes to the menu, not the app: it started the /// preview behind the open menu. #[test] diff --git a/crates/ui-egui/tests/it/ui_new_primitive.rs b/crates/ui-egui/tests/it/ui_new_primitive.rs index fbb3a34e5..c40aa23ed 100644 --- a/crates/ui-egui/tests/it/ui_new_primitive.rs +++ b/crates/ui-egui/tests/it/ui_new_primitive.rs @@ -70,7 +70,7 @@ fn choose(h: &mut Harness<'_, EffectcraftApp>, kind: PrimitiveKind) { } fn layers(h: &Harness<'_, EffectcraftApp>) -> Vec { - h.state().session.active_comp().unwrap().layers.clone() + h.state().session.active_comp().unwrap().layers.to_vec() } fn renderer(h: &Harness<'_, EffectcraftApp>) -> Renderer { diff --git a/crates/ui-egui/tests/it/ui_project_delete.rs b/crates/ui-egui/tests/it/ui_project_delete.rs index f217c29db..2a395cf4b 100644 --- a/crates/ui-egui/tests/it/ui_project_delete.rs +++ b/crates/ui-egui/tests/it/ui_project_delete.rs @@ -364,3 +364,29 @@ fn a_cut_is_read_with_the_modifiers_where_it_is_in_the_frame() { assert_eq!(h.state().session.project.item(solid).is_none(), deletes, "case {n}: {events:?} after {before:?}"); } } + +/// An open text edit stands down the Project-panel Clear redirect: the composition being typed +/// into must not be deleted out from under it, with no confirmation when nothing uses it (#2227). +#[test] +fn clear_with_the_project_focused_stands_down_during_a_text_edit() { + let (mut h, _, _) = harness(); + let working = h.state().session.active_comp_id().unwrap(); + // the composition being typed into is also the Project panel's selection, as when it was opened + click(&mut h, &format!("project.item.{}.name", working.0), Modifiers::NONE); + let text = h.state_mut().session.execute("layer.newText", json!({"text": "Title", "size": 80})).unwrap()["layer"].as_u64().unwrap(); + h.state_mut().session.execute("text.edit", json!({"layer": text, "created": true})).unwrap(); + h.state_mut().ui.focused = PanelKind::Project; + h.run_steps(2); + assert!(h.state().session.state.project_selection.contains(&working), "the comp is the project selection"); + let project = h.state().session.project.clone(); + + // Edit > Clear (the menu, or the control channel) dispatches through `invoke` + let ctx = h.ctx.clone(); + let cleared = effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "edit.clear", json!({})); + h.run_steps(3); + + cleared.expect("Clear stands down during a text edit"); + assert!(h.state().session.project.item(working).is_some(), "the composition being typed into survives"); + assert_eq!(h.state().session.project, project, "nothing was deleted"); + assert!(h.state().session.state.text_edit.is_some(), "the text edit stays open"); +} diff --git a/crates/ui-egui/tests/it/ui_project_rows.rs b/crates/ui-egui/tests/it/ui_project_rows.rs index 27ce5e693..b8638bbd1 100644 --- a/crates/ui-egui/tests/it/ui_project_rows.rs +++ b/crates/ui-egui/tests/it/ui_project_rows.rs @@ -274,12 +274,13 @@ fn comp_context_menu_opens_that_comps_settings() { assert_eq!(h.state().dialog, Some(Dialog::CompSettings)); assert_eq!(h.state().session.active_comp_id(), Some(main), "the viewer keeps its comp"); // A new name, then OK: Side's own size goes back to Side under it. Main's values in the - // dialog, or OK writing to the viewer's comp, would resize or rename the wrong one. + // dialog, or OK writing to the viewer's comp, would resize or rename the wrong one. The + // dialog previews the name as it is typed; OK keeps that as one undo step. + let undo = h.state().session.history.undo.len(); click(&mut h, "dialog.comp.name"); h.input_mut().events.push(Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND }); h.input_mut().events.push(Event::Text("Side 2".into())); h.run_steps(2); - let undo = h.state().session.history.undo.len(); h.input_mut().events.push(Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); h.run_steps(2); assert_eq!(h.state().dialog, None, "OK closed the dialog"); diff --git a/crates/ui-egui/tests/it/ui_scriptui.rs b/crates/ui-egui/tests/it/ui_scriptui.rs index aa439737b..09986f6d1 100644 --- a/crates/ui-egui/tests/it/ui_scriptui.rs +++ b/crates/ui-egui/tests/it/ui_scriptui.rs @@ -113,6 +113,42 @@ fn panels_open_at_quit_dock_again_at_launch() { assert_eq!(store.read(effectcraft_engine::commands::scripts::OPEN_PANELS_FILE).as_deref(), Some(r#"{"open":[]}"#)); } +/// A script copied into the Scripts folder while the app runs is listed in Edit ▸ Keyboard +/// Shortcuts and takes a key there: File ▸ Scripts shows the key next to the script, and +/// pressing it runs the script as its menu entry does. +#[test] +fn a_key_given_to_a_script_runs_it() { + use effectcraft_engine::config::{ConfigStore, MemoryConfig}; + let store = std::sync::Arc::new(MemoryConfig::default()); + let mut s = effectcraft_host::session(); + s.config = Some(store.clone()); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + assert!(h.state().session.shortcuts().find(r#"file.runScript {"name":"Rename Layers.jsx"}"#).is_some(), "the shortcut table is built"); + store.write("Scripts/Add Comp.jsx", "app.project.items.addComp('From Key', 64, 64, 1, 1, 25);").unwrap(); + effectcraft_ui_egui::panels::shortcut_editor::open(h.state_mut()); + let script = json!({"name": "Add Comp.jsx"}); + let r = h.state_mut().session.execute("shortcuts.set", json!({"command": "file.runScript", "params": script, "keys": "Cmd+Alt+Shift+F7"})).unwrap(); + assert_eq!(r["command"], r#"file.runScript {"name":"Add Comp.jsx"}"#, "{r}"); + assert_eq!(r["keys"], json!(["Cmd+Alt+Shift+F7"]), "{r}"); + assert_eq!(r["conflicts"], json!([]), "{r}"); + h.state_mut().dialog = None; + let spec = effectcraft_ui_egui::native_menu::build(h.state()); + let entry = spec.items().into_iter().find(|i| i.command == "file.runScript" && i.params == script).expect("the File ▸ Scripts entry"); + assert_eq!(entry.shortcut.as_deref(), Some("Cmd+Alt+Shift+F7")); + let (m, k, ..) = effectcraft_ui_egui::menus::bindings(&h.state().session) + .into_iter() + .find(|b| b.2 == "file.runScript" && b.3 == script) + .expect("the key is bound to the script"); + let made = |h: &Harness<'_, EffectcraftApp>| h.state().session.project.items.values().filter(|i| i.name == "From Key").count(); + assert_eq!(made(&h), 0); + for pressed in [true, false] { + h.event(Event::Key { key: k, physical_key: Some(k), pressed, repeat: false, modifiers: m }); + h.step(); + } + assert_eq!(made(&h), 1, "the key runs the script"); +} + /// The bundled Ease Presets ScriptUI panel (Window ▸ Ease Presets.jsx) docks, and clicking a /// preset in its list eases the selected keyframes in one undo step. #[test] @@ -264,6 +300,47 @@ fn script_windows_send_mouse_events_to_listeners() { h.state_mut().session.execute("scriptui.close", json!({})).unwrap(); } +/// #2584: a drag that starts on a control keeps reaching it outside the script window (moves and +/// the release), and `MouseEvent.buttons` tells a drag from a hover. +#[test] +fn a_drag_keeps_its_control_past_the_window_edge() { + let mut h = harness(); + let code = r#" + var w = new Window("palette", "Drag"); + var pad = w.add("group", undefined, undefined, { name: "pad" }); + pad.preferredSize = [120, 80]; + var log = w.add("statictext", undefined, "", { name: "log" }); + log.preferredSize = [400, 20]; + var seen = []; + var rec = function (e) { seen.push(e.type + (e.clientX < 0 ? " out" : " in") + " B" + e.buttons); log.text = seen.join("|"); }; + pad.addEventListener("mousedown", rec); + pad.addEventListener("mousemove", rec); + pad.addEventListener("mouseup", rec); + w.show(); + "#; + let r = h.state_mut().session.execute("script.run", json!({"code": code, "name": "drag.jsx"})).unwrap(); + assert_eq!(r["ok"], true, "{r}"); + h.run_steps(3); + let win = h.state().session.script_ui.windows[0].id; + let pad = rect(&h, &format!("scriptui.{win}.pad")); + let (from, to) = (pad.min + vec2(10.0, 20.0), pad.min + vec2(-300.0, 20.0)); + h.input_mut().events.push(Event::PointerMoved(from)); + h.run_steps(1); + h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.run_steps(1); + for k in 1..=4 { + h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (k as f32 / 4.0))); + h.run_steps(1); + } + h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(3); + let log = h.state().session.script_ui.windows[0].root.lookup(&json!("log")).cloned().unwrap().text; + assert!(log.starts_with("mousemove in B0|mousedown in B1|"), "a hover move has no button held: {log}"); + assert!(log.contains("mousedown in B1|mousemove out B1"), "moves outside the window still arrive, with the button held: {log}"); + assert!(log.ends_with("|mouseup out B0"), "the release outside reaches the control: {log}"); + h.state_mut().session.execute("scriptui.close", json!({})).unwrap(); +} + fn click_at(h: &mut Harness<'_, EffectcraftApp>, c: egui::Pos2) { h.input_mut().events.push(Event::PointerMoved(c)); h.run_steps(1); diff --git a/crates/ui-egui/tests/it/ui_tabs.rs b/crates/ui-egui/tests/it/ui_tabs.rs index 65b429f44..5fc3d8598 100644 --- a/crates/ui-egui/tests/it/ui_tabs.rs +++ b/crates/ui-egui/tests/it/ui_tabs.rs @@ -466,3 +466,31 @@ fn open_comps_are_tabs_in_the_timeline_row_and_the_rest_are_in_its_overflow_menu assert_eq!(open_comps(&h), comps); assert!(has(&h, &format!("panel.tab.Timeline.{hidden}"))); } + +/// Whether `id` was registered in the last frame (not the one before). +fn drawn(h: &Harness<'_, EffectcraftApp>, id: &str) -> bool { + h.state().auto.elements.iter().any(|e| e.id == id) +} + +#[test] +fn an_inactive_comp_tab_shows_its_close_button_on_hover_and_closes_without_showing_its_comp() { + let (mut h, pre, main) = harness(); + h.state_mut().session.state.open_comps = vec![ItemId(main), ItemId(pre)]; + h.run_steps(3); + let (tm, tp) = (rect(&h, &format!("panel.tab.Timeline.{main}")), rect(&h, &format!("panel.tab.Timeline.{pre}"))); + // The shown comp's × is always there; Pre's only while the pointer is over its tab. + h.event(Event::PointerMoved(pos2(800.0, 900.0))); + h.run_steps(2); + assert!(drawn(&h, "panel.tab.Timeline.close") && drawn(&h, &format!("panel.tab.Timeline.{main}.close"))); + assert!(!drawn(&h, &format!("panel.tab.Timeline.{pre}.close")), "no × on an inactive tab the pointer is not over"); + h.event(Event::PointerMoved(tp.center())); + h.run_steps(2); + let close = rect(&h, &format!("panel.tab.Timeline.{pre}.close")); + assert!(tp.contains_rect(close), "{close:?} inside {tp:?}"); + // Every comp tab keeps the ×'s room: hovering changes no width. + assert_eq!((rect(&h, &format!("panel.tab.Timeline.{main}")), rect(&h, &format!("panel.tab.Timeline.{pre}"))), (tm, tp)); + // Its × closes Pre's Timeline; Main stays shown. + click_n(&mut h, close.center(), 1); + assert_eq!(open_comps(&h), vec![main]); + assert_eq!(h.state().session.state.active_comp, Some(ItemId(main))); +} diff --git a/crates/ui-egui/tests/it/ui_text_edit.rs b/crates/ui-egui/tests/it/ui_text_edit.rs index 84a218466..ea4b56d26 100644 --- a/crates/ui-egui/tests/it/ui_text_edit.rs +++ b/crates/ui-egui/tests/it/ui_text_edit.rs @@ -171,7 +171,7 @@ fn type_tool_click_type_edit_and_commit() { /// #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. +/// text layer clicked into being and left empty stays, as it does in After Effects. #[test] fn clicking_another_panel_stops_typing_into_text() { for type_tool in [false, true] { @@ -194,7 +194,7 @@ fn clicking_another_panel_stops_typing_into_text() { 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"); + assert_eq!(layers(&h), 3, "left empty, it stays, as in After Effects"); click(&mut h, at, Modifiers::NONE); } else { h.state_mut().session.execute("text.edit", json!({"layer": title})).unwrap(); @@ -1734,3 +1734,19 @@ fn same_caret_application_text_replacement_rejects_the_previous_context_fence() h.run_steps(1); assert_eq!(edited(&h).1, "Aがbcd"); } + +/// #2077: the Character panel's Line Join buttons set the stroke's corner style (Miter for new text). +#[test] +fn character_panel_line_join_buttons_set_the_join() { + 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": "Join", "size": 80, "applyStroke": true, "strokeWidth": 8})).unwrap()["layer"].as_u64().unwrap(); + let mut h = harness(EffectcraftApp::new(s)); + h.state_mut().show_panel(PanelKind::Character); + h.run_steps(3); + let join = |h: &Harness<'_, EffectcraftApp>| layer_doc(&h.state().session, LayerId(lid)).unwrap().line_join; + assert_eq!(join(&h), effectcraft_engine::keyframe::LineJoin::Miter); + let bevel = rect(&h, "character.lineJoin.bevel").center(); + click(&mut h, bevel, Modifiers::NONE); + assert_eq!(join(&h), effectcraft_engine::keyframe::LineJoin::Bevel); +} diff --git a/crates/ui-egui/tests/it/ui_timeline_shapes.rs b/crates/ui-egui/tests/it/ui_timeline_shapes.rs index c8b45e8da..c9b2245f8 100644 --- a/crates/ui-egui/tests/it/ui_timeline_shapes.rs +++ b/crates/ui-egui/tests/it/ui_timeline_shapes.rs @@ -76,6 +76,22 @@ fn shape_contents_add_menu_adds_path_operations() { assert_eq!(items, ["group", "trim"]); } +#[test] +fn shape_contents_add_menu_adds_wiggle_transform() { + let mut s = session(); + let l = s.execute("layer.newShape", json!({"kind": "ellipse"})).unwrap()["layer"].as_u64().unwrap(); + let contents = s.active_comp().unwrap().layer(LayerId(l)).unwrap().props.sub("contents").unwrap().uid; + let mut h = harness(s); + open_layer(&mut h, l); + click(&mut h, &format!("timeline.group.{contents}.add")); + assert!(h.get_by_label("Zig Zag").rect().max.y <= 1000.0, "the whole menu fits in the window"); + let entry = h.get_by_label("Wiggle Transform").rect(); + click_at(&mut h, entry.center()); + let layer = h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().clone(); + let items: Vec<&str> = layer.props.sub("contents").unwrap().groups().map(|g| g.match_id.as_str()).collect(); + assert_eq!(items, ["group", "wiggler"]); +} + /// A shape group's inner Contents is elided in After Effects: opening the group lists its items /// (here the path) directly, with no "Contents" row between them. #[test] @@ -380,3 +396,94 @@ fn ctrl_v_pastes_svg_markup_as_a_shape_layer() { assert_eq!(c.layers.len(), 4); assert_eq!(c.layers[0].name, "Oval 2"); } + +fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2, cancel: bool) { + h.input_mut().events.push(Event::PointerMoved(from)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + for k in 1..=8 { + h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (k as f32 / 8.0))); + h.step(); + } + if cancel { + h.input_mut().events.push(Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.step(); + } + h.input_mut().events.push(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +/// #2210: an effect's row drags to a new place in the layer's effect stack, as in After Effects +/// (not a marquee); Escape cancels the drag. +#[test] +fn effects_drag_to_a_new_place_in_the_timeline() { + let mut s = session(); + let l = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap(); + for fx in ["Gaussian Blur", "Invert", "Tint"] { + s.execute("effect.apply", json!({"layers": [l], "effect": fx})).unwrap(); + } + let effects = s.active_comp().unwrap().layer(LayerId(l)).unwrap().props.sub("effects").unwrap().clone(); + let uids: Vec = effects.groups().map(|g| g.uid).collect(); + let mut h = harness(s); + h.state_mut().ui.timeline.open_groups.insert(effects.uid); + open_layer(&mut h, l); + let names = |h: &Harness<'_, EffectcraftApp>| -> Vec { + h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.sub("effects").unwrap().groups().map(|g| g.name.clone()).collect() + }; + let row = |h: &Harness<'_, EffectcraftApp>, uid: u64| { + let e = h.state().auto.find(&format!("timeline.group.{uid}.name")).unwrap_or_else(|| panic!("no row for {uid}")); + egui::Rect::from_min_size(pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3])) + }; + // Tint above Gaussian Blur. + let (tint, blur) = (row(&h, uids[2]), row(&h, uids[0])); + drag(&mut h, tint.center(), pos2(blur.center().x, blur.min.y + 2.0), true); + assert_eq!(names(&h), ["Gaussian Blur", "Invert", "Tint"], "Escape cancels"); + drag(&mut h, tint.center(), pos2(blur.center().x, blur.min.y + 2.0), false); + assert_eq!(names(&h), ["Tint", "Gaussian Blur", "Invert"]); + // Gaussian Blur (now second) below Invert, the last. + let (blur, invert) = (row(&h, uids[0]), row(&h, uids[1])); + drag(&mut h, blur.center(), pos2(invert.center().x, invert.max.y - 2.0), false); + assert_eq!(names(&h), ["Tint", "Invert", "Gaussian Blur"]); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(names(&h), ["Tint", "Gaussian Blur", "Invert"], "one undo step per drop"); +} + +/// #2552: Shift-clicking effect rows in the Timeline adds them to the selection (and a second +/// Shift-click takes one out), so one animation preset can hold several effects. +#[test] +fn shift_click_selects_several_effects_for_a_preset() { + let mut s = session(); + let l = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap(); + for fx in ["Gaussian Blur", "Invert"] { + s.execute("effect.apply", json!({"layers": [l], "effect": fx})).unwrap(); + } + let effects = s.active_comp().unwrap().layer(LayerId(l)).unwrap().props.sub("effects").unwrap().clone(); + let uids: Vec = effects.groups().map(|g| g.uid).collect(); + let mut h = harness(s); + h.state_mut().ui.timeline.open_groups.insert(effects.uid); + open_layer(&mut h, l); + let shift_click = |h: &mut Harness<'_, EffectcraftApp>, uid: u64| { + let p = center(h, &format!("timeline.group.{uid}.name")); + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::SHIFT)); + h.input_mut().events.push(Event::PointerMoved(p)); + h.step(); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::SHIFT }); + h.step(); + } + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); + }; + let selected = |h: &Harness<'_, EffectcraftApp>| h.state().session.state.selected_props.iter().map(|(_, u)| *u).collect::>(); + click(&mut h, &format!("timeline.group.{}.name", uids[0])); + assert_eq!(selected(&h), [uids[0]]); + shift_click(&mut h, uids[1]); + assert_eq!(selected(&h), [uids[0], uids[1]], "Shift adds the second effect"); + let path = std::env::temp_dir().join(format!("ec-2552-{}.ffx", std::process::id())); + let r = h.state_mut().session.execute("anim.savePreset", json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!(r["effects"], 2, "{r}"); + let _ = std::fs::remove_file(&path); + shift_click(&mut h, uids[1]); + assert_eq!(selected(&h), [uids[0]], "a second Shift-click takes it out"); +} diff --git a/crates/ui-egui/tests/it/ui_viewer.rs b/crates/ui-egui/tests/it/ui_viewer.rs index c9024ac58..41cf5fe43 100644 --- a/crates/ui-egui/tests/it/ui_viewer.rs +++ b/crates/ui-egui/tests/it/ui_viewer.rs @@ -482,8 +482,9 @@ fn fill_rgb(h: &Harness<'_, EffectcraftApp>) -> [u8; 3] { fn shape_drag_ghost_shows_the_tools_own_shape_while_dragging() { let (from, to) = ([140.0, 100.0], [400.0, 300.0]); let centre = [270.0, 200.0]; - // 6 comp px inside the box's top-left corner: inside a rectangle, outside every other kind. - let corner = [146.0, 106.0]; + // 4 comp px inside the box's top-left corner: inside a rectangle, outside every other kind + // (a rounded corner of 20 px arcs past this point). + let corner = [144.0, 104.0]; for (tool, fills_corner) in [(Tool::Rectangle, true), (Tool::RoundedRect, false), (Tool::Ellipse, false), (Tool::Polygon, false), (Tool::Star, false)] { let mut h = harness(); h.state_mut().ui.tool = tool; @@ -802,6 +803,76 @@ fn hidden_shape_layer_keeps_its_controls_and_can_still_scale() { assert_ne!(scale(&h), before, "a hidden layer's handle still scales it"); } +/// #1964: Shift-dragging a selected mask vertex keeps it to the axis it moves along most, as a +/// Shift-dragged layer does; without Shift it moves freely. +#[test] +fn shift_drag_keeps_a_mask_vertex_to_one_axis() { + let mut h = harness(); + let plate = layer_id(&h, "Plate"); + let corners = [[100.0, 60.0], [500.0, 60.0], [500.0, 300.0], [100.0, 300.0]]; + let mask = { + let s = &mut h.state_mut().session; + s.execute("layer.select", json!({"layers": [plate]})).unwrap(); + let m = s.execute("mask.new", json!({"layer": plate, "vertices": corners, "closed": true})).unwrap()["mask"].as_u64().unwrap(); + s.execute("mask.selectVertices", json!({"vertices": [{"layer": plate, "mask": m, "index": 0}]})).unwrap(); + m + }; + h.run_steps(2); + let vertex = format!("viewer.mask.{mask}.vertex.0"); + let before = mask_path(&h, "Plate").vertices[0]; + let from = rect(&h, &vertex).center(); + drag_path(&mut h, &[from, from + vec2(30.0, 8.0)], egui::Modifiers::SHIFT); + let shifted = mask_path(&h, "Plate").vertices[0]; + assert!(shifted[0] > before[0], "it moved along x: {before:?} -> {shifted:?}"); + assert_eq!(shifted[1], before[1], "and not along y"); + let from = rect(&h, &vertex).center(); + drag_path(&mut h, &[from, from + vec2(30.0, 8.0)], egui::Modifiers::NONE); + let free = mask_path(&h, "Plate").vertices[0]; + assert!(free[0] > shifted[0] && free[1] > shifted[1], "without Shift both axes move: {shifted:?} -> {free:?}"); +} + +/// #999: a mask vertex dragged against the edge of the Composition panel pans the view, and the +/// vertex keeps moving while the pointer is held there, as in After Effects. +#[test] +fn a_vertex_held_at_the_viewer_edge_pans_the_view() { + let mut h = harness(); + let plate = layer_id(&h, "Plate"); + let corners = [[100.0, 60.0], [500.0, 60.0], [500.0, 300.0], [100.0, 300.0]]; + let mask = { + let s = &mut h.state_mut().session; + s.execute("layer.select", json!({"layers": [plate]})).unwrap(); + let m = s.execute("mask.new", json!({"layer": plate, "vertices": corners, "closed": true})).unwrap()["mask"].as_u64().unwrap(); + s.execute("mask.selectVertices", json!({"vertices": [{"layer": plate, "mask": m, "index": 0}]})).unwrap(); + m + }; + h.run_steps(2); + let pan0 = h.state().ui.viewer.pan; + let from = rect(&h, &format!("viewer.mask.{mask}.vertex.0")).center(); + let edge = pos2(2.0, from.y); + h.input_mut().events.push(Event::PointerMoved(from)); + h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + for i in 1..=6 { + h.input_mut().events.push(Event::PointerMoved(from + (edge - from) * (i as f32 / 6.0))); + h.step(); + } + let reached = mask_path(&h, "Plate").vertices[0]; + for _ in 0..20 { + h.step(); + } + h.input_mut().events.push(Event::PointerButton { pos: edge, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); + let pan = h.state().ui.viewer.pan; + assert!(pan[0] > pan0[0] && pan[1] == pan0[1], "the view panned right to show more on the left: {pan0:?} -> {pan:?}"); + let after = mask_path(&h, "Plate").vertices[0]; + assert!(after[0] < reached[0], "the vertex kept moving left with the view: {reached:?} -> {after:?}"); + // Back inside the view, nothing pans. + let still = h.state().ui.viewer.pan; + let mid = rect(&h, "viewer.comp").center(); + drag(&mut h, mid, mid + vec2(5.0, 0.0)); + assert_eq!(h.state().ui.viewer.pan, still); +} + /// #1769: a hidden masked layer keeps its mask path and vertices, and they remain editable. #[test] fn hidden_masked_layer_keeps_its_mask_path_editable() { diff --git a/crates/ui-egui/tests/it/ui_viewer_trackpad.rs b/crates/ui-egui/tests/it/ui_viewer_trackpad.rs new file mode 100644 index 000000000..55466371a --- /dev/null +++ b/crates/ui-egui/tests/it/ui_viewer_trackpad.rs @@ -0,0 +1,156 @@ +//! Trackpad gestures in the Composition viewer: a two-finger scroll pans, a pinch (and the mouse +//! wheel, also one that scrolls in pixels) zooms around the pointer (egui_kittest, UI logic only). + +use effectcraft_engine::Session; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::dock::PanelKind; +use egui::{Event, Rect, pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +fn harness() -> Harness<'static, EffectcraftApp> { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "View", "width": 640, "height": 360, "duration": 4})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.state_mut().show_panel(PanelKind::Composition); + h.run_steps(4); + 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 wheel(h: &mut Harness<'_, EffectcraftApp>, unit: egui::MouseWheelUnit, delta: egui::Vec2, modifiers: egui::Modifiers) { + h.input_mut().events.push(Event::MouseWheel { unit, delta, modifiers, phase: egui::TouchPhase::Move, source: egui::MouseWheelSource::Unknown }); + h.run_steps(30); +} + +#[test] +fn trackpad_scroll_pans_the_composition() { + let mut h = harness(); + let comp = rect(&h, "viewer.comp"); + h.input_mut().events.push(Event::PointerMoved(comp.center())); + wheel(&mut h, egui::MouseWheelUnit::Point, vec2(80.0, -40.0), Default::default()); + let after = rect(&h, "viewer.comp"); + let moved = after.min - comp.min; + assert!((moved.x - 80.0).abs() < 1.5 && (moved.y + 40.0).abs() < 1.5, "the scroll pans: {moved:?}"); + assert!((after.width() - comp.width()).abs() < 0.5, "a scroll doesn't zoom: {} → {}", comp.width(), after.width()); +} + +#[test] +fn pinch_and_mouse_wheel_zoom_around_the_pointer() { + let mut h = harness(); + let comp = rect(&h, "viewer.comp"); + let at = comp.min + comp.size() * 0.25; + h.input_mut().events.push(Event::PointerMoved(at)); + h.input_mut().events.push(Event::Zoom(1.5)); + h.run_steps(3); + let zoomed = rect(&h, "viewer.comp"); + assert!((zoomed.width() / comp.width() - 1.5).abs() < 0.01, "the pinch zooms: {} → {}", comp.width(), zoomed.width()); + let still = zoomed.min + zoomed.size() * 0.25; + assert!(still.distance(at) < 2.0, "the point under the pointer stays put: {at:?} vs {still:?}"); + // A mouse wheel (it scrolls in lines) zooms too, and so does Alt + a trackpad scroll. + wheel(&mut h, egui::MouseWheelUnit::Line, vec2(0.0, 3.0), Default::default()); + let wheeled = rect(&h, "viewer.comp"); + assert!(wheeled.width() > zoomed.width() * 1.1, "the wheel zooms in: {} → {}", zoomed.width(), wheeled.width()); + h.input_mut().events.push(Event::ModifiersChanged(egui::Modifiers::ALT)); + wheel(&mut h, egui::MouseWheelUnit::Point, vec2(0.0, -120.0), egui::Modifiers::ALT); + let out = rect(&h, "viewer.comp"); + assert!(out.width() < wheeled.width() * 0.9, "Alt + scroll zooms out: {} → {}", wheeled.width(), out.width()); +} + +/// Browsers and some high-resolution wheels report a mouse wheel in pixels, as a trackpad does: +/// it still zooms. +#[test] +fn a_pixel_unit_mouse_wheel_zooms() { + let mut h = harness(); + let comp = rect(&h, "viewer.comp"); + let at = comp.min + comp.size() * 0.25; + h.input_mut().events.push(Event::PointerMoved(at)); + wheel(&mut h, egui::MouseWheelUnit::Point, vec2(0.0, 120.0), Default::default()); + let zoomed = rect(&h, "viewer.comp"); + assert!(zoomed.width() > comp.width() * 1.1, "a pixel wheel zooms in: {} → {}", comp.width(), zoomed.width()); + let still = zoomed.min + zoomed.size() * 0.25; + assert!(still.distance(at) < 2.0, "the point under the pointer stays put: {at:?} vs {still:?}"); + // Shift turns the wheel's scroll sideways: still a wheel, and the next notch zooms. + h.input_mut().events.push(Event::ModifiersChanged(egui::Modifiers::SHIFT)); + wheel(&mut h, egui::MouseWheelUnit::Point, vec2(0.0, 120.0), egui::Modifiers::SHIFT); + h.input_mut().events.push(Event::ModifiersChanged(egui::Modifiers::NONE)); + let before = rect(&h, "viewer.comp"); + wheel(&mut h, egui::MouseWheelUnit::Point, vec2(0.0, -120.0), Default::default()); + let out = rect(&h, "viewer.comp"); + assert!(out.width() < before.width() * 0.9, "the wheel still zooms: {} → {}", before.width(), out.width()); +} + +/// A straight vertical trackpad scroll pans when the touch phase says it is a trackpad, +/// and after any diagonal scroll; a mouse wheel in lines takes the zoom back. +#[test] +fn a_vertical_trackpad_scroll_pans_once_the_trackpad_is_known() { + let mut h = harness(); + let comp = rect(&h, "viewer.comp"); + h.input_mut().events.push(Event::PointerMoved(comp.center())); + h.input_mut().events.push(Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::Vec2::ZERO, + modifiers: Default::default(), + source: egui::MouseWheelSource::Unknown, + phase: egui::TouchPhase::Start, + }); + h.input_mut().events.push(Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, 50.0), + modifiers: Default::default(), + source: egui::MouseWheelSource::Unknown, + phase: egui::TouchPhase::Move, + }); + h.step(); + h.input_mut().events.push(Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::Vec2::ZERO, + modifiers: Default::default(), + source: egui::MouseWheelSource::Unknown, + phase: egui::TouchPhase::End, + }); + h.run_steps(30); + let panned = rect(&h, "viewer.comp"); + assert!((panned.min.y - comp.min.y - 50.0).abs() < 1.5, "the scroll pans: {:?}", panned.min - comp.min); + assert!((panned.width() - comp.width()).abs() < 0.5, "a trackpad scroll doesn't zoom"); + // Without phases (Windows, Linux, the web) it stays a trackpad. + wheel(&mut h, egui::MouseWheelUnit::Point, vec2(0.0, 30.0), Default::default()); + let again = rect(&h, "viewer.comp"); + assert!((again.min.y - panned.min.y - 30.0).abs() < 1.5 && (again.width() - comp.width()).abs() < 0.5, "{again:?}"); + // A wheel that scrolls in lines is a mouse: it zooms, and so does a pixel wheel after it. + wheel(&mut h, egui::MouseWheelUnit::Line, vec2(0.0, 3.0), Default::default()); + let zoomed = rect(&h, "viewer.comp"); + assert!(zoomed.width() > again.width() * 1.1, "the wheel zooms: {} → {}", again.width(), zoomed.width()); + wheel(&mut h, egui::MouseWheelUnit::Point, vec2(0.0, 120.0), Default::default()); + assert!(rect(&h, "viewer.comp").width() > zoomed.width() * 1.1, "a pixel wheel zooms again"); +} + +/// Where the platform names the device (macOS), a straight vertical trackpad scroll pans at once +/// and a wheel in points zooms. +#[test] +fn the_wheel_source_decides_where_the_platform_reports_it() { + let mut h = harness(); + let comp = rect(&h, "viewer.comp"); + let at = comp.min + comp.size() * 0.25; + h.input_mut().events.push(Event::PointerMoved(at)); + let scroll = |h: &mut Harness<'_, EffectcraftApp>, source, dy| { + h.input_mut().events.push(Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, dy), + modifiers: Default::default(), + phase: egui::TouchPhase::Move, + source, + }); + h.run_steps(30); + }; + scroll(&mut h, egui::MouseWheelSource::Trackpad, 40.0); + let panned = rect(&h, "viewer.comp"); + assert!((panned.min.y - comp.min.y - 40.0).abs() < 1.5, "the trackpad pans: {:?}", panned.min - comp.min); + assert!((panned.width() - comp.width()).abs() < 0.5, "a trackpad scroll doesn't zoom"); + scroll(&mut h, egui::MouseWheelSource::Wheel, 120.0); + assert!(rect(&h, "viewer.comp").width() > panned.width() * 1.1, "the wheel zooms"); +} diff --git a/docs/agents.md b/docs/agents.md index d84442a84..033762da4 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -212,8 +212,25 @@ Things that tripped up a long agent-driven session (a multi-scene 3D piece built or call `save_project {"path": …}` after each milestone. - **Environment light.** `layer.newLight` also accepts `"kind":"Environment"`: an equirectangular image that metallic surfaces reflect. Set its `lightOptions/source` to a footage, comp or solid - layer by layer id; with no source it uses the comp's Environment Layer (`layer.environment`) - ([#261](https://github.com/storytold/effectcraft/issues/261)). + layer by layer id; with no source it uses the comp's Environment Layer (`layer.environment`), + or a default studio environment — bright above and toward the initial camera, dark below and + behind, at a dim 0.3 mean level — of the light's colour × intensity when there is none; either + way the light brightens the scene. A selected Source or Environment Layer that fails to + render — a deleted layer, missing footage — lights nothing instead: the light never silently + falls back to the studio default. It has `castsShadows`/`shadowDarkness`/`shadowDiffusion` + like the other lights: Cast Shadows shades the scene from the image's dominant bright region + (the default environment's key, or a source image's brightest area; an image without a + sufficiently strong dominant direction has none — a uniform one, or opposing bright regions + that cancel — so it casts no shadow) — the region weighted by the light's colour, so a + coloured light shadows from the regions it actually lights (opposing red and blue regions + cancel to a white light, but a red light's shadow comes from the red one) — occluding only + that key's share of the light, so the + shadow stays partial like After Effects' area-light shadows, and soft by default (a penumbra + of at least 8 pixels or four map texels, plus Shadow Diffusion). It only lights + Advanced 3D comps, so creating one (or changing a light's type to Environment) switches a + Classic 3D comp to Advanced 3D + ([#261](https://github.com/storytold/effectcraft/issues/261), + [#2436](https://github.com/storytold/effectcraft/issues/2436)). ### Cookbook (from end-to-end QA) @@ -272,10 +289,10 @@ build real projects through these interfaces; they are worked examples of everyt then `keys.velocity {"outSpeed":0,"outInfluence":33.33}`. * **Gradients**: `set_property {"path":"contents/gfill/colors","value":["#0080ff","#ffff00"]}` or `{"colors":[[0,"#0080ff"],[1,[1,1,0]]],"opacities":[[0,1],[1,0.5]]}`. -* **Image sequences**: `file.import {"paths":["/r/shot_0001.png"]}` imports the file's whole - numbered run as one item (several picked frames: that range; a folder: each run in it; - `"sequence": false`: one still; dropped files, `"drag": true`, import as stills unless - `"sequence": true`, as in After Effects; `"alphabetical": true`: Force Alphabetical Order, the files +* **Image sequences**: `file.import {"paths":["/r/shot_0001.png"], "sequence": true}` imports the + file's whole numbered run as one item (several picked frames: that range; a folder: each run in + it, also without `sequence`). Without `"sequence": true` a picked or dropped numbered file imports + as one still, as in After Effects; `"alphabetical": true`: Force Alphabetical Order, the files play one after another) at `frameRate` or 30 fps (Settings ▸ Import). Missing frames show colour bars, as in After Effects. `file.interpretFootage {"items":[id], "frameRate":12, "startTimecode":1001}` conforms them and sets Override Start (the item and layers that ran to its end get the new @@ -540,7 +557,7 @@ indices): character attributes 3. `layer.setText {"range":[6,11], "size":40, "fill":"#ff5500"}` styles the selection (the Character panel does the same); with an empty range it sets the style the next typed text takes. 4. `edit.copy`, `edit.paste`, `edit.pasteTextMatchFormatting` and `edit.pasteTextFormattingOnly` - work on the selected text; `text.endEdit` commits (an empty Type-tool layer is removed). + work on the selected text; `text.endEdit` commits (an empty Type-tool layer is kept, as After Effects does). Fonts: `text.fonts` (MCP `list_fonts`) lists every family a text layer can use, bundled and installed, with its styles, origin (`bundled`, `system`, `user`), `variable` (it has variation axes) and own-language name @@ -583,7 +600,9 @@ A dialog's `show()` waits for the user: the `script.run` / `file.runScript` repl `"waiting": true`, and the script continues (its final output arrives in the reply of the click that closes the dialog). File ▸ Scripts: `file.scripts.list`, `file.runScript {"name": …}`, `file.installScript` / `file.installScriptUIPanel {"path": …}`, `window.scriptPanel {"name": …}`, -`window.restoreScriptPanels` (the panels open when the app last quit). Scripts' `app.settings`: +`window.restoreScriptPanels` (the panels open when the app last quit), `file.runStartupScripts` and +`file.runShutdownScripts` (the scripts in `Scripts/Startup` and `Scripts/Shutdown`; the second closes +the project first). Scripts' `app.settings`: `script.settings.get {"section": …, "key": …}` and `script.settings.save {…, "value": …}`. Extension points and the bundled extensions: [plugins.md](plugins.md). @@ -622,6 +641,13 @@ Extension points and the bundled extensions: [plugins.md](plugins.md). Dragged onto another property's name or value, in the Timeline or Effect Controls (`effectControls.prop..value[.]`), it runs `prop.pickWhip`: the reference replaces the property's expression, or is added as one, and the expression is enabled. +* Parent and Track Matte for several layers: on a selected layer's row, the Timeline's Parent + menu and pick whip (`timeline.layer..parent`, `timeline.layer..pickWhip`) and its Track + Matte menu and pick whip (`timeline.layer..trackMatte`, + `timeline.layer..trackMattePickWhip`) set every selected layer's (on an unselected row, + that layer's), in one undo step. Directly: `layer.setParent {layers, parent}` and + `layer.setTrackMatte {layers, matte, kind}`, where `kind: "preserve"` keeps each layer's + own matte mode. * A property's menu (right-click its name; entries `timeline.prop..menu.`): Reset (`prop.reset`: no expression, and the property's default value, an effect parameter's or a Transform property's; an animated property keeps its keyframes and gets the default as a diff --git a/docs/architecture.md b/docs/architecture.md index 4ffdd9dd7..f52c93e1a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -158,9 +158,22 @@ comp to Advanced 3D in the same undo step, as in After Effects. Shading is physi GGX specular, Smith–Schlick geometry, Schlick Fresnel, Lambert diffuse, after Karis 2013): parallel, spot and point lights with After Effects' falloffs, ambient lights, image-based light from an Environment light whose Source (or the comp's Environment Layer, Layer ▸ -Environment Layer) is an equirectangular image (cosine irradiance map; roughness-indexed mip -chain with the split-sum environment BRDF fit), and percentage-closer-filtered shadow maps -(spot: perspective, parallel: orthographic over the scene, point: six cube faces). Cards keep +Environment Layer, or a default studio environment — bright above and toward the initial +camera, dark below and behind, at a dim 0.3 mean level — of the light's colour × intensity +when there is neither) is an equirectangular image (cosine irradiance map; roughness-indexed +mip chain with the split-sum environment BRDF fit), and percentage-closer-filtered shadow maps +(spot: perspective, parallel: orthographic over the scene, point: six cube faces, environment: +one orthographic from the image's dominant bright region — none only when the image lacks a +sufficiently strong dominant direction (a uniform one, or opposing bright regions that +cancel) — the region weighted by the light's colour, so a coloured light shadows from the +regions it actually lights (opposing red and blue regions cancel to a white light, but a red +light's shadow comes from the red one) — occluding only the image's directional key fraction of +the light, so the shadow stays partial like After Effects' area-light shadows, with a soft +default penumbra of at least eight pixels or four map texels). A Source or Environment Layer +that fails to render — a deleted layer, missing footage — lights nothing: no silent fallback +to the default studio environment. An Environment light only lights Advanced 3D comps, +so adding one (or changing a light's type to it) switches a Classic 3D comp to Advanced 3D, +as model layers do. Cards keep Material Options (Accepts Lights/Shadows, Casts Shadows On/Only, Ambient, Diffuse, Specular); models and primitives add Base Color, Metallic, Roughness, Emissive. Rasterisation runs at 2×2 supersampling with a reversed-Z depth buffer; opaque triangles resolve visibility first, the @@ -401,7 +414,10 @@ hierarchical block-matching optical flow and a bidirectional warp (Pixel Motion, its nested layers straight into the parent with concatenated transforms (one resample), so nested blend modes and adjustment layers act on the parent's layers; nested 3D layers use the parent's camera and lights and, when the precomp layer is 3D, are depth-sorted with the parent's -3D run. Masks, effects or styles force a flattened render. Text layers are always rasterised at +3D run. Essential Properties overrides (properties, Font and Media Replacement) travel with the +collapsed context and are applied while the nested layers are evaluated, so they do not force a +flattened render or clip at the precomp's frame. Masks, effects or styles force a flattened +render. Text layers are always rasterised at their on-screen scale, as text is vector in After Effects; on shape layers and vector footage the 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, @@ -468,8 +484,10 @@ effects crate, hence the `effects → track` edge. `essential` field lists its exposed controls (properties, Media Replacement, groups, comments). Every precomp layer of such a comp gets an Essential Properties group (`GroupKind::Essential`, children `eg`), kept in step by `Session::edit`; editing a child overrides it for -that instance. The renderer draws an instance with overrides from a copy of the project with the -overridden source properties set (`essential::with_overrides`). Templates are `.ectemplate` +that instance. A collapsed instance carries its property, Font and Media Replacement overrides +into the nested render context (`Collapsed`, `EvalCtx::{property_overrides, media_overrides}`); +other instances are drawn from a copy of the project with the overridden source properties set +(`essential::with_overrides`). Templates are `.ectemplate` ZIPs: `manifest.json` (controls), `project.ecproj` (the comp and its dependencies), `media/…` and `poster.png`; importing offsets every id past the project's (`essential::offset_ids`). **Responsive Design — Time**: a time-stretched precomp maps time piecewise so its protected diff --git a/docs/control-protocol.md b/docs/control-protocol.md index c68711ea5..d9049306c 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -103,7 +103,7 @@ Points are logical window coordinates. A target is `{id}` (the element's centre; | `ui.click` | `{id \| x,y, button?: left\|right\|middle, count?, modifiers?}` | | `ui.move` | `{id \| x,y}` | | `ui.drag` | `{from: target, to: target, steps? (12), modifiers?}`: press, move, release | -| `ui.scroll` | `{id \| x,y, dx, dy}` | +| `ui.scroll` | `{id \| x,y, dx?, dy?, unit?: point\|line, source?: wheel\|trackpad, pinch?, modifiers?}`: `line` is a mouse wheel (zooms the viewers); `point` (default) is a pixel-precise scroll; `source: trackpad` pans the viewers (as on macOS); with no `source` the viewers take a scroll in points as a wheel until one moves on both axes (a trackpad: from then on it pans); `pinch` is a trackpad pinch's zoom factor | | `ui.key` | `{key}`, for example `Space`, `F9`, `Cmd+Z`, `Cmd+Shift+Z`, `PageDown`, `Escape` | | `ui.type` | `{text}`: text input into the focused field | diff --git a/docs/effects.md b/docs/effects.md index b8214e438..c6fb40922 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -3,11 +3,11 @@ -EffectCraft ships 307 effects with 3032 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 308 effects with 3033 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**: 307 effects process 32-bit float (HDR, overbright) pixels without clamping. -- **Status**: 307 implemented in full, 0 partial (what is missing is listed). +- **32**: 308 effects process 32-bit float (HDR, overbright) pixels without clamping. +- **Status**: 308 implemented in full, 0 partial (what is missing is listed). Effects are addressed by id (`ec..`) or display name in commands, scripts and the control channel, and every parameter by its id (`effects/#1/blurriness`). @@ -16,6 +16,7 @@ Effects are addressed by id (`ec..`) or display name in commands | Effect | Id | Params | GPU | 32 | Status | |---|---|---:|:-:|:-:|---| | 3D Channel Extract | `ec.3d.channelextract` | 6 | GPU | 32 | Implemented | +| 3D Layer Instance | `ec.3d.instance3d` | 1 | | 32 | Implemented | | Cryptomatte | `ec.3d.cryptomatte` | 4 | GPU | 32 | Implemented | | Depth Matte | `ec.3d.depthmatte` | 3 | GPU | 32 | Implemented | | Depth of Field | `ec.3d.depthoffield` | 4 | GPU | 32 | Implemented | diff --git a/docs/fidelity/README.md b/docs/fidelity/README.md index fc780016b..0398118c5 100644 --- a/docs/fidelity/README.md +++ b/docs/fidelity/README.md @@ -71,6 +71,45 @@ After the fix, all ten tangents equal AE's to the printed 0.001 px, and the two test `spatial_auto_bezier_tangents_follow_after_effects` checks the rule on other original keys, including a timing change. AE's output stays local; only the script and these scores are tracked. +## Justify All with the every-line composer + +Measured on 19 October 2026 with After Effects 2026 (26.2) on box text (EXPORTER read-back t6): +with Justify All, AE stretches the paragraph's last line too and breaks the lines so that it is not +a lone word (4 / 4 / 4 words where the other justifications break 6 / 6 / 1). EffectCraft's +every-line composer ignored the last line's slack and left "twelve" alone on a stretched line; it now +counts that slack for Justify All, and every word starts within 1 px of AE's. Regression test: +`justify_all_balances_the_lines_with_the_every_line_composer`. + +## Right-to-left point text with a justified paragraph + +Measured on 19 October 2026 with After Effects 2026 (26.2) (EXPORTER read-back t7): right-to-left +point text whose paragraph has a justified code (justify last left / centre / right, justify all) +sits flush right at its anchor, like right-aligned text. EffectCraft placed it by the last-line +side (left / centre / left). Now flush right; left-to-right point text is unchanged (not measured). +Regression test: `rtl_point_text_with_a_justified_paragraph_is_flush_at_its_start`. Still open: +AE wraps right-to-left box text slightly wider (its word gaps are 1–2 px narrower than in +left-to-right text with the same font), so a 300 px box fits a word more per line. + +## Text animator tracking + +Measured on 19 October 2026 with After Effects 2026 (26.2): a text animator's Tracking Amount is in +pixels, not 1/1000 em like the Character panel's tracking (Amount 30 adds 30 px between neighbouring +characters at 80 px and at 40 px, half before and half after each), and a Subtract range selector +over characters the first selector leaves out keeps them at 0 instead of tracking them negatively. +With both, the EXPORTER's t8 layer D (Tracking 30 on characters 2–4 by index, a Subtract selector +over the last 20 %) puts every glyph where AE does (it ended 88 px short). The "Tracking In" text +preset now animates 40 → 0 px (was 400 → 0 thousandths of an em). Lottie export is unchanged (not +checked against a player). Regression tests: `animator_tracking_is_in_pixels_as_in_after_effects`, +`modes_combine`. + +## Per-character 3D rotation order + +Measured on 19 October 2026 with After Effects 2026 (26.2) (case 931: "H" at 400 px, per-character +3D, animator X / Y / Z Rotation 40 / 30 / 20): a character turns as a 3D layer does, Rx · Ry · Rz +(Z first). EffectCraft composed Rz · Ry · Rx, so the glyph faced another way (23 of 5184 grid points +off, max 255); now 3 at the anti-aliased edge (mean 0.015). Regression test: +`per_character_rotation_turns_z_first_as_a_3d_layer`. + ## Rectangle path start point Measured on 19 October 2026 with After Effects 2026 (26.2) on 400 × 300 shape rectangles with Trim diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index e10a242fa..50dff8aff 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -49,7 +49,7 @@ trips and renders, checked by tests, not against After Effects), **partial** (na | BMP | R | yes | — | good | | WebP | — | yes (still; animated WebP as footage with its frames, timing and alpha, #762) | — | beyond After Effects | | Targa `.tga` | R/W | yes (still and sequence; alpha interpreted on import) | yes (sequence; uncompressed 24-bit RGB or 32-bit RGB + Alpha, straight or premultiplied) | good: import (#745); output reads back through it unchanged; the alpha is not labelled in the file (no TGA 2.0 extension area), so it is interpreted on import | -| DPX / Cineon `.dpx` `.cin` | R/W | yes (still and sequence; 8/10/12/16-bit luma, RGB, RGBA, both byte orders; log code values shown as stored) | none | partial: import (#756); no Interpret Footage ▸ Cineon Settings (log to linear), no DPX output; common in film VFX | +| DPX / Cineon `.dpx` `.cin` | R/W | yes (still and sequence; 8/10/12/16-bit luma, RGB, RGBA, both byte orders; log code values shown as stored, or converted by Interpret Footage ▸ Cineon Settings ▸ Logarithmic Conversion) | none | partial: import (#756) and Cineon Settings; no DPX output; common in film VFX | | Radiance `.hdr` | R/W | yes (still and sequence, 32-bit linear float like OpenEXR) | none | good for import (#745); no Radiance output | | SGI, PICT, ElectricImage, RLA/RPF images | R | none | none | Legacy; RPF camera data import exists (`keys.rpfCameraImport`) | | Camera Raw (DNG, CR3, NEF…) | R (Camera Raw) | none | none | LightCraft has decoders that could be shared | diff --git a/docs/footage.md b/docs/footage.md index bd99cbeb2..f7f1df21b 100644 --- a/docs/footage.md +++ b/docs/footage.md @@ -94,16 +94,21 @@ used, and layers that ran to the end of the footage keep doing so. | Fields, pixel aspect, loop | As in After Effects. | | **Assign Profile**, **Interpret As Linear Light** | The colour profile the file's values are in (the working space converts from it), and whether they are linear light. | | **Preserve RGB** | The file's values enter the composition unconverted: no colour profile, linear light or working-space conversion, and float files (OpenEXR, float TIFF) keep their linear values instead of being sRGB-encoded. Use it to convert chosen layers yourself with an OCIO effect, or to read data passes (depth, mist, normals) as numbers. | +| **Cineon Settings** (DPX and Cineon files) | With *Logarithmic Conversion* on, the file's log code values are converted to the *Current Gamma* (1.7 by default; 1 gives linear light): the *10 Bit Black Point* (95) becomes the *Converted Black Point* (0), and with *Highlight Rolloff* at 0 the *10 Bit White Point* (685) becomes the *Converted White Point* (1023). *Highlight Rolloff* (20) bends the top 20% of the converted range into a curve, as the Cineon Converter effect does: the white point then converts to a level below the Converted White Point, and brighter code values stay below it. At 0, a 32 bpc project keeps over-range values. *Units* shows the converted points as 10-bit, 8-bit or float values. With Logarithmic Conversion off (the default), the code values are shown as stored. | Settings ▸ Import ▸ **Report Missing Frames** (on by default) lists the missing frame numbers when a sequence imports; Interpret Footage lists them too. **File ▸ Interpret Footage ▸ Remember Interpretation / Apply Interpretation** copy the alpha, -fields, pixel aspect, loop and colour settings (Preserve RGB too) to other footage. +fields, pixel aspect, loop and colour settings (Preserve RGB too, and Cineon Settings between +DPX and Cineon files) to other footage. Agents: `file.interpretFootage {"items": [id], "frameRate": 24, "alpha": "premultiplied", "startTimecode": 1001, "preserveRgb": true}` (timecode or a frame number; `"overrideStart": -false` goes back to Use Source File Timecode). +false` goes back to Use Source File Timecode). Cineon Settings: `{"logConversion": true, +"tenBitBlack": 95, "tenBitWhite": 685, "currentGamma": 1.7, "highlightRolloff": 20, +"cineonUnits": "8bit", "convertedBlack": 0, "convertedWhite": 255}` (converted points in +`cineonUnits`, else the footage's Units). ## Multi-layer OpenEXR @@ -129,9 +134,10 @@ including files overwritten at the same path and byte size. and 3D models are not sources, and a composition can't be nested in itself. Alt-dragging onto a layer is one layer at a time, as in After Effects. Agents: `layer.replaceSource {"item": id, "layers": [ids]}`. -- **File ▸ Replace Footage ▸ File…** (Ctrl+H) points the item at another file. Pick a numbered - file to replace it with that file's whole sequence (`file.replaceFootage {"path": …, - "sequence": false}` for one frame). +- **File ▸ Replace Footage ▸ File…** (Ctrl+H) points the item at another file. A still stays a + still, and a sequence item keeps bringing the new numbered file's run; pass + `sequence: true` to bring a numbered still's whole run (`file.replaceFootage {"path": …, + "sequence": true}`), or `sequence: false` for one frame. - **Replace with Precomposition (used N times)**, right-clicking a footage item or solid in the Project panel, puts the item in a new composition of its size, pixel aspect and frame rate, in the same folder, and switches every layer in every composition diff --git a/docs/gaps.md b/docs/gaps.md index 0dbaa54fa..566d1edfa 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -37,7 +37,7 @@ because 179 issues were closed with regression tests between 5 and 10 October. | UI/UX fidelity | ≈ 65% | 60–120 | [ui-parity.md](ui-parity.md) | | File formats | ≈ 50% | 130–230 | [file-format-parity.md](file-format-parity.md) | | Hardware | ≈ 40% | 60–120 | [hardware-parity.md](hardware-parity.md) | -| Localization | ≈ 18% | 60–110 + review | [localization-parity.md](localization-parity.md) | +| Localization | ≈ 24% | 60–110 + review | [localization-parity.md](localization-parity.md) | | Stability | ≈ 45% | 40–80 | 23 never-crash PRs landed on 4 October and the "Never crash" rules bind every crate, but crash reports are open (#580 Chromatic Aberrations pan/tilt, #501 crash on startup, #426 timeline drag while playing, #341 quit on Intel Mac, #234 macOS 27 exit, #879 `--gpu` export aborts on a failed allocation; #591, #306 and #511 are closed), and no CI workflow runs `cargo xtask ci` on pull requests | | Real-user experience | ≈ 60%, uneven by platform | (in G2) | 179 issues closed 5–10 October (as counted on 10 October), nearly all with regression tests ([log below](#real-user-report-log-510-october)); 216 open and 328 closed on 12 October, many from Linux and Windows. Most checking still happens on macOS | | Performance | unknown against After Effects, ≈ 35% | 50–100 | Internal numbers only (e.g. Advanced 3D 290 ms/frame at 1080p on the GPU on an M4 Pro). Users report stutter on cached frames (#595, #304; the RAM preview now prefetches nearest the playhead, 271000b2), late audio (#504, #335); slow scrubbing (#329, closed by #784) and #554 are closed. No 4K / hundreds-of-layers project tested | @@ -67,13 +67,13 @@ are grouped by area; the issue tracker has the rest. | 8 | **Mask and shape bugs** | Open: #1004 mask vertex multi-select, #999 auto-pan, #1042 / #1033 vertex nodes under the bounding box, #959 / #960 shape group toggles and operators. Now closed: #510, #513, #527, #509, #303, #553, #932 (Convert To Bezier Path on a shape layer's parametric path) | Masks are core compositing | 10–20 h | G2 · [target-app-parity.md](target-app-parity.md) | | 9 | **Effect behaviour bugs** | #580, #463 Curl Noise, #369 Motion Tile output size, #943 Turbulent Displace Evolution, #473 Cryptomatte selection (quoted names with commas fixed, 3b7f1ace; UI and GPU time open), #474 multi-layer EXR (decode shared, faffad3a; f16 storage and cache sizing open). Closed: #526, #514, #533–#536 | Each one breaks a look someone built | 10–20 h | G1/G2 · [effects-parity.md](effects-parity.md) | | 10 | **Colour pipeline differences** | #340 ProRes 4444 tagged bt709 but sRGB-encoded (open); ACES 2.0 / OCIO 2.5 configs (26.5) missing; now closed: #494 32-bpc blend modes, #495 and #750 Working Gamma 2.4, #546 / #547 viewer colour | Wrong colour in delivery is a client-visible failure | 15–30 h | G1 · [file-format-parity.md](file-format-parity.md) | -| 11 | **Text rendering and layout** | Open: #453 CJK text in comps, #316 paragraph alignment inverted (maintainers' tests show Left/Right correct; asked for a project), #417 soft at Fit (mostly fixed by #444), #550 toolbar font options. Closed: #483, #583 | Text is in almost every motion graphic | 10–20 h | G2 · [ui-parity.md](ui-parity.md) | +| 11 | **Text rendering and layout** | #453 CJK text in comps: the Han fallback now reaches the installed Chinese faces; open: #316 paragraph alignment inverted (maintainers' tests show Left/Right correct; asked for a project), #417 soft at Fit (mostly fixed by #444), #550 toolbar font options. Closed: #483, #583 | Text is in almost every motion graphic | 10–20 h | G2 · [ui-parity.md](ui-parity.md) | | 12 | **3D interaction and depth** | No 3D transform gizmos (#392, #347); camera aperture (#260); reflections (#261); extruded surface materials beyond Front / Bevel / Side / Back Color (fb5a5e9a; #315 closed); #307 reopened (the viewer's renderer button now shows and switches the renderer, #645); Z position (#489) closed | Advanced 3D is After Effects' growth area since 2023 | 20–35 h | G2 · [ui-parity.md](ui-parity.md) | | 13 | **3D model formats and materials** | After Effects 26 imports FBX, USD/USDZ, STL besides glTF and OBJ (`usd_plugins`), and Substance 3D materials with mesh displacement (26.0–26.2); we read glTF, OBJ, STL (binary and ASCII, with facet colours) and FBX 7.x (binary and ASCII: meshes, hierarchy, Lambert/Phong materials and textures; no FBX animation or skinning yet) | Product shots and 3D scenes come as FBX/USDZ | 25–45 h | — · [file-format-parity.md](file-format-parity.md) | | 14 | **Object Matte** (26.2, disk cache in 26.5) | First slice (#783): the Object Matte effect (Effect ▸ Matte) and `objectMatte.apply` turn a point (and optional box) on a reference frame into a matte and follow the object up to 600 frames each way, or segment every frame on its own; Roto Brush's engine, cache and model (MobileSAM from Settings ▸ Roto Brush, classical graph cut without it). Since then: the Object Matte tool in the viewer (click / drag a box, hover crosshair), the box as the model's box prompt, background tracking with progress and cancel, Freeze, and segmentations kept in the disk cache (26.5). Missing: a comparison with After Effects ([plan](fidelity/object-matte.md)) | The new one-click roto | 10–20 h | G7 · [target-app-parity.md](target-app-parity.md) | | 15 | **Hardware video decode and encode** | No VideoToolbox, NVENC, Quick Sync or VA-API; #498 asks for it (answered: CUDA / NVENC need Nvidia's proprietary SDK through FFI, which the no-`unsafe` rule forbids); open PR #819 adds hardware decoding via `filmcraft-platform`; FilmCraft has VideoToolbox / NVENC / VA-API paths to borrow | 4K playback and export speed | 25–45 h | G5/G6 · [hardware-parity.md](hardware-parity.md) | -| 16 | **Localization depth** | Menus in 11 languages (ja, zh-hans, zh-hant, uk, es, pt-br, ru, fr, de, cs, ka: 628 rows each); panels in 7 (ja, zh-hans, zh-hant, uk, pt-br, cs, ka); effect names translated in Simplified Chinese and Georgian (307, plus the generated Effect menu), their ~1,650 parameter names untranslated; Korean and Italian (After Effects ships them) absent; Hangul probe added (#599 closed) | After Effects is used heavily in Japan, China, Korea, Brazil, Europe | 60–110 h + native review | G9 · [localization-parity.md](localization-parity.md) | -| 17 | **Media Browser lists files import refuses** | `media_browser.rs` lists AVI; `media::is_importable` has none of them (Targa and Radiance HDR import since #745; DPX and Cineon since #756, log shown as stored: no Cineon Settings conversion yet; MXF OP1a / OP-Atom since #767; FilmCraft has no AVI demuxer); #431 and #324 closed | Confusing failures on common VFX formats | 15–30 h | G6 · [file-format-parity.md](file-format-parity.md) | +| 16 | **Localization depth** | Menus in 11 languages (ja, zh-hans, zh-hant, uk, es, pt-br, ru, fr, de, cs, ka: 630 rows each, 629 in Georgian); panels in 7 (ja 996 rows, zh-hans 998, zh-hant 951, uk 524, pt-br 695, cs 518, ka 885); effect names translated in Simplified Chinese (307), Japanese (297) and Georgian (307, plus the generated Effect menu), their ~1,650 parameter names untranslated; Korean and Italian (After Effects ships them) absent; Hangul probe added (#599 closed) | After Effects is used heavily in Japan, China, Korea, Brazil, Europe | 60–110 h + native review | G9 · [localization-parity.md](localization-parity.md) | +| 17 | **Media Browser lists files import refuses** | `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 unless Interpret Footage ▸ Cineon Settings converts it; MXF OP1a / OP-Atom since #767; FilmCraft has no AVI demuxer); #431 and #324 closed | Confusing failures on common VFX formats | 15–30 h | G6 · [file-format-parity.md](file-format-parity.md) | | 18 | **Relative paths and relinking** | Projects store absolute footage paths; no relink when a project moves | Moving or sharing a project breaks it | 6–12 h | G4 · [file-format-parity.md](file-format-parity.md) | | 19 | **Plug-in ecosystem** | No After Effects SDK hosting (#600, #327 OpenFX, #334, #376 plug-in manager); our WebAssembly API has no third-party plug-ins | Many pro projects depend on Trapcode, Element, Saber… | 40–80 h for API growth; SDK hosting needs an owner decision | G8 · [scripting-parity.md](scripting-parity.md) | | 20 | **Multi-monitor and video output** | #486: on Windows, macOS and X11 every floating panel is an OS window of its own (Undock Panel, a drag out of the main window, panel menu ▸ Move Panel to New Window, `window.panelWindow`) and so can go onto another screen (on Wayland, the web and in embedded viewports floating panels stay in the app window, since Wayland reports no window positions); tabs and groups drag between windows, shortcuts work in them, and the current and named workspaces restore their places; still missing: Mercury Transmit to video hardware (we have a second preview window only) | Two-screen setups are standard for professionals | 10–20 h | G2 · [hardware-parity.md](hardware-parity.md) | @@ -169,6 +169,11 @@ The most important missing piece: it turns every other estimate here into a meas 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 [#1306](https://github.com/storytold/effectcraft/issues/1306) (from + #1072): the `ui_expression_values` tests, checked headlessly on Windows, read the colour the + values are painted in. Expression-driven vectors, angles and scalars are red in the Timeline, + Effect Controls and Properties panel, in dark and light themes, hovered too; disabled, removed + and empty expressions show the ordinary colour. - 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 @@ -305,8 +310,11 @@ Issues #41–#47 (all from the Linux AppImage 0.1.1, 5 October): panning the vie on the same extension points as After Effects: scripts, ScriptUI panels and effect plug-ins ([plugins.md](plugins.md#extension-points-compared-with-after-effects) compares them). ScriptUI panels in the user's ScriptUI Panels folder are listed in the Window menu, dock like any panel - and open again at the next launch. Missing: the Startup and Shutdown script folders, keyboard - shortcuts for scripts and `.jsxbin`; `#include` / `#target` directives landed (#759, + and open again at the next launch. The scripts in the Startup folder run at launch, and the desktop + app runs the Shutdown folder's scripts as it quits. Each File ▸ Scripts entry can be given a key in + Edit ▸ Keyboard Shortcuts. Missing: a global scope that scripts share for the session (a Startup + script's variables and functions are not available to later scripts) and `.jsxbin`; + `#include` / `#target` directives landed (#759, [scripting-parity.md](scripting-parity.md)). - Done so far: ease presets (#254) cover what ease-curve plug-ins are used for: easing curves kept by name and applied to pairs of keyframes. Modelled on a third-party panel, they are not core: a diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 0a0a85a97..c25da4928 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -23,7 +23,7 @@ monitors are missing, and two GPU start-up failures are open. | 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 | 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 | — | — | — | — | +| Touch / trackpad gestures | pinch zoom in viewers | Composition and Layer viewers: two-finger scroll pans, pinch zooms around the pointer (Alt + scroll and the mouse wheel zoom too); Timeline pinch zooms time | two-finger scroll pans once it moves diagonally (a pixel-precise wheel keeps zooming); pinch arrives as Ctrl + scroll and zooms | same | same | No rotate or swipe gestures; tested in `ui_viewer_trackpad.rs` (UI logic, not on hardware) | | Audio hardware | Core Audio / ASIO / WASAPI, device choice, latency | cpal output; preview sound late after a start offset (#504 open; #554 closed) | 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 | | Memory management | RAM reserve, disk cache | RAM Reserved, Reduce Cache When Low on Memory, disk cache | same | same | OPFS disk cache | good | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index e980fe50f..59b3f792c 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-23 · **Change:** minor (Georgian: menus, panels and effect names; missing strings show in English; `missing_translations_report` lists them) · **Target:** Adobe After Effects 2026 +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-30 · **Change:** minor (Polish (`pl`): menus 630, panels 695; recounted at this tree after #1774: Japanese 1,923 of ≈ 4,200 interface strings (46%, with 297 effect names), Simplified Chinese 1,935 (46%, with 307 effect names), Traditional Chinese 1,581 (38%, its panel catalog 951 rows after #2244); overall ≈ 24%; previously: minor, the panel/dialog catalogs grow by 303 rows in Japanese, Simplified Chinese and Traditional Chinese: panel tabs, form and dialog labels, context menus, tracker/graph/text-panel labels, credits and File-panel footers; previously: minor, catalogs recounted at `upstream/main` 470ea7a3: menus in 10 languages, panels in 6, Hangul probe (#599)) · **Target:** Adobe After Effects 2026 How much of EffectCraft's interface is translated, language by language, compared with After Effects. [gaps.md](gaps.md) gap 16 and workstream G9 hold the work; @@ -17,28 +17,31 @@ interface; Arabic and Hebrew text *in layers* is supported through its Middle Ea ## How EffectCraft translates - **Menus:** `crates/ui-egui/src/i18n.rs`, one catalog per language keyed by - `(command, English label)` (624 catalog rows each at 470ea7a3, counted). A test - (`menu_catalogs_have_no_stale_or_duplicate_rows`) fails on a row whose entry left the menu tree - or on a repeated row. The generated Effect menu is not part of that fixed tree: its entries - and category submenus take their labels from the effect catalog below. + `(command, English label)`. A test (`menu_catalogs_have_no_stale_or_duplicate_rows`) fails on a + row whose entry left the menu tree or on a repeated row, so every catalog covers all fixed + entries (630 catalog rows each at this tree, counted; 629 in Georgian). The generated Effect menu is not part of + that fixed tree: its entries and category submenus take their labels from the effect catalog + below. - **Panels, dialogs, buttons, tooltips:** `crates/ui-egui/src/i18n/ui.rs`, keyed by the English - source string, with English fallback. Catalogs exist for Japanese (693 rows), Simplified and - Traditional Chinese (695 and 648 since #2244 pruned the Traditional one), Ukrainian (524), - Brazilian Portuguese (695) and Czech (518), counted at this tree. Georgian has every row the - Simplified Chinese catalog has, plus 191 for panels converted to the catalog. + source string, with English fallback. Catalogs exist for Japanese (996 rows), Simplified + Chinese (998), Traditional Chinese (951, after #2244 pruned the table), Ukrainian (524), + Brazilian Portuguese (695), Czech (518) and Spanish (513); counted at this tree. Georgian has + the Simplified Chinese catalog's 694 rows, plus 191 for panels converted to the catalog since + (885 in all). - **Effect names:** `crates/ui-egui/src/i18n/effects.rs`, keyed by **effect id**, in Simplified - Chinese and Georgian (307 rows, one per effect in the registry, plus its 21 categories). The registry's + Chinese (307 rows, one per effect in the registry), Japanese (297 of the 307 ids) and Georgian + (307 rows, plus its 21 categories). The registry's English name stays the effect's identity: `effect.list`, `effect.apply {"effect": …}`, scripts and expressions match on it, and the property tree stores it as the effect group's name. Only the drawing — the Effect menu, Effect Controls, the Timeline and Effects & Presets — goes through the catalog, so a project file is identical whichever interface language opens it. A repeat (`Gaussian Blur 2`) keeps the index the engine appends, and a group the user renamed in Effect Controls stays verbatim. Adding a language there is two tables and one arm, and no - panel changes: Traditional Chinese and Japanese have their tables in place and empty, so those - languages draw English until a translator fills them (the Japanese set for #821 is what that - slot is for). Each language decides for itself which names stay English — Adobe's Japanese - interface leaves the `CC`, `VR` and `OCIO` sets in English while its Chinese one translates - them — so a row repeating the English name is a choice, not a missing row. + panel changes: Traditional Chinese has its table in place and empty, and Japanese fills 297 of + the 307 ids, so the rest draw English until a translator completes them. Each language decides + for itself which names stay English — Adobe's Japanese interface leaves the `CC`, `VR` and + `OCIO` sets in English while its Chinese one translates them — so a row repeating the English + name is a choice, not a missing row. - **Missing strings:** a string with no row in the interface language shows in English, and no test, build or release fails on a missing translation. The `missing_translations_report` test (`crates/ui-egui/src/i18n.rs`) prints, per language, the fixed menu entries, panel strings, effect @@ -60,49 +63,52 @@ interface; Arabic and Hebrew text *in layers* is supported through its Middle Ea ## Measurement -Measured 10 October 2026 at `origin/main` 1446e27 and recounted on 12 October at `upstream/main` -470ea7a3 by counting catalog rows (`const` tables in `crates/ui-egui/src/i18n.rs` and -`i18n/ui.rs`; menu rows 624 per language, so the share is computed from 624 rather than 618). The -denominator, **≈ 3,900 user-visible strings**, was estimated on 10 October and is kept: 618 fixed menu entries + ≈ 1,320 capitalised UI -literals in `crates/ui-egui/src` outside tests and catalogs (a regex count, so an upper bound) + -≈ 1,650 distinct effect parameter names + 306 effect names. Engine messages are not counted, so -the true percentages are slightly lower. +Measured 10 October 2026 at `origin/main` 1446e27, recounted on 22 October 2026 at `ad8b6acb`, +and recounted again at this tree after #1774 by counting catalog rows (`const` tables in +`crates/ui-egui/src/i18n.rs`, `i18n/ui.rs` and `i18n/effects.rs`: 630 menu rows, 998 +panel/dialog sources in Simplified Chinese, 951 in Traditional Chinese and 996 in Japanese). The +denominator, **≈ 4,200 user-visible strings**, is a rounded estimate: the 630 fixed menu entries, +the 998 panel/dialog sources, 307 effect names, ≈ 1,650 distinct effect parameter names and +≈ 620 other unwrapped interface strings (Home screen literals, engine-built tooltips, error and +status messages). It counts distinct strings, not catalog rows; engine messages are only partly +counted, so the true percentages are slightly lower. | Language | Code | UI strings translated | Dialogs / tooltips / help | Script support | Native review | Status | To `full` | |---|---|---|---|---|---|---|---| -| English | `en` | ≈ 3,900 (100%, source) | All; docs in English | — | yes | full | — | -| Simplified Chinese | `zh-hans` | 1,324 (≈ 34%): menus 629 rows, panels 695 | Panels and dialogs at the zh source list; no help, effect names translated (307) and their parameter names not; Home screen literals unwrapped (#574, closed) | System CJK fallback; Han follows the UI language; CJK text in comps renders wrongly (#453); IME in text layers | no | partial | 6–9 h + review | +| English | `en` | ≈ 4,200 (100%, source) | All; docs in English | — | yes | full | — | +| Simplified Chinese | `zh-hans` | 1,935 (≈ 46%): menus 630 rows, panels 998, effect names 307 | Panels and dialogs partly; no help, effect names translated and their parameter names not; Home screen literals unwrapped (#574, closed) | System CJK fallback; Han follows the UI language; CJK text in comps renders wrongly (#453); IME in text layers | no | partial | 6–9 h + review | | Spanish | `es` | 1,142 (≈ 29%): menus 629 rows, panels 513 | Menus only; the panel catalog from #996 | Latin, fine | no | partial | 9–12 h + review | | Hindi | `hi` | 0 | — | UI: Devanagari fallback font but no shaping; text layers shape Devanagari | no | none (After Effects: none) | 9–13 h + shaping in the UI | | Arabic | `ar` | 0 | — | UI: Arabic fallback font but no shaping and no RTL layout; text layers: bidi + shaping | no | none (After Effects: none) | 9–13 h + 15–25 h RTL UI | | French | `fr` | 624 (≈ 16%) | Menus only | Latin, fine | no | menus only | 7–10 h + review | | Portuguese (Brazil) | `pt-br` | 1,324 (≈ 34%): menus 629 rows, panels 695 | Panels and dialogs at the Chinese catalogs' source list; the six keys main added after that count are translated too; AI-assisted, not yet native-speaker proofread | Latin, fine | no | partial | independent native review | | Indonesian | `id` | 0 | — | Latin, fine | no | none (After Effects: none) | 8–12 h + review | -| Japanese | `ja` | 1,322 (≈ 34%): menus 629 rows, panels 693 | Panels and dialogs partly | Japanese system fallback; vertical text and Tate-Chu-Yoko in text layers; IME | no | partial | 6–9 h + review | +| Japanese | `ja` | 1,923 (≈ 46%): menus 630 rows, panels 996, effect names 297 | Panels and dialogs partly; no help, effect names translated for 297 of the 307 ids and their parameter names not; Home screen literals unwrapped (#574, closed) | Japanese system fallback; vertical text and Tate-Chu-Yoko in text layers; IME | no | partial | 6–9 h + review | | German | `de` | 624 (≈ 16%) | Menus only (open PR #666 adds interface translations) | Latin, fine | no | menus only | 7–10 h + review | | Korean | `ko` | 0 | — | Hangul fallback probe added (#599, closed); not checked on macOS here | no | none | 9–13 h + review | | Vietnamese | `vi` | 0 | — | Latin with stacked diacritics; untested | no | none (After Effects: none) | 8–12 h + review | -Other languages EffectCraft ships (4): +Other languages EffectCraft ships (5): | Language | Code | UI strings translated | Status | To `full` | |---|---|---|---|---| -| Traditional Chinese | `zh-hant` | 1,277 (≈ 33%): menus 629 rows, panels 648 | partial | 6–9 h + review | +| Traditional Chinese | `zh-hant` | 1,581 (≈ 38%): menus 630 rows, panels 951 | partial | 6–9 h + review | | Ukrainian | `uk` | 1,153 (≈ 29%): menus 629 rows, panels 524 | partial | 6–9 h + review | +| Polish | `pl` | 1,325 (≈ 34%): menus 630 rows, panels 695 | partial; unwrapped UI, effects and engine text still in English; AI-drafted, native review welcome | Remaining strings + native review | | Czech | `cs` | 1,147 (≈ 29%): menus 629 rows, panels 518 | Existing catalog entries covered; unwrapped UI, effects and engine text still in English; AI-assisted, not yet native-speaker proofread | Remaining strings + independent native review | -| Georgian | `ka` | 1,514 (≈ 39%): menus 628 rows, panels 886 (the Chinese catalogs' 695 plus 191 Georgian-only rows: form dialogs, context menus, column headers), effect names 307 + 21 categories; native-speaker reviewed | partial | 5–8 h | +| Georgian | `ka` | 1,514 (≈ 39%): menus 629 rows, panels 885 (the Simplified Chinese catalog's 694 plus 191 Georgian-only rows: form dialogs, context menus, column headers), effect names 307 + 21 categories; native-speaker reviewed | partial | 5–8 h | -Russian (`ru`) has a menu catalog (624 rows, ≈ 16%); After Effects also ships Italian, which EffectCraft does not (8–12 h). +Russian (`ru`) has a menu catalog (630 rows, ≈ 15%); After Effects also ships Italian, which EffectCraft does not (8–12 h). -**Overall: ≈ 21%**, the average coverage of After Effects' 10 non-English languages -(Simplified Chinese, Traditional Chinese, Japanese and Brazilian Portuguese 34%, Spanish 29%, -French, German and Russian 16%, Korean and Italian 0%; 212 / 10, was ≈ 12% on 10 October; -Ukrainian (≈ 29%) and Czech (≈ 29%) are EffectCraft-only and stay out of that average). This is the localization row in -[target-app-parity.md](target-app-parity.md#by-dimension). +**Overall: ≈ 24%**, the average coverage of After Effects' 10 non-English languages +(Simplified Chinese 46%, Traditional Chinese 38%, Japanese 46%, Brazilian Portuguese 34%, +Spanish 29%, French, German and Russian 16%, Korean and Italian 0%; 241 / 10, was ≈ 21% on +22 October; Ukrainian (≈ 29%) and Czech (≈ 29%) are EffectCraft-only and stay out of that +average). This is the localization row in [target-app-parity.md](target-app-parity.md#by-dimension). **Effort.** One-time work first: route parameter names and engine messages through a catalog, wrap the remaining panel literals, and add a Hangul probe (10–20 h). Then each language -needs ≈ 2,900 more strings (≈ 5–9 h per language at the pace of #398 and #401). Parity with After +needs ≈ 2,600 more strings (≈ 5–9 h per language at the pace of #398 and #401). Parity with After Effects' 10 non-English languages: **≈ 60–110 h**; the four extra languages of the standard list (Hindi, Arabic, Indonesian, Vietnamese) add ≈ 50–80 h, most of it right-to-left layout and complex shaping in the interface. Every language needs native-speaker review, which only a human @@ -112,9 +118,12 @@ can give; only Georgian, contributed by a native speaker (#1843), has had it. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | Recounted on the merged tree after #1774: Japanese 1,923 (46%, 297 effect names), Simplified Chinese 1,935 (46%, 307 effect names), Traditional Chinese 1,581 (38%, 951 panel rows after #2244's prune); overall ≈ 24% | +| 2026-10-22 | minor | Panel/dialog second step: 300 more catalog rows in Japanese, Simplified Chinese and Traditional Chinese, plus the three rows the review found missing (Basic, Advanced and Play/Stop) — panel tabs, form and dialog labels, panel context menus, the tracker and graph controls, text-panel options, File-panel footers and credits; the panel tables are now 996 Japanese and 998 Simplified Chinese rows, all drawn through `tr`/`tr_args`, with the tests keeping the three catalogs listing the same sources; the 30 October recount above carries the share | | 2026-10-18 | minor | Brazilian Portuguese completes its panel catalog (278 new rows, 417 → 695, the Chinese catalogs' source list); the doc recount closes the drift the batch merges left in every language's panel count; AI-assisted, not yet native-speaker proofread | -| 2026-10-12 | minor | Recounted: Russian, French, German and Czech menus (#873, #987), Brazilian Portuguese and Czech panels (#830, #987), Hangul probe (#599); overall ≈ 12% to ≈ 18%; Korean and Italian still absent | | 2026-10-14 | minor | Georgian (`ka`): menus 628, panels 886 (191 Georgian-only, in `GEORGIAN_PANELS`), effect names and categories in `effects.rs` | -| 2026-10-10 | major | First localization parity document: catalog rows counted for all six languages, the twelve-language table, After Effects' 11 shipped languages from its bundle | -| 2026-10-11 | minor | Ukrainian panel catalog brought level with the Chinese ones (495 of 496 rows, counted at this commit); its share rises to ≈ 29%. The other languages' panel counts are the 10 October measurement | +| 2026-10-29 | minor | Polish (`pl`): menus 630, panels 695 | | 2026-10-13 | minor | Effect names and the generated Effect menu leave English: `i18n/effects.rs` adds 306 rows keyed by effect id (Simplified Chinese, from After Effects 2026's interface and Adobe's Chinese documentation for the `CC` series) and the 21 category names. Parameter names are still English; no other language has rows | +| 2026-10-12 | minor | Recounted: Russian, French, German and Czech menus (#873, #987), Brazilian Portuguese and Czech panels (#830, #987), Hangul probe (#599); overall ≈ 12% to ≈ 18%; Korean and Italian still absent | +| 2026-10-11 | minor | Ukrainian panel catalog brought level with the Chinese ones (495 of 496 rows, counted at this commit); its share rises to ≈ 29%. The other languages' panel counts are the 10 October measurement | +| 2026-10-10 | major | First localization parity document: catalog rows counted for all six languages, the twelve-language table, After Effects' 11 shipped languages from its bundle | diff --git a/docs/plugins.md b/docs/plugins.md index 2117152a1..3b9182a60 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -16,9 +16,9 @@ Guide): | File ▸ Scripts ▸ Run Script File… (`.jsx`, `.jsxbin`) | `.jsx` / `.js` | Gap: `.jsxbin` (an undocumented binary encoding) | | File ▸ Scripts ▸ Recent Script Files: the 10 most recently run scripts; Cmd+Option+Shift+D (Ctrl+Alt+Shift+D on Windows) reruns one | File ▸ Scripts ▸ Recent Script Files lists the 10 scripts run last (script files and File ▸ Scripts entries, newest first, kept in the settings); Cmd+Alt+Shift+D runs the most recent one again (`file.runScript {"recent": 0}`, a key that can be changed in Edit ▸ Keyboard Shortcuts) | Same | | ExtendScript preprocessor directives (`#include`, `#target`, `#targetengine`) | `#include` / `//@include` / `#includepath` inline files relative to the script (once each, depth-limited, file gate applies); `#target`, `#targetengine`, `#strict` are ignored | Same | -| `Scripts/Startup` and `Scripts/Shutdown`: run in alphabetical order when the app starts and quits | Not run | Gap | +| `Scripts/Startup` and `Scripts/Shutdown`: run in alphabetical order when the app starts and quits | `Scripts/Startup/` and `Scripts/Shutdown/` in the settings folder, in alphabetical order (case ignored): the Startup scripts run at launch before a project opens, and the Shutdown scripts run as the desktop app quits, after the project is closed. A Startup script that fails is reported in a toast and in the log, a Shutdown script that fails is reported in the log, and the next script still runs | Partial: each script runs in its own global scope, so variables and functions a Startup script defines are not available to later scripts; a Startup script's modal dialog stays open while the next scripts run and the app starts; a Shutdown script's modal dialogs close as if canceled; the web app runs no Shutdown scripts | | `Scripts/ScriptUI Panels`: listed at the bottom of the Window menu, dockable like the app's own panels, `this` is the panel | `Scripts/ScriptUI Panels/` in the settings folder (File ▸ Scripts ▸ Install ScriptUI Panel… copies one there), listed at the bottom of the Window menu (a subfolder, one level, is a submenu named after it), docked like any panel, `this` is the panel; the panels open when the app quits open again at the next launch | Same. Where a panel was docked isn't kept: workspace layouts aren't saved between launches | -| Keyboard shortcuts for scripts (the Keyboard Shortcuts editor lists the scripts of the Scripts folder) | Shortcuts bind to commands and menu entries; the File ▸ Scripts and Window menu entries of scripts aren't offered | Gap | +| Keyboard shortcuts for scripts (the Keyboard Shortcuts editor lists the scripts of the Scripts folder) | Edit ▸ Keyboard Shortcuts lists the scripts of File ▸ Scripts under File ▸ Scripts (`file.runScript {"name": ""}`); a key given to one is saved with the preset, File ▸ Scripts shows it, and pressing it runs the script as its menu entry does | Same. After Effects binds a key to a script's place in the alphabetical list (`ExecuteScriptMenuItem01`…), and EffectCraft binds it to the script's file name. The Window menu entries of ScriptUI panels aren't offered | | Running a script from the command line (`afterfx -r script.jsx`) | `effectcraft-cli script`, the control channel's and MCP's `script.run` | Same purpose, other entry points | | `app.settings` (kept in the preferences between sessions) | Kept in `script_settings.json` in the settings folder (`script.settings.get` / `script.settings.save`) | Same | | Allow Scripts to Write Files and Access Network | Settings ▸ Scripting & Expressions | Same | @@ -39,6 +39,10 @@ journaled and the same as the UI's action. The `effectcraft-script` crate's docs file.jsx`, or `script.run {code, name}` over the control channel and MCP. * Install one: File ▸ Scripts ▸ Install Script File… (or copy it into `Scripts/` in the settings folder). It is listed in File ▸ Scripts at once (After Effects asks for a restart). +* Run one at every launch or quit: copy it into `Scripts/Startup/` or `Scripts/Shutdown/` in the + settings folder. These scripts are not listed in File ▸ Scripts. `file.runStartupScripts` and + `file.runShutdownScripts` run them and return `{ran, waiting, failed}`; the desktop app calls them at + launch and quit, and the web app calls `file.runStartupScripts` at launch. * `file.scripts.list` lists installed and bundled scripts and panels; `file.runScript {name}` runs one; `file.uninstallScript {name}` removes an installed one. * A subfolder of `Scripts/` is a submenu of File ▸ Scripts named after the folder, and its scripts diff --git a/docs/preferences.md b/docs/preferences.md index ce96da0bf..af53f12f6 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -42,6 +42,7 @@ Commands (CLI, MCP, control channel): - `startup.windowGraphics`: Window Graphics (`auto` / `gl`), what the desktop window draws with from the next launch. `auto` lets the platform pick (DirectX 12, Vulkan or Metal); `gl` uses OpenGL, with CPU compositing, for graphics drivers that crash with the others. A launch leaves a `launch-pending` marker in the settings folder until its window has been drawing for a few seconds (or the app quits normally before that); a launch that stops earlier (a graphics driver crashing as the window opens or as its surface is re-created) leaves the marker behind, and the next launch switches to `gl` and says so (not on macOS, which has no OpenGL backend). `WGPU_BACKEND` overrides both. - `project.useTemplate`: New Project Loads Template - `project.templatePath`: Template Project +- `project.compactProjectFiles`: Save Compact Project Files (default off). Off: File ▸ Save writes `.ecproj` files as indented JSON, one value per line, easy to read and diff in version control. On: compact JSON on one line, smaller and faster to save and open. Either opens the same; auto-saves, XML copies and template packages are not affected. - `autoSave.enabled`: Automatically Save Projects - `autoSave.intervalMinutes`: Save Every - `autoSave.maxVersions`: Maximum Project Versions @@ -189,7 +190,8 @@ Effect Controls uses compact, fixed 20 pt parameter rows, as After Effects does: Edit ▸ Keyboard Shortcuts (`Cmd+Alt+'`) opens the shortcut editor: an on-screen keyboard showing which keys are assigned for the held modifiers (purple: application-wide, green: panel-specific, both: split), modifier toggles, a searchable list of every command (all engine commands, menu -entries with bound parameters and the frontend's tool / panel commands), and a preset menu. +entries with bound parameters, the scripts File ▸ Scripts lists and the frontend's tool / panel +commands), and a preset menu. - **Presets.** "EffectCraft Default" is After Effects' default layout and is read-only; editing it creates a "Custom" copy. Custom presets store only their differences from the default and diff --git a/docs/scripting-parity.md b/docs/scripting-parity.md index 1d808726a..8d2934518 100644 --- a/docs/scripting-parity.md +++ b/docs/scripting-parity.md @@ -21,12 +21,12 @@ our own scripts; no real-world script library has been run against both apps. | Scripting object model | `app`, `Project`, items, layers, properties, keys, eases, render queue, `TextDocument`, `Shape`, markers, `File`/`Folder`, `Socket`, `app.settings` | Same classes, edits run through undoable engine commands; match names table; layers, groups and properties are callable (`layer("Effects")(1)` is `.property(...)`) | good | `crates/script` tests (≈ 10 test files) | | `$.evalFile`, `BridgeTalk` | Evaluate another script file; identify the host application and message other Adobe applications | `$.evalFile` evaluates a file relative to the calling file (nested at most 32 deep, under the scripts' file gate) and returns its last value; `BridgeTalk` names the host honestly (`appName` `effectcraft`, its version); messaging other applications throws "not supported" | partial | `tests_evalfile.rs` (#760); no inter-application messaging | | ScriptUI | Dialogs, palettes, dockable panels, resource strings, `onDraw` / ScriptUIGraphics, mouse events | Same, dockable panels reopen at launch | good | `ui_scriptui`, `tests_ui*` | -| `app.scheduleTask` / `cancelTask` | One-shot and repeating tasks (polling, panel refresh) until `cancelTask` | Same: repeating tasks run every `delay` ms (at least 50 ms, at most 64 pending per script) until `cancelTask`, until they throw (reported once, then cancelled), or until their owner goes: a script's last window closing, or the project closing for scripts without windows; frontends tick them every frame, agents with `script.runTasks` | good | `tests_tasks.rs` (#773); one-shot tasks of a script file still run right after it, not after their delay | +| `app.scheduleTask` / `cancelTask` | One-shot and repeating tasks (polling, panel refresh) until `cancelTask` | Same: repeating tasks run every `delay` ms (at least 50 ms, at most 64 pending per script) until `cancelTask`, until they throw (reported once, then cancelled), or until their owner goes: a script's last window closing, or the project closing for scripts without windows (a Startup script's tasks go on until EffectCraft quits); frontends tick them every frame, agents with `script.runTasks` | good | `tests_tasks.rs` (#773); one-shot tasks of a script file still run right after it, not after their delay | | Scripts menu, ScriptUI Panels folder | yes | yes; a subfolder (one level) of either folder is a submenu named after it | same | [plugins.md](plugins.md) | -| Startup / Shutdown folders | yes | not run | missing | plugins.md | +| Startup / Shutdown folders | yes | `Scripts/Startup` runs at launch and `Scripts/Shutdown` runs as the desktop app quits, after the project is closed, each in alphabetical order | partial | `crates/script/src/tests/startup.rs`; each script has its own global scope, so a Startup script's variables and functions are not available to later scripts ([plugins.md](plugins.md)) | | `#include`, `#target` directives | yes | `#include` / `//@include` / `#includepath` inline files relative to the including script (once each, nested at most 32 deep, under the scripts' file gate; errors name the included file and line); `#target`, `#targetengine`, `#strict` are ignored | good | `tests_include.rs` (#759); installed scripts and panels have no folder of their own, so they include from the project's folder or absolute paths | | `.jsxbin` | yes | no | out of scope | No public specification | -| Keyboard shortcuts for scripts | yes | no | missing | plugins.md | +| Keyboard shortcuts for scripts | yes: the scripts of the Scripts folder take keys, by their place in the alphabetical list | Edit ▸ Keyboard Shortcuts lists the scripts of File ▸ Scripts; a key stays with the script's file name | same | `file_scripts_entries_take_keyboard_shortcuts`, `a_key_given_to_a_script_runs_it` ([plugins.md](plugins.md)); ScriptUI panels in the Window menu take no key | | Recent Script Files | File ▸ Scripts ▸ Recent Script Files (10 scripts), a key to rerun | Same list, kept in the settings; Cmd+Alt+Shift+D reruns the most recent script | same | `recent_script_files_run_again` ([plugins.md](plugins.md)) | | Command-line rendering `aerender` | yes | `effectcraft-cli render`, `script`, `exec` | same purpose | `apps/effectcraft-cli` | | Essential Graphics scripting hooks | `addToMotionGraphicsTemplate…` | yes (M13.7) | good | — | @@ -35,7 +35,7 @@ our own scripts; no real-world script library has been run against both apps. ## Estimate -**≈ 20–40 h**: `#include` / `#target` (landed, #759) and Startup / Shutdown folders 4–8 h as first estimated (Startup / Shutdown remain), script shortcuts 2–4 h, +**≈ 20–40 h**: `#include` / `#target` (landed, #759) and Startup / Shutdown folders 4–8 h as first estimated (Startup / Shutdown landed; a global scope shared between scripts remains), script shortcuts 2–4 h, a corpus of public scripts and expressions with the fixes it finds 10–20 h, an expression performance benchmark 4–8 h. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 34829d406..770e1390e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -117,7 +117,7 @@ compositing and effects dominate; audio is light). Hours are to full parity for | Project, footage and import | 8% | 92% | 55% | 25–45 | No relative paths or relinking when a project moves; Media Browser lists AVI, which it can't import (TGA/HDR/DPX/MXF import since #745, #756, #767); #431, #324, #552 and #351 are closed | | Compositions, layers and compositing | 13% | 98% | 60% | 30–55 | 32-bpc blend modes differed from After Effects (#494, closed); expand precomps in place (#601); #527, #510, #513, #539 and #540 are closed | | Animation: keyframes, graph editor, motion paths, puppet | 15% | 97% | 55% | 35–60 | Only scalar ease is measured against After Effects (168 samples); puppet lag (#586); #456, #457, #355, #553 and #318 are closed; Proportional Scrubbing (26.2) | -| Expressions and scripting | 7% | 93% | 65% | 20–40 | `.jsxbin`, Startup/Shutdown folders, script shortcuts (`#include` landed, #759); external editor (#364); see [scripting-parity.md](scripting-parity.md) | +| Expressions and scripting | 7% | 93% | 65% | 20–40 | `.jsxbin`, a global scope shared between scripts, script shortcuts (`#include` landed, #759; Startup/Shutdown folders run); external editor (#364); see [scripting-parity.md](scripting-parity.md) | | Shapes, masks and roto | 9% | 93% | 45% | 30–50 | Open mask and shape issues (#1004, #999, #1042, #932); #510, #513, #527, #509 and #303 are closed; paste SVG/AI as shape layers (26.3; SVG markup pastes since #738) | | Text and typography | 7% | 94% | 50% | 20–35 | CJK text in comps renders wrongly (#453); paragraph alignment inverted (#316); soft text at Fit (#417, mostly fixed by #444); toolbar font options (#550); #483 and #583 are closed; variable-font filter landed (#741) | | Effects and animation presets | 14% | 85% | 40% | 70–130 | All 306 effects exist, but rendered output is unmeasured against After Effects; 76 built-in presets (text, Transitions, Backgrounds, Behaviors) against After Effects' 621 ([effects-parity.md](effects-parity.md)); open effect bugs (#580 crash, #463, #369, #473, #474; #514 and #526 are closed); effect colour labels (26.5); see [effects-parity.md](effects-parity.md) | @@ -139,7 +139,7 @@ Feature depth (≈ 50%) feeds the overall *ready* number with the other dimensio | UI/UX fidelity | 65% | 60–120 | After Effects-shaped menus (587 entries), panels, workspaces, shortcuts; many interaction issues still open; multi-monitor only as separate panel windows (#486 open); Effect Controls not yet like 26.5's | [ui-parity.md](ui-parity.md) | | File formats | 50% | 130–230 | `.aep` / `.aepx` / `.mogrt` / `.ffx` can't be read; strong modern codecs (H.264, HEVC, AV1, VP9, ProRes) | [file-format-parity.md](file-format-parity.md) | | Hardware | 40% | 60–120 | wgpu GPU on Metal/Vulkan/Direct3D 12/WebGPU with CPU fallback; no hardware decode/encode; Direct3D 12 compositor fails (#613); no video-out hardware | [hardware-parity.md](hardware-parity.md) | -| Localization | 18% | 60–110 + review | Average coverage of After Effects' 10 non-English languages (measured catalog rows over ≈ 3,900 strings): menus in 10 languages, panels in 6, effects in none; After Effects ships 11 languages fully | [localization-parity.md](localization-parity.md) | +| Localization | 24% | 60–110 + review | Average coverage of After Effects' 10 non-English languages (measured catalog rows over ≈ 4,200 strings): menus in 10 languages, panels in 7, effects in Simplified Chinese and Japanese; After Effects ships 11 languages fully | [localization-parity.md](localization-parity.md) | | Performance | 35% | 50–100 | Internal numbers only; users report stutter and slow scrubbing | [gaps.md](gaps.md#g5-performance-at-real-world-scale) | | Stability | 45% | 40–80 | Open crash reports #580, #501, #426, #341, #234, #879 (#591, #306 and #511 are closed); no CI runs the tests on pull requests | [gaps.md](gaps.md#g3-stability-and-a-green-main) | | Platforms | 65% | 30–60 | Releases for macOS, Windows x64/x86/ARM64, Linux, FreeBSD and the web (more than After Effects), but Linux and Windows users hit basic bugs | [gaps.md](gaps.md#g2-real-user-reliability-on-every-platform) | diff --git a/docs/testing.md b/docs/testing.md index 67d622fd9..d556fdc4f 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -5,6 +5,10 @@ cargo test --workspace # everything cargo xtask ci # fmt, clippy -D warnings, tests, layers, assets, wasm ``` +The `dev` profile keeps debug info to line tables (fast builds, readable backtraces). To debug +interactively — local variables, parameters, column breakpoints — build with the `dev-full` +profile: `cargo run --profile dev-full -p effectcraft -- --demo` (see `CONTRIBUTING.md`). + `cargo xtask ci` builds in the dev profile and runs the tests with `cargo nextest run` (plus `cargo test --doc`) when `cargo-nextest` is installed, and with `cargo test` otherwise. It then runs `missing_translations_report` with `--nocapture`, which prints the interface strings each diff --git a/docs/web.md b/docs/web.md index c18f7e869..1cdecab00 100644 --- a/docs/web.md +++ b/docs/web.md @@ -242,6 +242,25 @@ Window ▸ Media Browser browses a virtual tree (`src/browse.rs`): `mediaBrowser.list` reports the places and these actions; `mediaBrowser.action {action}` runs one. +### Startup project + +By default the page opens the last visit's project, and the demo project on a first visit. A +`startup` key in `effectcraft-config.json` (next to `index.html`) changes that: + +```json +{"startup":"empty"} +``` + +| Value | On load | +|---|---| +| `"last"` (or no key) | the last visit's project; the demo project on a first visit | +| `"empty"` | the last visit's project; a blank project on a first visit, never the demo | +| `"demo"` | the demo project, whatever the last visit left; the session is not saved, so the last visit's project stays as it was | + +The `?empty` and `?demo` URL flags still win. A missing, invalid or non-JSON file, or an unknown +value, is the default. The file is fetched once per page load; serve it with +`Cache-Control: no-cache` (the service worker never caches it). + ### Audio Audio preview goes through **Web Audio**: an AudioWorklet (`audio-worklet.js`; a diff --git a/extensions/scriptui-panels/Ease Presets.jsx b/extensions/scriptui-panels/Ease Presets.jsx index 6f6eadae5..9f4ea2185 100644 --- a/extensions/scriptui-panels/Ease Presets.jsx +++ b/extensions/scriptui-panels/Ease Presets.jsx @@ -18,6 +18,8 @@ // segment on that side. So a selected pair gets the whole curve, and a single key works too. // Roving keys are skipped: they pass speed straight through (their sides stay linear), so an ease // set on them wouldn't play. +// The built-in Linear preset uses Linear interpolation, including on unused endpoint sides +// so a fully linear key has a diamond icon; the other presets use Bezier. // // User presets are kept with app.settings (section "Ease Presets", key "userPresets") as JSON: // {"version": 1, "presets": [{"name": …, "curve": {"outInfluence", "outSpeed", "inInfluence", @@ -29,6 +31,7 @@ var MAX_NAME = 64; var MIN_INFLUENCE = 0.1; var MAX_SPEED = 100; + var LINEAR = KeyframeInterpolationType.LINEAR; var BEZIER = KeyframeInterpolationType.BEZIER; var HOLD = KeyframeInterpolationType.HOLD; @@ -178,16 +181,16 @@ // Ease one side of key `k` with `eases`, leaving its other side as it plays (auto-Bezier and // linear sides keep their shape; a continuous key stops being continuous, its sides now differ). - function easeSide(p, k, out, eases) { + function easeSide(p, k, out, eases, interpolation) { var inType = p.keyInInterpolationType(k), outType = p.keyOutInterpolationType(k); var inEase = p.keyInTemporalEase(k), outEase = p.keyOutTemporalEase(k); if (p.keyTemporalContinuous(k)) p.setTemporalContinuousAtKey(k, false); if (out) { p.setTemporalEaseAtKey(k, inEase, eases); - p.setInterpolationTypeAtKey(k, inType, BEZIER); + p.setInterpolationTypeAtKey(k, inType, interpolation); } else { p.setTemporalEaseAtKey(k, eases, outEase); - p.setInterpolationTypeAtKey(k, BEZIER, outType); + p.setInterpolationTypeAtKey(k, interpolation, outType); } } @@ -201,7 +204,7 @@ // Ease the sides `mode` names ("In", "Out" or "In-Out") of every selected keyframe with `c`, in // one undo step: a side with a neighbouring key, scaled to the segment on that side. Returns // {eased, roving}: the number of keyframes eased, and of selected roving keyframes skipped. - function applyCurve(c, mode) { + function applyCurve(c, mode, interpolation) { var keyed = selectedKeyed(), sides = [], keys = 0, roving = 0; var doIn = mode !== "Out", doOut = mode !== "In"; // Every side and its eases first: the segments are measured before any key changes. @@ -214,16 +217,16 @@ continue; } var s = doIn && k > 1 ? slopes(p, k - 1) : null; - if (s) sides.push({ prop: p, key: k, out: false, eases: eases(s, c.inSpeed, c.inInfluence) }); + if (s || (doIn && interpolation === LINEAR)) sides.push({ prop: p, key: k, out: false, eases: s ? eases(s, c.inSpeed, c.inInfluence) : p.keyInTemporalEase(k) }); s = doOut && k < p.numKeys ? slopes(p, k) : null; - if (s) sides.push({ prop: p, key: k, out: true, eases: eases(s, c.outSpeed, c.outInfluence) }); + if (s || (doOut && interpolation === LINEAR)) sides.push({ prop: p, key: k, out: true, eases: s ? eases(s, c.outSpeed, c.outInfluence) : p.keyOutTemporalEase(k) }); if (sides.length > before) keys++; } } if (!sides.length) return { eased: 0, roving: roving }; app.beginUndoGroup("Apply Ease Preset"); try { - for (var e = 0; e < sides.length; e++) easeSide(sides[e].prop, sides[e].key, sides[e].out, sides[e].eases); + for (var e = 0; e < sides.length; e++) easeSide(sides[e].prop, sides[e].key, sides[e].out, sides[e].eases, interpolation); } finally { app.endUndoGroup(); } @@ -384,7 +387,10 @@ function apply(c, what) { var m = mode.selection ? mode.selection.text : "In-Out"; - var r = applyCurve(c, m), n = r.eased; + // Linear is an interpolation type too, so its keys get diamond icons. Edited speeds + // make a custom Bezier curve even while Linear remains selected in the list. + var interpolation = list.selection && list.selection.text === "Linear" && c.outSpeed === 1 && c.inSpeed === 1 ? LINEAR : BEZIER; + var r = applyCurve(c, m, interpolation), n = r.eased; var side = m === "In" ? "before it" : m === "Out" ? "after it" : "next to it"; var skipped = r.roving > 0 ? "skipped " + r.roving + " roving keyframe(s); turn off Roving to ease them" : ""; if (n > 0) status.text = "Eased " + n + " keyframe(s) (" + m + ") with " + what + (skipped ? "; " + skipped : ""); diff --git a/packaging/web/README.md b/packaging/web/README.md index 0a1f56fe8..672d9ad67 100644 --- a/packaging/web/README.md +++ b/packaging/web/README.md @@ -58,6 +58,19 @@ location /effectcraft/ { Local test: `python3 -m http.server 8765` inside the folder, then open http://localhost:8765/. +## Startup project (optional) + +To choose what the page opens on load, add an `effectcraft-config.json` next to `index.html`: + +```json +{"startup":"empty"} +``` + +`"empty"` restores the last visit's project and otherwise starts blank (never the demo); `"demo"` +always opens the demo project without saving the session (the last visit's project stays); `"last"` (the default) restores the last visit's project and shows +the demo on a first visit. Serve the file with `Cache-Control: no-cache`. Details: +[docs/web.md](https://github.com/storytold/effectcraft/blob/main/docs/web.md#startup-project). + ## Embedding in a page (iframe) ```html diff --git a/packaging/web/_headers b/packaging/web/_headers index 013e1cf8a..e59592fb1 100644 --- a/packaging/web/_headers +++ b/packaging/web/_headers @@ -12,3 +12,6 @@ /index.html Cache-Control: no-cache + +/effectcraft-config.json + Cache-Control: no-cache diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index afc767de2..0f8072840 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -13,3 +13,4 @@ serde_json = { workspace = true } chrono = { workspace = true } tempfile = "3" +pkg-config = "0.3" diff --git a/xtask/src/main.rs b/xtask/src/main.rs index da8026d78..ad03cada7 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -407,6 +407,62 @@ fn serve_dir(dir: &std::path::Path, port: u16) -> Result<(), String> { Ok(()) } +/// The guidance printed when the ALSA pkg-config package is missing. Split out so the tests can +/// check the package names without needing a machine that is actually missing them. +#[cfg(target_os = "linux")] +fn audio_backend_missing_hint(pkg_config_stderr: Option<&str>) -> String { + let mut hint = String::from( + "Linux audio needs the ALSA development package, and pkg-config cannot find it.\n\ + The desktop app plays preview sound through cpal, whose alsa-sys dependency asks\n\ + pkg-config for `alsa`; without the package the build dies minutes later with an\n\ + unrelated-looking linker error.\n\ + \n\ + Fedora: sudo dnf install alsa-lib-devel\n\ + Debian/Ubuntu: sudo apt install libasound2-dev\n\ + Arch: sudo pacman -S alsa-lib\n\ + \n\ + Install one of those and run `cargo xtask ci` again. If you cannot install it, point\n\ + PKG_CONFIG_PATH at a directory holding an `alsa.pc` whose Libs line names your system's\n\ + libasound (usually `libasound.so.2`); every CI run will need that variable set.", + ); + if let Some(stderr) = pkg_config_stderr { + hint.push_str("\n\npkg-config said: "); + hint.push_str(stderr); + } + hint +} + +/// `cargo xtask ci` builds the desktop app on the host, so on Linux it needs a usable ALSA +/// pkg-config package before the build dies minutes later at link time. The probe goes through +/// the same `pkg-config` crate alsa-sys's build script uses, so the executable and search-path +/// lookup match by construction: `PKG_CONFIG` and its target-scoped variants, then `pkg-config` +/// on `PATH` with a `pkgconf` fallback, plus the scoped `PKG_CONFIG_PATH`/`LIBDIR`/`SYSROOT_DIR`. +/// A probe that ran and said no is "ALSA missing"; one that could not run is reported separately, +/// so a machine with only `pkgconf` does not fail a build that would succeed. +#[cfg(target_os = "linux")] +fn audio_backend() -> Result<(), String> { + // cargo_metadata off: xtask is not a build script, so the crate's rerun-if-env lines would + // only clutter the ci log. + match pkg_config::Config::new().cargo_metadata(false).probe("alsa") { + Ok(_) => Ok(()), + Err(pkg_config::Error::ProbeFailure { output, .. }) | Err(pkg_config::Error::Failure { output, .. }) => { + let stderr = String::from_utf8_lossy(&output.stderr); + let stderr = stderr.trim(); + Err(audio_backend_missing_hint((!stderr.is_empty()).then_some(stderr))) + } + Err(e) => Err(format!( + "The pkg-config probe for `alsa` could not be run: {e}. Install pkg-config (or pkgconf), \ + or point PKG_CONFIG at a working one — it is the same lookup alsa-sys's pkg-config \ + dependency performs." + )), + } +} + +#[cfg(not(target_os = "linux"))] +fn audio_backend() -> Result<(), String> { + Ok(()) +} + /// Lib, bin and integration tests (examples aren't linked; clippy checked them), then doctests, /// which nextest doesn't run. /// @@ -438,7 +494,8 @@ type Stage = fn() -> Result<(), String>; /// Every stage runs even when an earlier one fails, so one run reports all the problems. fn ci() -> Result<(), String> { - let stages: [(&str, Stage); 7] = [ + let stages: [(&str, Stage); 8] = [ + ("audio backend", audio_backend), ("fmt", || run(Command::new(env!("CARGO")).args(["fmt", "--check"]))), ("clippy", || run(Command::new(env!("CARGO")).args(["clippy", "--workspace", "--all-targets", "--", "-D", "warnings"]))), ("tests", tests), @@ -459,6 +516,7 @@ fn main() -> ExitCode { "assets" => assets(), "pdf-deps" => pdf_deps::run(&root()), "ci" => ci(), + "audio-backend" => audio_backend(), "web" => web(&std::env::args().skip(2).collect::>()), "bench" => bench::run(&root(), &std::env::args().skip(2).collect::>()), "ico" => { @@ -469,7 +527,7 @@ fn main() -> ExitCode { let rest: Vec = std::env::args().skip(2).collect(); version::run(&root(), &rest.iter().map(String::as_str).collect::>()) } - _ => Err("usage: cargo xtask ".into()), + _ => Err("usage: cargo xtask ".into()), }; match r { Ok(()) => ExitCode::SUCCESS, @@ -484,3 +542,22 @@ fn main() -> ExitCode { pub fn root() -> std::path::PathBuf { std::path::Path::new(env!("CARGO_MANIFEST_DIR")).parent().expect("xtask has a parent dir").to_path_buf() } + +#[cfg(all(test, target_os = "linux"))] +mod tests { + use super::audio_backend_missing_hint; + + #[test] + fn alsa_missing_names_the_package_for_each_distro() { + let e = audio_backend_missing_hint(None); + for want in ["alsa-lib-devel", "libasound2-dev", "alsa-lib", "pkg-config", "PKG_CONFIG_PATH"] { + assert!(e.contains(want), "hint lost `{want}`:\n{e}"); + } + } + + #[test] + fn alsa_missing_appends_pkg_config_s_own_detail() { + let e = audio_backend_missing_hint(Some("Package 'alsa' not found")); + assert!(e.contains("pkg-config said: Package 'alsa' not found"), "detail lost:\n{e}"); + } +} diff --git a/xtask/tests/it/audio_probe.rs b/xtask/tests/it/audio_probe.rs new file mode 100644 index 000000000..d984c7637 --- /dev/null +++ b/xtask/tests/it/audio_probe.rs @@ -0,0 +1,126 @@ +//! The Linux audio-backend preflight against fake pkg-config tools. +//! +//! The probe resolves its executable through the pkg-config crate, which reads the environment, +//! so the fake-tool scenarios run the real `xtask audio-backend` binary in a child process with a +//! controlled environment: `Command::env` changes only the child's environment, so the tests need +//! no global env mutation and stay parallel-safe. + +use std::ffi::OsStr; +use std::path::PathBuf; +use std::process::{Command, Output}; + +/// A scratch directory holding the fake tools for one scenario: `dir` is the child's `PATH`, +/// `off_path` holds executables reachable only by an explicit `*_PKG_CONFIG` override. +struct FakeTools { + dir: PathBuf, + off_path: PathBuf, +} + +impl FakeTools { + fn new(tag: &str) -> Self { + let base = std::env::temp_dir().join(format!("effectcraft-xtask-audio-{tag}-{}", std::process::id())); + let dir = base.join("bin"); + let off_path = base.join("off-path"); + std::fs::create_dir_all(&dir).expect("create PATH dir"); + std::fs::create_dir_all(&off_path).expect("create off-PATH dir"); + Self { dir, off_path } + } + + /// Write a plain (not executable) file. + fn file(&self, on_path: bool, name: &str, body: &str) -> PathBuf { + let path = if on_path { self.dir.clone() } else { self.off_path.clone() }.join(name); + std::fs::write(&path, body).expect("write fake tool"); + path + } + + /// Write an executable shell script. + fn script(&self, on_path: bool, name: &str, body: &str) -> PathBuf { + use std::os::unix::fs::PermissionsExt; + let path = self.file(on_path, name, body); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).expect("chmod fake tool"); + path + } + + /// A fake `pkgconf` that answers as alsa 1.2.11 linking -lasound. + fn working_pkgconf(&self, on_path: bool) -> PathBuf { + self.script(on_path, "pkgconf", "#!/bin/sh\ncase \" $* \" in\n *\" --modversion \"*) echo 1.2.11 ;;\n *) echo -lasound ;;\nesac\n") + } +} + +impl Drop for FakeTools { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.dir); + let _ = std::fs::remove_dir_all(&self.off_path); + } +} + +/// Run the built `xtask audio-backend` with `dir` as the only `PATH`, the given variables set, +/// and every variable the probe reads otherwise cleared, so a developer's own environment +/// cannot leak into the scenario. +fn run_audio_backend(tools: &FakeTools, set: &[(&str, &OsStr)]) -> Output { + Command::new(env!("CARGO_BIN_EXE_xtask")) + .arg("audio-backend") + .env("PATH", &tools.dir) + .env_remove("PKG_CONFIG") + .env_remove("PKG_CONFIG_PATH") + .env_remove("PKG_CONFIG_LIBDIR") + .env_remove("PKG_CONFIG_SYSROOT_DIR") + .env_remove("PKG_CONFIG_ALLOW_CROSS") + .env_remove("ALSA_NO_PKG_CONFIG") + .env_remove("HOST_PKG_CONFIG") + .env_remove("TARGET_PKG_CONFIG") + .env_remove("HOST") + .env_remove("TARGET") + .envs(set.iter().copied()) + .output() + .expect("run xtask audio-backend") +} + +#[test] +fn a_host_with_only_pkgconf_still_finds_alsa() { + // The reviewed case: PKG_CONFIG unset, no pkg-config on PATH, only pkgconf. The pkg-config + // crate alsa-sys uses falls back to pkgconf, so the stage must stay green. + let tools = FakeTools::new("pkgconf-only"); + tools.working_pkgconf(true); + let out = run_audio_backend(&tools, &[]); + assert!(out.status.success(), "pkgconf-only host must pass:\n{}", String::from_utf8_lossy(&out.stderr)); +} + +#[test] +fn a_probe_that_runs_and_says_no_names_the_package() { + let tools = FakeTools::new("says-no"); + tools.script(true, "pkg-config", "#!/bin/sh\necho 'Package alsa was not found' >&2\nexit 1\n"); + let out = run_audio_backend(&tools, &[]); + assert!(!out.status.success()); + let stderr = String::from_utf8_lossy(&out.stderr); + for want in ["alsa-lib-devel", "libasound2-dev", "alsa-lib", "pkg-config"] { + assert!(stderr.contains(want), "hint lost `{want}`:\n{stderr}"); + } + assert!(stderr.contains("not found"), "pkg-config's own detail should be appended:\n{stderr}"); +} + +#[test] +fn a_probe_that_cannot_run_is_not_alsa_missing() { + // PKG_CONFIG names a file that is not an executable; with PKG_CONFIG set the crate does not + // fall back to pkgconf, so the lookup fails to launch — reported separately from "missing". + let tools = FakeTools::new("cannot-run"); + let broken = tools.file(true, "not-an-executable", "this is not a program\n"); + let out = run_audio_backend(&tools, &[("PKG_CONFIG", broken.as_os_str())]); + assert!(!out.status.success()); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("could not"), "expected the launch-failure error:\n{stderr}"); + assert!(!stderr.contains("alsa-lib-devel"), "must not read as ALSA missing:\n{stderr}"); +} + +#[test] +fn a_host_scoped_override_selects_that_executable() { + // With TARGET and HOST set (build-script style), HOST_PKG_CONFIG wins over the PATH lookup, + // and here it is the only working tool — so success proves the override was used. + let tools = FakeTools::new("scoped"); + let good = tools.working_pkgconf(false); + let out = run_audio_backend( + &tools, + &[("HOST", OsStr::new("x86_64-unknown-linux-gnu")), ("TARGET", OsStr::new("x86_64-unknown-linux-gnu")), ("HOST_PKG_CONFIG", good.as_os_str())], + ); + assert!(out.status.success(), "HOST_PKG_CONFIG override must be honored:\n{}", String::from_utf8_lossy(&out.stderr)); +} diff --git a/xtask/tests/it/main.rs b/xtask/tests/it/main.rs new file mode 100644 index 000000000..f2c41c738 --- /dev/null +++ b/xtask/tests/it/main.rs @@ -0,0 +1,4 @@ +//! xtask's integration tests: one binary, modules in `tests/it/`. + +#[cfg(target_os = "linux")] +mod audio_probe;