diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 10635b69..9caf1c27 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -16,7 +16,22 @@ iconography, images or other Adobe assets; open licences only; every asset attri | `assets/fonts/Inter-Italic.ttf` | The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) | | `assets/fonts/NotoSerif-Regular.ttf` | The Noto Project Authors | https://github.com/notofonts/latin-greek-cyrillic | OFL-1.1 (`assets/fonts/OFL-NotoSerif.txt`) | | `assets/fonts/JetBrainsMono-Regular.ttf` | The JetBrains Mono Project Authors | https://github.com/JetBrains/JetBrainsMono | OFL-1.1 (`assets/fonts/OFL-JetBrainsMono.txt`) | -| `apps/effectcraft-web/web/favicon.svg` | EffectCraft contributors | Original work: web app icon, the code-drawn EffectCraft mark (split rounded square, keyframe diamond) as SVG | MIT OR Apache-2.0 | +| `apps/effectcraft-web/web/favicon.svg` | EffectCraft contributors | Original work: web app icon, a copy of `assets/app-icon/effectcraft-small.svg` (the unicorn app icon) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | +| `assets/app-icon/effectcraft-1024.png` | EffectCraft contributors | Original work: effectCraft app icon, 1024 px (owner's ArtCraft unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | +| `assets/app-icon/effectcraft-macos-512.png` | EffectCraft contributors | Original work: effectCraft runtime Dock icon (macOS) (owner's ArtCraft unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | +| `assets/app-icon/effectcraft-small.svg` | EffectCraft contributors | Original work: effectCraft app icon, light vector (owner's ArtCraft unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | +| `assets/app-icon/effectcraft.icns` | EffectCraft contributors | Original work: effectCraft macOS app icon (owner's ArtCraft unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | +| `assets/app-icon/effectcraft.ico` | EffectCraft contributors | Original work: effectCraft Windows app icon (owner's ArtCraft unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | +| `assets/app-icon/effectcraft.svg` | EffectCraft contributors | Original work: effectCraft app icon, master vector (owner's ArtCraft unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | +| `assets/app-icon/hicolor/128x128/apps/ai.storyteller.effectcraft.png` | EffectCraft contributors | Original work: effectCraft app icon, Linux hicolor 128x128 (owner's ArtCraft unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | +| `assets/app-icon/hicolor/16x16/apps/ai.storyteller.effectcraft.png` | EffectCraft contributors | Original work: effectCraft app icon, Linux hicolor 16x16 (owner's ArtCraft unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | +| `assets/app-icon/hicolor/24x24/apps/ai.storyteller.effectcraft.png` | EffectCraft contributors | Original work: effectCraft app icon, Linux hicolor 24x24 (owner's ArtCraft unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | +| `assets/app-icon/hicolor/256x256/apps/ai.storyteller.effectcraft.png` | EffectCraft contributors | Original work: effectCraft app icon, Linux hicolor 256x256 (owner's ArtCraft unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | +| `assets/app-icon/hicolor/32x32/apps/ai.storyteller.effectcraft.png` | EffectCraft contributors | Original work: effectCraft app icon, Linux hicolor 32x32 (owner's ArtCraft unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | +| `assets/app-icon/hicolor/48x48/apps/ai.storyteller.effectcraft.png` | EffectCraft contributors | Original work: effectCraft app icon, Linux hicolor 48x48 (owner's ArtCraft unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | +| `assets/app-icon/hicolor/512x512/apps/ai.storyteller.effectcraft.png` | EffectCraft contributors | Original work: effectCraft app icon, Linux hicolor 512x512 (owner's ArtCraft unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | +| `assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png` | EffectCraft contributors | Original work: effectCraft app icon, Linux hicolor 64x64 (owner's ArtCraft unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | +| `assets/app-icon/hicolor/scalable/apps/ai.storyteller.effectcraft.svg` | EffectCraft contributors | Original work: effectCraft app icon, Linux hicolor scalable (owner's ArtCraft unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | | `crates/lottie/tests/fixtures/bouncing-ball.json` | EffectCraft contributors | Original work: hand-written Lottie test fixture (bouncing ball, eased position keys) | MIT OR Apache-2.0 | | `crates/lottie/tests/fixtures/shapes-gradient.json` | EffectCraft contributors | Original work: hand-written Lottie test fixture (star, rectangle, gradient fill, dashed stroke, trim paths) | MIT OR Apache-2.0 | | `crates/lottie/tests/fixtures/precomp-matte-text.json` | EffectCraft contributors | Original work: hand-written Lottie test fixture (precomp, track matte, parented text) | MIT OR Apache-2.0 | diff --git a/Cargo.lock b/Cargo.lock index 3a8b2998..6ce4ac9f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1137,6 +1137,7 @@ dependencies = [ "rfd", "serde_json", "winit", + "winresource", ] [[package]] @@ -4688,10 +4689,12 @@ version = "1.1.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" dependencies = [ + "indexmap", "serde_core", "serde_spanned", "toml_datetime", "toml_parser", + "toml_writer", "winnow", ] @@ -4725,6 +4728,12 @@ dependencies = [ "winnow", ] +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + [[package]] name = "tracing" version = "0.1.44" @@ -5764,6 +5773,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winresource" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087" +dependencies = [ + "toml", + "version_check", +] + [[package]] name = "wit-bindgen" version = "0.57.1" diff --git a/README.md b/README.md index d4ccd4ac..00ffa3b1 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,10 @@

+

+ EffectCraft app icon: an engraved unicorn on EffectCraft pink (#e0368f) +

+

EffectCraft

diff --git a/apps/effectcraft-web/web/favicon.svg b/apps/effectcraft-web/web/favicon.svg index 0b4f3c8f..150ac8f9 100644 --- a/apps/effectcraft-web/web/favicon.svg +++ b/apps/effectcraft-web/web/favicon.svg @@ -1,8 +1 @@ - - - - - - - + \ No newline at end of file diff --git a/apps/effectcraft-web/web/favicon.svg.attribution b/apps/effectcraft-web/web/favicon.svg.attribution index e5f8b025..e723699a 100644 --- a/apps/effectcraft-web/web/favicon.svg.attribution +++ b/apps/effectcraft-web/web/favicon.svg.attribution @@ -1,9 +1,8 @@ asset: favicon.svg title: EffectCraft web app icon (favicon) -author: EffectCraft contributors +author: EffectCraft contributors (drawing by the project owner in ArtCraft) source: original work license: MIT OR Apache-2.0 -added: 2026-10-01 by EffectCraft contributors (Claude agent, M15 web app) -notes: Hand-written SVG of the code-drawn EffectCraft mark (`paint_logo` in - crates/ui-egui/src/header.rs): a navy rounded square split into blue and violet halves - with a white keyframe diamond, from basic shapes. Not derived from any other product's icon. +license-file: assets/app-icon/LICENSE.txt +added: 2026-10-02 by EffectCraft contributors (Claude agent, app icon) +notes: Copy of assets/app-icon/effectcraft-small.svg (the lighter vector trace of the app icon). Replaces the earlier code-drawn split-square mark. diff --git a/apps/effectcraft/Cargo.toml b/apps/effectcraft/Cargo.toml index 5a2165aa..6b34763f 100644 --- a/apps/effectcraft/Cargo.toml +++ b/apps/effectcraft/Cargo.toml @@ -19,6 +19,10 @@ serde_json = { workspace = true } rfd = { workspace = true } cpal = { workspace = true } +[build-dependencies] +# Icon + VERSIONINFO for effectcraft.exe; build.rs only uses it when targeting Windows. +winresource = "0.1" + [target.'cfg(target_os = "macos")'.dependencies] # Same winit as eframe: macOS event-loop options (no app activation when driven by an agent). winit = { version = "0.30", default-features = false } diff --git a/apps/effectcraft/build.rs b/apps/effectcraft/build.rs new file mode 100644 index 00000000..50eb5f34 --- /dev/null +++ b/apps/effectcraft/build.rs @@ -0,0 +1,27 @@ +//! Windows only: embed the app icon and version info (VERSIONINFO) into `effectcraft.exe`. +//! +//! On every other target this does nothing. A missing resource compiler is a warning, so a +//! cross-compile from macOS or Linux still links, unless `EFFECTCRAFT_REQUIRE_WINRES=1` turns it +//! into an error (for release builds). + +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-changed=../../assets/app-icon/effectcraft.ico"); + println!("cargo:rerun-if-env-changed=EFFECTCRAFT_REQUIRE_WINRES"); + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") { + return; + } + let mut res = winresource::WindowsResource::new(); + res.set_icon("../../assets/app-icon/effectcraft.ico") + .set("ProductName", "EffectCraft") + .set("FileDescription", "EffectCraft motion graphics and visual effects") + .set("LegalCopyright", "Copyright (c) 2026 The EffectCraft contributors. MIT OR Apache-2.0.") + .set("OriginalFilename", "effectcraft.exe") + .set("InternalName", "effectcraft"); + if let Err(e) = res.compile() { + if std::env::var_os("EFFECTCRAFT_REQUIRE_WINRES").is_some() { + panic!("embedding Windows resources failed: {e}"); + } + println!("cargo:warning=effectcraft.exe built without icon/version resources: {e}"); + } +} diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index 70cbd933..34b40a84 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -11,6 +11,16 @@ mod control_server; use effectcraft_ui_egui::EffectcraftApp; use serde_json::json; +/// The app icon for the window, taskbar and Dock (assets/app-icon, regenerated by +/// `packaging/icons.sh`). macOS gets the padded variant so it sits on Apple's icon grid in the Dock. +fn app_icon() -> egui::IconData { + #[cfg(target_os = "macos")] + let png: &[u8] = include_bytes!("../../../assets/app-icon/effectcraft-macos-512.png"); + #[cfg(not(target_os = "macos"))] + let png: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.effectcraft.png"); + eframe::icon_data::from_png_bytes(png).unwrap_or_default() +} + fn main() -> eframe::Result { let mut control_port: Option = std::env::var("EFFECTCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()); let mut files = Vec::new(); @@ -37,7 +47,10 @@ fn main() -> eframe::Result { .with_min_inner_size([960.0, 600.0]) .with_drag_and_drop(true) // Started for an agent (`--control`): open without taking the user's keyboard focus. - .with_active(control_port.is_none()), + .with_active(control_port.is_none()) + .with_icon(app_icon()) + // Wayland matches the window to `ai.storyteller.effectcraft.desktop` by this ID. + .with_app_id("ai.storyteller.effectcraft"), event_loop_builder: agent_event_loop(control_port.is_some()), ..Default::default() }; diff --git a/assets/app-icon/LICENSE.txt b/assets/app-icon/LICENSE.txt new file mode 100644 index 00000000..5fdd611e --- /dev/null +++ b/assets/app-icon/LICENSE.txt @@ -0,0 +1,13 @@ +EffectCraft app icon (effectcraft.svg, effectcraft-small.svg and every file derived from them in +this directory: effectcraft-1024.png, effectcraft-macos-512.png, effectcraft.icns, effectcraft.ico, +hicolor/), and the web favicon apps/effectcraft-web/web/favicon.svg. + +Original artwork made for this project by the project owner in ArtCraft, then vectorised (no +third-party material). Licensed like the rest of EffectCraft, under either of + + * Apache License, Version 2.0 (https://www.apache.org/licenses/LICENSE-2.0) + * MIT license (https://opensource.org/licenses/MIT) + +at your option. + +Regenerate the derived files with packaging/icons.sh. diff --git a/assets/app-icon/README.md b/assets/app-icon/README.md new file mode 100644 index 00000000..814ad601 --- /dev/null +++ b/assets/app-icon/README.md @@ -0,0 +1,42 @@ +# EffectCraft app icon + +**Creature:** a unicorn, as an engraved head-and-shoulders bust looking out of the tile. + +**Style:** an ink engraving portrait in the Crafting Apps' owl-template framing (the FilmCraft owl): +full-bleed colour field, tight head-and-shoulders crop, the body running off the bottom and right edges. + +**Tile:** 512 × 512 viewBox, rounded square `rx=112`, clipped; no border or roundel. + +**Palette** (exactly three colours): + +| Colour | Hex | Used for | +|---|---|---| +| App colour (EffectCraft pink) | `#e0368f` | the full-bleed field | +| Paper | `#efe9dc` | the figure | +| Ink | `#0b0b0c` | line work and contour | + +**Provenance:** the project owner drew it in ArtCraft (a 2880 px engraving-style image, kept in +craftrules at `assets/app-icons/effectcraft/source.png`). It was vectorised with craftrules +`assets/logo-options/_tools/vectorize_tile.py`, which keys out the field and traces the ink with potrace. +Licence: see [LICENSE.txt](LICENSE.txt) (MIT OR Apache-2.0, like the repo). + +## Files + +| File | What | +|---|---| +| `effectcraft.svg` | master vector, traced at 2048 px (canonical) | +| `effectcraft-small.svg` | lighter vector (~350 KB), traced at 1024 px; also the web favicon | +| `effectcraft-1024.png` | 1024 px render | +| `effectcraft-macos-512.png` | runtime Dock icon on macOS, on Apple's 824/1024 grid | +| `effectcraft.icns` | macOS icon (Apple grid) | +| `effectcraft.ico` | Windows icon, 16–256 px; embedded in `effectcraft.exe` by `apps/effectcraft/build.rs` | +| `hicolor/x/apps/ai.storyteller.effectcraft.png` | Linux icon theme, 16–512 px; the 256 px one is the runtime icon on Windows and Linux | +| `hicolor/scalable/apps/ai.storyteller.effectcraft.svg` | Linux scalable icon | + +App ID: `ai.storyteller.effectcraft` (Wayland app ID, `packaging/linux/ai.storyteller.effectcraft.desktop`). + +## Regenerate + +Copy a new `icon-master.svg` / `icon.svg` from craftrules `assets/app-icons/effectcraft/` over +`effectcraft.svg` / `effectcraft-small.svg`, then run `packaging/icons.sh` (needs `resvg`; `iconutil` +on macOS for the `.icns`). Copy `effectcraft-small.svg` to `apps/effectcraft-web/web/favicon.svg` too. diff --git a/assets/app-icon/effectcraft-1024.png b/assets/app-icon/effectcraft-1024.png new file mode 100644 index 00000000..8635334a Binary files /dev/null and b/assets/app-icon/effectcraft-1024.png differ diff --git a/assets/app-icon/effectcraft-1024.png.attribution b/assets/app-icon/effectcraft-1024.png.attribution new file mode 100644 index 00000000..1793aa51 --- /dev/null +++ b/assets/app-icon/effectcraft-1024.png.attribution @@ -0,0 +1,8 @@ +asset: effectcraft-1024.png +title: EffectCraft app icon, 1024 px +author: EffectCraft contributors (drawing by the project owner in ArtCraft) +source: original work +license: MIT OR Apache-2.0 +license-file: assets/app-icon/LICENSE.txt +added: 2026-10-02 by EffectCraft contributors (Claude agent, app icon) +notes: Rendered from effectcraft.svg with resvg by packaging/icons.sh. diff --git a/assets/app-icon/effectcraft-macos-512.png b/assets/app-icon/effectcraft-macos-512.png new file mode 100644 index 00000000..8f34aeb6 Binary files /dev/null and b/assets/app-icon/effectcraft-macos-512.png differ diff --git a/assets/app-icon/effectcraft-macos-512.png.attribution b/assets/app-icon/effectcraft-macos-512.png.attribution new file mode 100644 index 00000000..7bfafd11 --- /dev/null +++ b/assets/app-icon/effectcraft-macos-512.png.attribution @@ -0,0 +1,8 @@ +asset: effectcraft-macos-512.png +title: EffectCraft runtime Dock icon (macOS) +author: EffectCraft contributors (drawing by the project owner in ArtCraft) +source: original work +license: MIT OR Apache-2.0 +license-file: assets/app-icon/LICENSE.txt +added: 2026-10-02 by EffectCraft contributors (Claude agent, app icon) +notes: Rendered from effectcraft.svg on Apple's 824/1024 icon grid with resvg by packaging/icons.sh; embedded by apps/effectcraft/src/main.rs. diff --git a/assets/app-icon/effectcraft-small.svg b/assets/app-icon/effectcraft-small.svg new file mode 100644 index 00000000..150ac8f9 --- /dev/null +++ b/assets/app-icon/effectcraft-small.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/app-icon/effectcraft-small.svg.attribution b/assets/app-icon/effectcraft-small.svg.attribution new file mode 100644 index 00000000..6dbaab16 --- /dev/null +++ b/assets/app-icon/effectcraft-small.svg.attribution @@ -0,0 +1,8 @@ +asset: effectcraft-small.svg +title: EffectCraft app icon, light vector +author: EffectCraft contributors (drawing by the project owner in ArtCraft) +source: original work +license: MIT OR Apache-2.0 +license-file: assets/app-icon/LICENSE.txt +added: 2026-10-02 by EffectCraft contributors (Claude agent, app icon) +notes: The same drawing vectorised at 1024 px with craftrules vectorize_tile.py, about 350 KB, for places where size matters. diff --git a/assets/app-icon/effectcraft.icns b/assets/app-icon/effectcraft.icns new file mode 100644 index 00000000..919d6876 Binary files /dev/null and b/assets/app-icon/effectcraft.icns differ diff --git a/assets/app-icon/effectcraft.icns.attribution b/assets/app-icon/effectcraft.icns.attribution new file mode 100644 index 00000000..d338c26f --- /dev/null +++ b/assets/app-icon/effectcraft.icns.attribution @@ -0,0 +1,8 @@ +asset: effectcraft.icns +title: EffectCraft macOS app icon +author: EffectCraft contributors (drawing by the project owner in ArtCraft) +source: original work +license: MIT OR Apache-2.0 +license-file: assets/app-icon/LICENSE.txt +added: 2026-10-02 by EffectCraft contributors (Claude agent, app icon) +notes: Rendered from effectcraft.svg on Apple's 824/1024 icon grid with resvg and iconutil by packaging/icons.sh. diff --git a/assets/app-icon/effectcraft.ico b/assets/app-icon/effectcraft.ico new file mode 100644 index 00000000..35cd557c Binary files /dev/null and b/assets/app-icon/effectcraft.ico differ diff --git a/assets/app-icon/effectcraft.ico.attribution b/assets/app-icon/effectcraft.ico.attribution new file mode 100644 index 00000000..25a7faa1 --- /dev/null +++ b/assets/app-icon/effectcraft.ico.attribution @@ -0,0 +1,8 @@ +asset: effectcraft.ico +title: EffectCraft Windows app icon +author: EffectCraft contributors (drawing by the project owner in ArtCraft) +source: original work +license: MIT OR Apache-2.0 +license-file: assets/app-icon/LICENSE.txt +added: 2026-10-02 by EffectCraft contributors (Claude agent, app icon) +notes: Rendered from effectcraft.svg (16-256 px) with resvg and packed by cargo xtask ico (packaging/icons.sh). diff --git a/assets/app-icon/effectcraft.svg b/assets/app-icon/effectcraft.svg new file mode 100644 index 00000000..3cf53a2f --- /dev/null +++ b/assets/app-icon/effectcraft.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/app-icon/effectcraft.svg.attribution b/assets/app-icon/effectcraft.svg.attribution new file mode 100644 index 00000000..21960623 --- /dev/null +++ b/assets/app-icon/effectcraft.svg.attribution @@ -0,0 +1,8 @@ +asset: effectcraft.svg +title: EffectCraft app icon, master vector +author: EffectCraft contributors (drawing by the project owner in ArtCraft) +source: original work +license: MIT OR Apache-2.0 +license-file: assets/app-icon/LICENSE.txt +added: 2026-10-02 by EffectCraft contributors (Claude agent, app icon) +notes: The owner's ArtCraft drawing of an engraved unicorn bust, vectorised at 2048 px with craftrules assets/logo-options/_tools/vectorize_tile.py (potrace; three colours). Canonical artwork; the other icon files derive from it. diff --git a/assets/app-icon/hicolor/128x128/apps/ai.storyteller.effectcraft.png b/assets/app-icon/hicolor/128x128/apps/ai.storyteller.effectcraft.png new file mode 100644 index 00000000..e41b16be Binary files /dev/null and b/assets/app-icon/hicolor/128x128/apps/ai.storyteller.effectcraft.png differ diff --git a/assets/app-icon/hicolor/128x128/apps/ai.storyteller.effectcraft.png.attribution b/assets/app-icon/hicolor/128x128/apps/ai.storyteller.effectcraft.png.attribution new file mode 100644 index 00000000..36847426 --- /dev/null +++ b/assets/app-icon/hicolor/128x128/apps/ai.storyteller.effectcraft.png.attribution @@ -0,0 +1,8 @@ +asset: ai.storyteller.effectcraft.png +title: EffectCraft app icon, Linux hicolor 128x128 +author: EffectCraft contributors (drawing by the project owner in ArtCraft) +source: original work +license: MIT OR Apache-2.0 +license-file: assets/app-icon/LICENSE.txt +added: 2026-10-02 by EffectCraft contributors (Claude agent, app icon) +notes: Rendered from effectcraft.svg with resvg by packaging/icons.sh. diff --git a/assets/app-icon/hicolor/16x16/apps/ai.storyteller.effectcraft.png b/assets/app-icon/hicolor/16x16/apps/ai.storyteller.effectcraft.png new file mode 100644 index 00000000..639ebefe Binary files /dev/null and b/assets/app-icon/hicolor/16x16/apps/ai.storyteller.effectcraft.png differ diff --git a/assets/app-icon/hicolor/16x16/apps/ai.storyteller.effectcraft.png.attribution b/assets/app-icon/hicolor/16x16/apps/ai.storyteller.effectcraft.png.attribution new file mode 100644 index 00000000..943f87c1 --- /dev/null +++ b/assets/app-icon/hicolor/16x16/apps/ai.storyteller.effectcraft.png.attribution @@ -0,0 +1,8 @@ +asset: ai.storyteller.effectcraft.png +title: EffectCraft app icon, Linux hicolor 16x16 +author: EffectCraft contributors (drawing by the project owner in ArtCraft) +source: original work +license: MIT OR Apache-2.0 +license-file: assets/app-icon/LICENSE.txt +added: 2026-10-02 by EffectCraft contributors (Claude agent, app icon) +notes: Rendered from effectcraft.svg with resvg by packaging/icons.sh. diff --git a/assets/app-icon/hicolor/24x24/apps/ai.storyteller.effectcraft.png b/assets/app-icon/hicolor/24x24/apps/ai.storyteller.effectcraft.png new file mode 100644 index 00000000..74d211bf Binary files /dev/null and b/assets/app-icon/hicolor/24x24/apps/ai.storyteller.effectcraft.png differ diff --git a/assets/app-icon/hicolor/24x24/apps/ai.storyteller.effectcraft.png.attribution b/assets/app-icon/hicolor/24x24/apps/ai.storyteller.effectcraft.png.attribution new file mode 100644 index 00000000..10347ea9 --- /dev/null +++ b/assets/app-icon/hicolor/24x24/apps/ai.storyteller.effectcraft.png.attribution @@ -0,0 +1,8 @@ +asset: ai.storyteller.effectcraft.png +title: EffectCraft app icon, Linux hicolor 24x24 +author: EffectCraft contributors (drawing by the project owner in ArtCraft) +source: original work +license: MIT OR Apache-2.0 +license-file: assets/app-icon/LICENSE.txt +added: 2026-10-02 by EffectCraft contributors (Claude agent, app icon) +notes: Rendered from effectcraft.svg with resvg by packaging/icons.sh. diff --git a/assets/app-icon/hicolor/256x256/apps/ai.storyteller.effectcraft.png b/assets/app-icon/hicolor/256x256/apps/ai.storyteller.effectcraft.png new file mode 100644 index 00000000..291feff6 Binary files /dev/null and b/assets/app-icon/hicolor/256x256/apps/ai.storyteller.effectcraft.png differ diff --git a/assets/app-icon/hicolor/256x256/apps/ai.storyteller.effectcraft.png.attribution b/assets/app-icon/hicolor/256x256/apps/ai.storyteller.effectcraft.png.attribution new file mode 100644 index 00000000..86dd6d76 --- /dev/null +++ b/assets/app-icon/hicolor/256x256/apps/ai.storyteller.effectcraft.png.attribution @@ -0,0 +1,8 @@ +asset: ai.storyteller.effectcraft.png +title: EffectCraft app icon, Linux hicolor 256x256 +author: EffectCraft contributors (drawing by the project owner in ArtCraft) +source: original work +license: MIT OR Apache-2.0 +license-file: assets/app-icon/LICENSE.txt +added: 2026-10-02 by EffectCraft contributors (Claude agent, app icon) +notes: Rendered from effectcraft.svg with resvg by packaging/icons.sh. Also the runtime window/taskbar icon on Windows and Linux. diff --git a/assets/app-icon/hicolor/32x32/apps/ai.storyteller.effectcraft.png b/assets/app-icon/hicolor/32x32/apps/ai.storyteller.effectcraft.png new file mode 100644 index 00000000..e477dc25 Binary files /dev/null and b/assets/app-icon/hicolor/32x32/apps/ai.storyteller.effectcraft.png differ diff --git a/assets/app-icon/hicolor/32x32/apps/ai.storyteller.effectcraft.png.attribution b/assets/app-icon/hicolor/32x32/apps/ai.storyteller.effectcraft.png.attribution new file mode 100644 index 00000000..06f910e8 --- /dev/null +++ b/assets/app-icon/hicolor/32x32/apps/ai.storyteller.effectcraft.png.attribution @@ -0,0 +1,8 @@ +asset: ai.storyteller.effectcraft.png +title: EffectCraft app icon, Linux hicolor 32x32 +author: EffectCraft contributors (drawing by the project owner in ArtCraft) +source: original work +license: MIT OR Apache-2.0 +license-file: assets/app-icon/LICENSE.txt +added: 2026-10-02 by EffectCraft contributors (Claude agent, app icon) +notes: Rendered from effectcraft.svg with resvg by packaging/icons.sh. diff --git a/assets/app-icon/hicolor/48x48/apps/ai.storyteller.effectcraft.png b/assets/app-icon/hicolor/48x48/apps/ai.storyteller.effectcraft.png new file mode 100644 index 00000000..be56e1aa Binary files /dev/null and b/assets/app-icon/hicolor/48x48/apps/ai.storyteller.effectcraft.png differ diff --git a/assets/app-icon/hicolor/48x48/apps/ai.storyteller.effectcraft.png.attribution b/assets/app-icon/hicolor/48x48/apps/ai.storyteller.effectcraft.png.attribution new file mode 100644 index 00000000..46fbd4d4 --- /dev/null +++ b/assets/app-icon/hicolor/48x48/apps/ai.storyteller.effectcraft.png.attribution @@ -0,0 +1,8 @@ +asset: ai.storyteller.effectcraft.png +title: EffectCraft app icon, Linux hicolor 48x48 +author: EffectCraft contributors (drawing by the project owner in ArtCraft) +source: original work +license: MIT OR Apache-2.0 +license-file: assets/app-icon/LICENSE.txt +added: 2026-10-02 by EffectCraft contributors (Claude agent, app icon) +notes: Rendered from effectcraft.svg with resvg by packaging/icons.sh. diff --git a/assets/app-icon/hicolor/512x512/apps/ai.storyteller.effectcraft.png b/assets/app-icon/hicolor/512x512/apps/ai.storyteller.effectcraft.png new file mode 100644 index 00000000..6c233775 Binary files /dev/null and b/assets/app-icon/hicolor/512x512/apps/ai.storyteller.effectcraft.png differ diff --git a/assets/app-icon/hicolor/512x512/apps/ai.storyteller.effectcraft.png.attribution b/assets/app-icon/hicolor/512x512/apps/ai.storyteller.effectcraft.png.attribution new file mode 100644 index 00000000..a08d1411 --- /dev/null +++ b/assets/app-icon/hicolor/512x512/apps/ai.storyteller.effectcraft.png.attribution @@ -0,0 +1,8 @@ +asset: ai.storyteller.effectcraft.png +title: EffectCraft app icon, Linux hicolor 512x512 +author: EffectCraft contributors (drawing by the project owner in ArtCraft) +source: original work +license: MIT OR Apache-2.0 +license-file: assets/app-icon/LICENSE.txt +added: 2026-10-02 by EffectCraft contributors (Claude agent, app icon) +notes: Rendered from effectcraft.svg with resvg by packaging/icons.sh. diff --git a/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png b/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png new file mode 100644 index 00000000..2b3b2e97 Binary files /dev/null and b/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png differ diff --git a/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png.attribution b/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png.attribution new file mode 100644 index 00000000..fcfe963c --- /dev/null +++ b/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png.attribution @@ -0,0 +1,8 @@ +asset: ai.storyteller.effectcraft.png +title: EffectCraft app icon, Linux hicolor 64x64 +author: EffectCraft contributors (drawing by the project owner in ArtCraft) +source: original work +license: MIT OR Apache-2.0 +license-file: assets/app-icon/LICENSE.txt +added: 2026-10-02 by EffectCraft contributors (Claude agent, app icon) +notes: Rendered from effectcraft.svg with resvg by packaging/icons.sh. diff --git a/assets/app-icon/hicolor/scalable/apps/ai.storyteller.effectcraft.svg b/assets/app-icon/hicolor/scalable/apps/ai.storyteller.effectcraft.svg new file mode 100644 index 00000000..150ac8f9 --- /dev/null +++ b/assets/app-icon/hicolor/scalable/apps/ai.storyteller.effectcraft.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/app-icon/hicolor/scalable/apps/ai.storyteller.effectcraft.svg.attribution b/assets/app-icon/hicolor/scalable/apps/ai.storyteller.effectcraft.svg.attribution new file mode 100644 index 00000000..562c3506 --- /dev/null +++ b/assets/app-icon/hicolor/scalable/apps/ai.storyteller.effectcraft.svg.attribution @@ -0,0 +1,8 @@ +asset: ai.storyteller.effectcraft.svg +title: EffectCraft app icon, Linux hicolor scalable +author: EffectCraft contributors (drawing by the project owner in ArtCraft) +source: original work +license: MIT OR Apache-2.0 +license-file: assets/app-icon/LICENSE.txt +added: 2026-10-02 by EffectCraft contributors (Claude agent, app icon) +notes: Copy of effectcraft-small.svg, written by packaging/icons.sh. diff --git a/crates/color/src/blend.rs b/crates/color/src/blend.rs index ee239c3c..2305f12a 100644 --- a/crates/color/src/blend.rs +++ b/crates/color/src/blend.rs @@ -157,6 +157,37 @@ impl BlendMode { matches!(self, BlendMode::StencilAlpha | BlendMode::StencilLuma | BlendMode::SilhouetteAlpha | BlendMode::SilhouetteLuma) } + /// Modes whose formulas are defined for over-range (HDR, 32 bpc) values. The others clamp + /// their inputs to 0..1 before blending, as After Effects documents for 32 bpc projects + /// (in 8/16 bpc everything is already in range). + pub fn supports_hdr(self) -> bool { + matches!( + self, + BlendMode::Normal + | BlendMode::Dissolve + | BlendMode::DancingDissolve + | BlendMode::Darken + | BlendMode::Multiply + | BlendMode::LinearBurn + | BlendMode::DarkerColor + | BlendMode::Add + | BlendMode::Lighten + | BlendMode::Screen + | BlendMode::LinearDodge + | BlendMode::LighterColor + | BlendMode::Difference + | BlendMode::ClassicDifference + | BlendMode::Subtract + | BlendMode::Divide + | BlendMode::StencilAlpha + | BlendMode::StencilLuma + | BlendMode::SilhouetteAlpha + | BlendMode::SilhouetteLuma + | BlendMode::AlphaAdd + | BlendMode::LuminescentPremul + ) + } + pub fn next(self) -> BlendMode { let i = BlendMode::ALL.iter().position(|m| *m == self).unwrap_or(0); BlendMode::ALL[(i + 1) % BlendMode::ALL.len()] @@ -364,8 +395,12 @@ pub fn blend_pixel(mode: BlendMode, dst: [f32; 4], src: [f32; 4], noise: f32) -> if da <= 0.0 { return src; } - let cs = [src[0] / sa, src[1] / sa, src[2] / sa]; - let cb = [dst[0] / da, dst[1] / da, dst[2] / da]; + let mut cs = [src[0] / sa, src[1] / sa, src[2] / sa]; + let mut cb = [dst[0] / da, dst[1] / da, dst[2] / da]; + if !mode.supports_hdr() { + cs = cs.map(|v| v.clamp(0.0, 1.0)); + cb = cb.map(|v| v.clamp(0.0, 1.0)); + } let b = non_separable(mode, cb, cs); let ao = sa + da - sa * da; let mut o = [0.0f32; 4]; @@ -432,6 +467,16 @@ mod tests { assert_eq!(blend_pixel(BlendMode::SilhouetteAlpha, d, [0.0, 0.0, 0.0, 0.0], 0.0), d); } + #[test] + fn hdr_modes() { + // Add and Screen keep over-range values (32 bpc); Overlay clamps its inputs. + let d = [2.0, 2.0, 2.0, 1.0]; + let s = [0.5, 0.5, 0.5, 1.0]; + assert!((blend_pixel(BlendMode::Add, d, s, 0.5)[0] - 2.5).abs() < 1e-6); + assert!((blend_pixel(BlendMode::Screen, d, s, 0.5)[0] - 1.5).abs() < 1e-6); + assert!(blend_pixel(BlendMode::Overlay, d, s, 0.5)[0] <= 1.0); + } + #[test] fn names_roundtrip() { for m in BlendMode::ALL { diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index e292d917..9212b020 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -6,9 +6,11 @@ //! colour and composited with Porter-Duff source-over on premultiplied pixels. pub mod blend; +pub mod space; pub use blend::{BlendMode, blend_pixel}; use serde::{Deserialize, Serialize}; +pub use space::{ColorSpace, Conversion}; /// Straight (un-premultiplied) RGBA, 0..1 nominal (32 bpc may exceed). #[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] diff --git a/crates/color/src/space.rs b/crates/color/src/space.rs new file mode 100644 index 00000000..2bc93513 --- /dev/null +++ b/crates/color/src/space.rs @@ -0,0 +1,273 @@ +//! RGB colour spaces for colour management: the project working space, footage interpretation +//! and the display. +//! +//! Each space is defined by its published primaries and white point and its transfer function: +//! +//! * **sRGB IEC 61966-2-1** — Rec. 709 primaries, D65, the piecewise sRGB curve. +//! * **Rec. 709** — ITU-R BT.709-6 primaries, D65, the BT.1886 display EOTF (gamma 2.4). +//! * **Rec. 2020** — ITU-R BT.2020-2 primaries, D65, BT.1886 (gamma 2.4). +//! * **Display P3** — SMPTE EG 432-1 (DCI-P3) primaries with D65 and the sRGB curve. +//! +//! RGB → XYZ matrices are derived from the primaries (the standard construction from chromaticity +//! coordinates: columns of xyY → XYZ scaled so that RGB (1, 1, 1) maps to the white point), so +//! conversions between any two spaces are exact up to `f64` rounding. Transfer functions extend +//! to over-range and negative values (32 bpc) by mirroring around zero. + +use serde::{Deserialize, Serialize}; + +/// An RGB colour space (all D65). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum ColorSpace { + /// sRGB IEC 61966-2-1. + Srgb, + /// HDTV Rec. 709 (gamma 2.4). + Rec709, + /// UHDTV Rec. 2020 (gamma 2.4). + Rec2020, + /// Display P3. + DisplayP3, +} + +/// CIE xy chromaticity of D65. +const D65: [f64; 2] = [0.3127, 0.3290]; + +impl ColorSpace { + pub const ALL: [ColorSpace; 4] = [ColorSpace::Srgb, ColorSpace::Rec709, ColorSpace::Rec2020, ColorSpace::DisplayP3]; + + /// Display name (as in Project Settings ▸ Color ▸ Working Space). + pub fn label(self) -> &'static str { + match self { + ColorSpace::Srgb => "sRGB IEC61966-2.1", + ColorSpace::Rec709 => "HDTV (Rec. 709)", + ColorSpace::Rec2020 => "Rec. 2020", + ColorSpace::DisplayP3 => "Display P3", + } + } + + /// Short identifier used by commands and files (`srgb`, `rec709`, `rec2020`, `p3`). + pub fn id(self) -> &'static str { + match self { + ColorSpace::Srgb => "srgb", + ColorSpace::Rec709 => "rec709", + ColorSpace::Rec2020 => "rec2020", + ColorSpace::DisplayP3 => "p3", + } + } + + /// Parse an id or label (case-insensitive, loose). + pub fn parse(s: &str) -> Option { + let k: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::().to_ascii_lowercase(); + match k.as_str() { + "srgb" | "srgbiec6196621" | "srgbiec61966" | "iec6196621" => Some(ColorSpace::Srgb), + "rec709" | "bt709" | "hdtvrec709" | "hdtv" | "709" => Some(ColorSpace::Rec709), + "rec2020" | "bt2020" | "2020" | "uhdtv" => Some(ColorSpace::Rec2020), + "p3" | "displayp3" | "p3d65" | "dcip3d65" => Some(ColorSpace::DisplayP3), + _ => None, + } + } + + /// Red, green and blue primaries (CIE xy). + pub fn primaries(self) -> [[f64; 2]; 3] { + match self { + ColorSpace::Srgb | ColorSpace::Rec709 => [[0.640, 0.330], [0.300, 0.600], [0.150, 0.060]], + ColorSpace::Rec2020 => [[0.708, 0.292], [0.170, 0.797], [0.131, 0.046]], + ColorSpace::DisplayP3 => [[0.680, 0.320], [0.265, 0.690], [0.150, 0.060]], + } + } + + /// Linear RGB → CIE XYZ (Y of white = 1). + pub fn to_xyz(self) -> [[f64; 3]; 3] { + rgb_to_xyz(self.primaries(), D65) + } + + /// Encoded value → linear light. + pub fn decode(self, v: f32) -> f32 { + let a = v.abs(); + let l = match self { + ColorSpace::Srgb | ColorSpace::DisplayP3 => crate::srgb_to_linear(a), + ColorSpace::Rec709 | ColorSpace::Rec2020 => a.powf(2.4), + }; + l.copysign(v) + } + + /// Linear light → encoded value. + pub fn encode(self, v: f32) -> f32 { + let a = v.abs(); + let e = match self { + ColorSpace::Srgb | ColorSpace::DisplayP3 => crate::linear_to_srgb(a), + ColorSpace::Rec709 | ColorSpace::Rec2020 => a.powf(1.0 / 2.4), + }; + e.copysign(v) + } +} + +/// The standard RGB → XYZ matrix from primaries and white point. +pub fn rgb_to_xyz(p: [[f64; 2]; 3], white: [f64; 2]) -> [[f64; 3]; 3] { + let xyz = |c: [f64; 2]| [c[0] / c[1], 1.0, (1.0 - c[0] - c[1]) / c[1]]; + let (r, g, b) = (xyz(p[0]), xyz(p[1]), xyz(p[2])); + let m = [[r[0], g[0], b[0]], [r[1], g[1], b[1]], [r[2], g[2], b[2]]]; + let w = xyz(white); + let s = mul_vec(&invert(&m), w); + let mut o = m; + for row in &mut o { + for j in 0..3 { + row[j] *= s[j]; + } + } + o +} + +/// 3×3 matrix inverse (the matrices here are always well conditioned). +pub fn invert(m: &[[f64; 3]; 3]) -> [[f64; 3]; 3] { + let c = |r0: usize, c0: usize, r1: usize, c1: usize| m[r0][c0] * m[r1][c1] - m[r0][c1] * m[r1][c0]; + let det = m[0][0] * c(1, 1, 2, 2) - m[0][1] * c(1, 0, 2, 2) + m[0][2] * c(1, 0, 2, 1); + let k = 1.0 / det; + [ + [c(1, 1, 2, 2) * k, -c(0, 1, 2, 2) * k, c(0, 1, 1, 2) * k], + [-c(1, 0, 2, 2) * k, c(0, 0, 2, 2) * k, -c(0, 0, 1, 2) * k], + [c(1, 0, 2, 1) * k, -c(0, 0, 2, 1) * k, c(0, 0, 1, 1) * k], + ] +} + +pub fn mul(a: &[[f64; 3]; 3], b: &[[f64; 3]; 3]) -> [[f64; 3]; 3] { + let mut o = [[0.0; 3]; 3]; + for i in 0..3 { + for j in 0..3 { + o[i][j] = (0..3).map(|k| a[i][k] * b[k][j]).sum(); + } + } + o +} + +pub fn mul_vec(m: &[[f64; 3]; 3], v: [f64; 3]) -> [f64; 3] { + [0, 1, 2].map(|i| m[i][0] * v[0] + m[i][1] * v[1] + m[i][2] * v[2]) +} + +/// A colour conversion between two spaces, each either encoded (with its transfer curve) or +/// linear. Applied to straight (un-premultiplied) RGB. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Conversion { + /// Decode the input with this space's curve first. + pub decode: Option, + /// Linear RGB matrix (None = identity primaries). + pub matrix: Option<[[f32; 3]; 3]>, + /// Encode the output with this space's curve last. + pub encode: Option, +} + +impl Conversion { + /// From `from` (linear when `from_linear`) to `to` (linear when `to_linear`). `None` when + /// the conversion is the identity. + pub fn new(from: ColorSpace, from_linear: bool, to: ColorSpace, to_linear: bool) -> Option { + let same_primaries = from.primaries() == to.primaries(); + let same_curve = from_linear == to_linear && (from_linear || curve_eq(from, to)); + if same_primaries && same_curve { + return None; + } + let matrix = (!same_primaries).then(|| { + let m = mul(&invert(&to.to_xyz()), &from.to_xyz()); + m.map(|r| r.map(|v| v as f32)) + }); + // Same primaries, both encoded with different curves (sRGB ↔ Rec. 709), or a matrix in + // between: go through linear. + let decode = (!from_linear).then_some(from); + let encode = (!to_linear).then_some(to); + Some(Conversion { decode, matrix, encode }) + } + + /// Decode only (encoded → linear in the same space). + pub fn linearize(space: ColorSpace) -> Conversion { + Conversion { decode: Some(space), matrix: None, encode: None } + } + + /// Encode only (linear → encoded in the same space). + pub fn delinearize(space: ColorSpace) -> Conversion { + Conversion { decode: None, matrix: None, encode: Some(space) } + } + + #[inline] + pub fn apply(&self, c: [f32; 3]) -> [f32; 3] { + let mut c = c; + if let Some(s) = self.decode { + c = c.map(|v| s.decode(v)); + } + if let Some(m) = &self.matrix { + c = [0, 1, 2].map(|i| m[i][0] * c[0] + m[i][1] * c[1] + m[i][2] * c[2]); + } + if let Some(s) = self.encode { + c = c.map(|v| s.encode(v)); + } + c + } +} + +fn curve_eq(a: ColorSpace, b: ColorSpace) -> bool { + let srgb = |s: ColorSpace| matches!(s, ColorSpace::Srgb | ColorSpace::DisplayP3); + srgb(a) == srgb(b) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn srgb_matrix_matches_iec() { + // IEC 61966-2-1 (rounded to 4 places). + let m = ColorSpace::Srgb.to_xyz(); + let want = [[0.4124, 0.3576, 0.1805], [0.2126, 0.7152, 0.0722], [0.0193, 0.1192, 0.9505]]; + for i in 0..3 { + for j in 0..3 { + assert!((m[i][j] - want[i][j]).abs() < 2e-4, "{i}{j}: {} vs {}", m[i][j], want[i][j]); + } + } + // Rec. 2020 luminance row (BT.2020: 0.2627, 0.6780, 0.0593). + let y = ColorSpace::Rec2020.to_xyz()[1]; + assert!((y[0] - 0.2627).abs() < 1e-4 && (y[1] - 0.6780).abs() < 1e-4 && (y[2] - 0.0593).abs() < 1e-4, "{y:?}"); + } + + #[test] + fn round_trips() { + let samples = [[0.2f32, 0.5, 0.8], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.03, 0.9, 0.4], [1.5, 0.2, 2.0]]; + for a in ColorSpace::ALL { + for b in ColorSpace::ALL { + for (al, bl) in [(false, false), (true, false), (false, true), (true, true)] { + let fwd = Conversion::new(a, al, b, bl); + let back = Conversion::new(b, bl, a, al); + assert_eq!(fwd.is_none(), back.is_none()); + for c in samples { + let mid = fwd.map(|f| f.apply(c)).unwrap_or(c); + let o = back.map(|f| f.apply(mid)).unwrap_or(mid); + for i in 0..3 { + // Pure power curves amplify f32 rounding near zero. + assert!((o[i] - c[i]).abs() < 2e-3, "{a:?}{al}→{b:?}{bl}: {c:?} → {mid:?} → {o:?}"); + } + } + } + } + } + } + + #[test] + fn white_is_preserved_and_gamut_grows() { + let c = Conversion::new(ColorSpace::Srgb, false, ColorSpace::Rec2020, false).unwrap(); + let w = c.apply([1.0, 1.0, 1.0]); + assert!(w.iter().all(|v| (v - 1.0).abs() < 1e-4), "{w:?}"); + // Pure sRGB red sits inside Rec. 2020: less saturated there. + let r = c.apply([1.0, 0.0, 0.0]); + assert!(r[0] < 1.0 && r[1] > 0.0 && r[2] > 0.0, "{r:?}"); + // sRGB ↔ Rec. 709 share primaries: only the curve changes. + let k = Conversion::new(ColorSpace::Srgb, false, ColorSpace::Rec709, false).unwrap(); + assert!(k.matrix.is_none()); + assert!(Conversion::new(ColorSpace::Srgb, true, ColorSpace::Rec709, true).is_none()); + } + + #[test] + fn transfer_extends_over_range() { + let s = ColorSpace::Srgb; + assert!((s.decode(s.encode(4.0)) - 4.0).abs() < 1e-4); + assert!((s.decode(s.encode(-0.25)) + 0.25).abs() < 1e-5); + assert!((ColorSpace::Rec709.decode(0.5) - 0.5f32.powf(2.4)).abs() < 1e-6); + assert_eq!(ColorSpace::parse("sRGB IEC61966-2.1"), Some(ColorSpace::Srgb)); + assert_eq!(ColorSpace::parse("Display P3"), Some(ColorSpace::DisplayP3)); + } +} diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/commands/edit.rs index b68144bb..e7ca4bd7 100644 --- a/crates/engine/src/commands/edit.rs +++ b/crates/engine/src/commands/edit.rs @@ -521,6 +521,10 @@ fn select_label_group(s: &mut Session, _: &Value) -> Result { fn purge_caches(s: &mut Session, p: &Value) -> Result { let what = str_p(p, "what").unwrap_or("all").to_string(); + if matches!(what.as_str(), "all" | "snapshot") { + s.snapshot = None; + s.state.viewer.show_snapshot = false; + } s.events.push(crate::Event::PurgeCaches); s.toast(format!("Purged {what} cache")); Ok(json!({"purged": what})) diff --git a/crates/engine/src/commands/file.rs b/crates/engine/src/commands/file.rs index 92e8836e..ee61f900 100644 --- a/crates/engine/src/commands/file.rs +++ b/crates/engine/src/commands/file.rs @@ -140,6 +140,18 @@ fn project_settings(s: &mut Session, p: &Value) -> Result { if let Some(l) = p.get("linearize").and_then(Value::as_bool) { proj.settings.linearize = l; } + if let Some(l) = p.get("blendLinear").or_else(|| p.get("blendColorsUsing1Gamma")).and_then(Value::as_bool) { + proj.settings.blend_linear = l; + } + if let Some(w) = p.get("workingSpace") { + proj.settings.working_space = match w.as_str() { + None | Some("none" | "None" | "") => None, + Some(n) => Some( + effectcraft_project::ColorSpace::parse(n) + .ok_or_else(|| bad("file.projectSettings", format!("workingSpace: none|srgb|rec709|rec2020|p3, not `{n}`")))?, + ), + }; + } if let Some(t) = str_p(p, "timeDisplay") { proj.settings.time_display = if t.eq_ignore_ascii_case("frames") { effectcraft_project::TimeDisplayStyle::Frames } else { effectcraft_project::TimeDisplayStyle::Timecode }; @@ -172,7 +184,7 @@ pub fn specs() -> Vec { "Project Settings...", ["File"], Some("Cmd+Alt+Shift+K"), - "{bitDepth?: 8|16|32, linearize?, timeDisplay?: timecode|frames}", + "{bitDepth?: 8|16|32, workingSpace?: none|srgb|rec709|rec2020|p3, linearize?, blendLinear?, timeDisplay?: timecode|frames}", always, project_settings ), diff --git a/crates/engine/src/commands/file_more.rs b/crates/engine/src/commands/file_more.rs index 0180d279..87c1e2df 100644 --- a/crates/engine/src/commands/file_more.rs +++ b/crates/engine/src/commands/file_more.rs @@ -106,6 +106,7 @@ fn placeholder_footage(p: &Value) -> Footage { codec: "Placeholder".into(), missing: true, sequence: vec![], + color_profile: None, } } @@ -413,6 +414,15 @@ fn interpret(s: &mut Session, p: &Value) -> Result { }; let loops = p.get("loop").and_then(Value::as_u64).map(|v| v.max(1) as u32); let par = f_p(p, "pixelAspect"); + // Color ▸ Assign Profile: a colour space id, or "auto"/"none" for the file's own (sRGB). + let profile = match str_p(p, "colorProfile") { + Some("auto" | "none" | "") => Some(None), + Some(c) => match effectcraft_project::ColorSpace::parse(c) { + Some(cs) => Some(Some(cs)), + None => return Err(bad("file.interpretFootage", format!("colorProfile: srgb|rec709|rec2020|p3|auto, not `{c}`"))), + }, + None => None, + }; s.edit("Interpret Footage", None, |proj, _| { for i in &items { if let Some(ItemKind::Footage(f)) = proj.item_mut(*i).map(|x| &mut x.kind) { @@ -431,6 +441,9 @@ fn interpret(s: &mut Session, p: &Value) -> Result { if let Some(x) = par { f.pixel_aspect = x; } + if let Some(c) = profile { + f.color_profile = c; + } } } Ok(()) @@ -634,7 +647,7 @@ pub fn specs() -> Vec { "Main...", ["File", "Interpret Footage"], Some("Cmd+Alt+G"), - "{items?, frameRate?, alpha?: straight|premultiplied|ignore, loop?, pixelAspect?}", + "{items?, frameRate?, alpha?: straight|premultiplied|ignore, loop?, pixelAspect?, colorProfile?: srgb|rec709|rec2020|p3|auto}", has_footage_selection, interpret ), diff --git a/crates/engine/src/commands/frontend.rs b/crates/engine/src/commands/frontend.rs index 6f9f6098..e4c7857e 100644 --- a/crates/engine/src/commands/frontend.rs +++ b/crates/engine/src/commands/frontend.rs @@ -42,6 +42,7 @@ pub fn specs() -> Vec { fe!("view.res.half", "Half", ["View", "Resolution"], Some("Cmd+Shift+J"), "{}", has_comp), fe!("view.res.third", "Third", ["View", "Resolution"], None, "{}", has_comp), fe!("view.res.quarter", "Quarter", ["View", "Resolution"], Some("Cmd+Alt+Shift+J"), "{}", has_comp), + fe!("view.res.custom", "Custom...", ["View", "Resolution"], None, "{factor?: 1..40 (render every n-th pixel)}", has_comp), fe!("view.rulers", "Show Rulers", ["View"], Some("Cmd+R"), "{value?}", always), fe!("view.panelBackground", "Panel Background Color", [], None, "{color?: black|darkGray|mediumGray|lightGray|white|custom|#hex, pick?: true}", always), fe!("view.guides", "Show Guides", ["View"], Some("Cmd+;"), "{value?}", always), diff --git a/crates/engine/src/commands/key_transform.rs b/crates/engine/src/commands/key_transform.rs new file mode 100644 index 00000000..82037d52 --- /dev/null +++ b/crates/engine/src/commands/key_transform.rs @@ -0,0 +1,153 @@ +//! Keyframe geometry edits from the viewer and the Graph Editor: the Graph Editor transform box +//! (scale / move selected keys in time and value; Alt-drag of a key group in the timeline) and +//! spatial Bezier tangents dragged on a motion path. + +use effectcraft_keyframe::Keyframe; +use effectcraft_project::{LayerId, Uid}; +use effectcraft_time::Tick; +use serde_json::{Value, json}; + +use super::prop::prop_ref; +use super::{CommandSpec, b_p, bad, f_p, has_comp, has_keys, merge_p}; +use crate::{EngineError, KeyRef, Result, Session, cmd}; + +fn v3(v: Option<&Value>) -> Option<[f64; 3]> { + let a = v?.as_array()?; + Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?, a.get(2).and_then(Value::as_f64).unwrap_or(0.0)]) +} + +/// Transform the selected keys: times scale about `timeAnchor` (comp seconds, default the first +/// selected key) by `timeScale` and move by `timeOffset`; values scale about `valueAnchor` by +/// `valueScale` and move by `valueOffset` (every dimension, or only `dim`). Times land on frames. +fn transform(s: &mut Session, p: &Value) -> Result { + let cid = s.active_comp_id().ok_or(EngineError::NoComp)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let fr = comp.frame_rate; + let sel = s.state.selected_keys.clone(); + let ts = f_p(p, "timeScale").unwrap_or(1.0); + let toff = f_p(p, "timeOffset").unwrap_or(0.0); + let vs = f_p(p, "valueScale").unwrap_or(1.0); + let voff = f_p(p, "valueOffset").unwrap_or(0.0); + let va = f_p(p, "valueAnchor").unwrap_or(0.0); + // `dim` (one dimension) or `dims` (several); none = every dimension. + let dims: Option> = match (p.get("dim").and_then(Value::as_u64), p.get("dims").and_then(Value::as_array)) { + (Some(d), _) => Some(vec![d as usize]), + (None, Some(a)) => Some(a.iter().filter_map(Value::as_u64).map(|d| d as usize).collect()), + _ => None, + }; + if !ts.is_finite() || ts <= 0.0 { + return Err(bad("keys.transform", "`timeScale` must be positive")); + } + // Default time anchor: the earliest selected key (comp time). + let first = sel.iter().filter_map(|k| comp.layer(k.layer).map(|l| l.comp_time(k.time))).min().unwrap_or(Tick::ZERO); + let ta = f_p(p, "timeAnchor").map(Tick::from_seconds_f64).unwrap_or(first); + let map_t = |ct: Tick| -> Tick { + let x = ta.seconds() + (ct.seconds() - ta.seconds()) * ts + toff; + fr.snap_nearest(Tick::from_seconds_f64(x.max(0.0))) + }; + let touch_values = vs != 1.0 || voff != 0.0; + s.edit("Transform Keyframes", merge_p(p), |proj, st| { + let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + let mut groups: std::collections::BTreeMap<(LayerId, Uid), Vec> = Default::default(); + for k in &sel { + groups.entry((k.layer, k.prop)).or_default().push(k.time); + } + let mut new_sel = vec![]; + for ((lid, uid), times) in groups { + let Some(layer) = comp.layer_mut(lid) else { continue }; + let (start, stretch) = (layer.start_time, layer.stretch); + let to_comp = |lt: Tick| if (stretch - 100.0).abs() < 1e-9 { start + lt } else { start + Tick((lt.0 as f64 * stretch / 100.0) as i64) }; + let to_layer = |ct: Tick| { + let d = ct - start; + if (stretch - 100.0).abs() < 1e-9 { d } else { Tick((d.0 as f64 * 100.0 / stretch) as i64) } + }; + let Some(pr) = layer.props.find_mut(uid) else { continue }; + let (mut moved, mut kept): (Vec, Vec) = pr.keys.drain(..).partition(|k| times.contains(&k.time)); + for k in &mut moved { + k.time = to_layer(map_t(to_comp(k.time))); + if touch_values { + let mut c = k.value.components(); + for (d, v) in c.iter_mut().enumerate() { + if dims.as_ref().is_none_or(|x| x.contains(&d)) { + *v = va + (*v - va) * vs + voff; + } + } + k.value = k.value.with_components(&c); + } + } + // Transformed keys win over unselected keys at the same time. + kept.retain(|k| !moved.iter().any(|m| m.time == k.time)); + moved.sort_by_key(|k| k.time); + moved.dedup_by_key(|k| k.time); + for k in &moved { + new_sel.push(KeyRef { layer: lid, prop: uid, time: k.time }); + } + kept.extend(moved); + kept.sort_by_key(|k| k.time); + pr.keys = kept; + effectcraft_keyframe::retime_roving(&mut pr.keys, pr.spatial); + } + st.selected_keys = new_sel; + Ok(json!({"keys": st.selected_keys.len()})) + }) +} + +/// Spatial tangents of one key of a spatial property (motion path handles), relative to the key +/// value. Setting one side only mirrors it to the other unless `break` is true; the key stops +/// being auto Bezier. +fn spatial_tangents(s: &mut Session, p: &Value) -> Result { + let (cid, lid, uid) = prop_ref(s, p, "keys.setSpatialTangents")?; + let t = Tick::from_seconds_f64(f_p(p, "time").ok_or_else(|| bad("keys.setSpatialTangents", "missing `time` (layer s)"))?); + let (tin, tout) = (v3(p.get("in")), v3(p.get("out"))); + if tin.is_none() && tout.is_none() { + return Err(bad("keys.setSpatialTangents", "pass `in` and/or `out` [dx, dy, dz?]")); + } + let brk = b_p(p, "break").unwrap_or(false); + s.edit("Edit Spatial Tangents", merge_p(p), |proj, _| { + let l = super::layer_mut(proj, cid, lid)?; + let pr = l.props.find_mut(uid).ok_or_else(|| bad("keys.setSpatialTangents", "no property"))?; + if !pr.spatial { + return Err(bad("keys.setSpatialTangents", "not a spatial property")); + } + let i = pr.keys.iter().position(|k| k.time == t).or_else(|| pr.keys.iter().enumerate().min_by_key(|(_, k)| (k.time.0 - t.0).abs()).map(|(i, _)| i)); + let i = i.ok_or_else(|| bad("keys.setSpatialTangents", "the property has no keyframes"))?; + let (cur_in, cur_out) = effectcraft_keyframe::spatial_tangents(&pr.keys, i); + let k = &mut pr.keys[i]; + let neg = |v: [f64; 3]| [-v[0], -v[1], -v[2]]; + let (ni, no) = match (tin, tout) { + (Some(a), Some(b)) => (a, b), + (Some(a), None) => (a, if brk { cur_out } else { neg(a) }), + (None, Some(b)) => (if brk { cur_in } else { neg(b) }, b), + (None, None) => (cur_in, cur_out), + }; + k.spatial_auto = false; + k.spatial_continuous = !brk && tin.is_some() != tout.is_some(); + k.spatial_in = ni; + k.spatial_out = no; + effectcraft_keyframe::retime_roving(&mut pr.keys, true); + Ok(json!({"in": ni, "out": no})) + }) +} + +pub fn specs() -> Vec { + vec![ + cmd!( + "keys.transform", + "Transform Keyframes", + [], + None, + "{timeScale?, timeAnchor? (comp s), timeOffset? (s), valueScale?, valueAnchor?, valueOffset?, dim? | dims?: [d…], merge?}", + has_keys, + transform + ), + cmd!( + "keys.setSpatialTangents", + "Edit Spatial Tangents", + [], + None, + "{layer?, path|prop, time (layer s), in?: [dx,dy,dz?], out?, break?, merge?}", + has_comp, + spatial_tangents + ), + ] +} diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index 0fb86385..efc613a8 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -607,6 +607,57 @@ fn timing(s: &mut Session, p: &Value) -> Result { Ok(Value::Null) } +/// Slip edit: move a layer's source (footage or precomp) under its fixed in and out points, like +/// dragging the source-duration bar in After Effects. `delta` in seconds or `frames`; positive +/// moves the source later. Clamped so the source still covers the in–out span. Keyframes and +/// layer markers are in layer time, so they travel with the source. +fn slip(s: &mut Session, p: &Value) -> Result { + let (cid, ids) = layers_p(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let fr = comp.frame_rate; + let delta = match (p.get("frames").and_then(Value::as_i64), f_p(p, "delta")) { + (Some(f), _) => fr.tick_of(f), + (None, Some(d)) => fr.snap_nearest(Tick::from_seconds_f64(d)), + _ => return Err(bad("layer.slip", "delta (seconds) or frames required")), + }; + // Allowed shift per layer: the source span [lo, hi] in comp time must keep covering in..out. + let mut shifts: Vec<(LayerId, Tick)> = Vec::new(); + for l in comp.layers.iter().filter(|l| ids.contains(&l.id)) { + if l.props.get("timeRemap").is_some() { + continue; + } + let dur = match &l.source { + LayerSource::Footage { item } => match s.project.item(*item).map(|i| &i.kind) { + Some(ItemKind::Footage(f)) if f.kind != effectcraft_project::FootageKind::Still => Some(Tick(f.duration.0 * f.loop_count.max(1) as i64)), + _ => None, + }, + LayerSource::Comp { item } => s.project.comp(*item).map(|c| c.duration), + _ => None, + }; + let Some(dur) = dur else { continue }; + let (a, b) = (l.comp_time(Tick::ZERO), l.comp_time(dur)); + let (lo, hi) = (a.min(b), a.max(b)); + let (min_d, max_d) = (l.out_point - hi, l.in_point - lo); + if min_d > max_d { + continue; + } + shifts.push((l.id, delta.max(min_d).min(max_d))); + } + if shifts.is_empty() { + return Err(bad("layer.slip", "select a footage or precomp layer (without time remapping)")); + } + s.edit("Slip Edit", merge_p(p), |proj, _| { + let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + for (id, d) in &shifts { + if let Some(l) = comp.layer_mut(*id) { + l.start_time += *d; + } + } + Ok(()) + })?; + Ok(json!({"slipped": shifts.iter().map(|(id, d)| json!({"layer": id.0, "delta": d.seconds()})).collect::>()})) +} + fn arrange(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; let how = str_p(p, "to").unwrap_or("front").to_string(); @@ -976,6 +1027,17 @@ pub fn specs() -> Vec { has_layers, timing ), + cmd!("layer.slip", "Slip Edit", [], None, "{layers?, delta? (seconds) | frames?, merge?}", has_layers, slip), + cmd!("layer.slipBack", "Slip Edit 1 Frame Earlier", [], Some("Alt+PageUp"), "{layers?}", has_layers, |s, p| { + let mut p = p.clone(); + p["frames"] = json!(-1); + slip(s, &p) + }), + cmd!("layer.slipForward", "Slip Edit 1 Frame Later", [], Some("Alt+PageDown"), "{layers?}", has_layers, |s, p| { + let mut p = p.clone(); + p["frames"] = json!(1); + slip(s, &p) + }), cmd!("layer.arrange", "Arrange", ["Layer", "Arrange"], None, "{layers?, to: front|forward|backward|back, index?}", has_layers, arrange), cmd!("layer.precompose", "Pre-compose...", ["Layer"], Some("Cmd+Shift+C"), "{layers?, name?}", has_layers, precompose), cmd!( diff --git a/crates/engine/src/commands/mask.rs b/crates/engine/src/commands/mask.rs index 8c671ecd..39894b58 100644 --- a/crates/engine/src/commands/mask.rs +++ b/crates/engine/src/commands/mask.rs @@ -20,19 +20,38 @@ fn pts(v: Option<&Value>) -> Vec<[f64; 2]> { v.and_then(Value::as_array).map(|a| a.iter().filter_map(|p| pt(Some(p))).collect()).unwrap_or_default() } -/// Find a mask group by uid, 1-based index or name. -fn find_mask<'a>(masks: &'a mut PropGroup, key: &Value) -> Option<&'a mut PropGroup> { - let i = match key { +/// Index of a mask group by uid, 1-based index or name. +fn mask_index(masks: &PropGroup, key: &Value) -> Option { + match key { Value::Number(n) => { let n = n.as_u64()?; - masks.children.iter().position(|c| c.uid() == n).or_else(|| (n as usize).checked_sub(1).filter(|i| *i < masks.children.len()))? + masks.children.iter().position(|c| c.uid() == n).or_else(|| (n as usize).checked_sub(1).filter(|i| *i < masks.children.len())) } - Value::String(s) => masks.children.iter().position(|c| c.name() == s)?, - _ => return None, - }; + Value::String(s) => masks.children.iter().position(|c| c.name() == s), + _ => None, + } +} + +/// Find a mask group by uid, 1-based index or name. +fn find_mask<'a>(masks: &'a mut PropGroup, key: &Value) -> Option<&'a mut PropGroup> { + let i = mask_index(masks, key)?; masks.children.get_mut(i)?.as_group_mut() } +/// Whether a group holds an editable Bezier path (a mask, or a shape layer's Path item). +pub(crate) fn is_path_group(g: &PropGroup) -> bool { + matches!(g.get("path").map(|p| &p.value), Some(KV::Path(_))) && (matches!(g.kind, effectcraft_project::GroupKind::Mask { .. }) || g.match_id == "path") +} + +/// A mask (uid, 1-based index or name) or, by uid, a shape layer's Path item. +pub(crate) fn path_group<'a>(props: &'a mut PropGroup, key: &Value) -> Option<&'a mut PropGroup> { + if let Some(i) = props.sub("masks").and_then(|m| mask_index(m, key)) { + return props.sub_mut("masks")?.children.get_mut(i)?.as_group_mut(); + } + let uid = key.as_u64()?; + props.find_group_mut(uid).filter(|g| is_path_group(g)) +} + /// Edit the mask path of (layer, mask) at the current time. fn edit_path(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut ShapePath) -> Result) -> Result<(Uid, T)> { let (cid, lid) = layer_p(s, p, label)?; @@ -41,8 +60,7 @@ fn edit_path(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut Sha s.edit(label, merge_p(p), |proj, _| { let l = layer_mut(proj, cid, lid)?; let lt = l.layer_time(t); - let masks = l.props.sub_mut("masks").ok_or_else(|| bad(label, "this layer has no masks"))?; - let g = find_mask(masks, &key).ok_or_else(|| bad(label, "no such mask"))?; + let g = path_group(&mut l.props, &key).ok_or_else(|| bad(label, "no such mask or shape path"))?; let uid = g.uid; let roto = super::paths::is_roto(g); let pr = g.get_mut("path").ok_or_else(|| bad(label, "mask has no path"))?; @@ -186,7 +204,7 @@ fn edit_vertices(s: &mut Session, p: &Value, label: &str, f: impl Fn(&mut ShapeP for ((l, m), idx) in &groups { let Some(layer) = comp.layer_mut(effectcraft_project::LayerId(*l)) else { continue }; let lt = layer.layer_time(t); - let Some(g) = layer.props.sub_mut("masks").and_then(|ms| ms.find_group_mut(*m)) else { continue }; + let Some(g) = layer.props.find_group_mut(*m).filter(|g| is_path_group(g)) else { continue }; let roto = super::paths::is_roto(g); let Some(pr) = g.get_mut("path") else { continue }; let KV::Path(mut sp) = pr.value_at(lt) else { continue }; @@ -233,6 +251,93 @@ fn delete_vertices(s: &mut Session, p: &Value) -> Result { Ok(n) } +/// Convert Vertex tool: a corner vertex becomes smooth (tangents along its neighbours, a sixth +/// of their distance each way), a smooth one becomes a corner (no tangents). `smooth` forces one. +fn convert_vertex(s: &mut Session, p: &Value) -> Result { + let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("mask.convertVertex", "missing `index`"))? as usize; + let force = b_p(p, "smooth"); + let (_, smooth) = edit_path(s, p, "Convert Vertex", |sp| { + let n = sp.vertices.len(); + if i >= n { + return Err(bad("mask.convertVertex", "no such vertex")); + } + sp.in_tangents.resize(n, [0.0; 2]); + sp.out_tangents.resize(n, [0.0; 2]); + let is_smooth = sp.in_tangents[i] != [0.0; 2] || sp.out_tangents[i] != [0.0; 2]; + let make_smooth = force.unwrap_or(!is_smooth); + if make_smooth { + let prev = if i > 0 { + Some(i - 1) + } else if sp.closed { + Some(n - 1) + } else { + None + }; + let next = if i + 1 < n { + Some(i + 1) + } else if sp.closed { + Some(0) + } else { + None + }; + let v = sp.vertices[i]; + let a = prev.map(|j| sp.vertices[j]).unwrap_or(v); + let b = next.map(|j| sp.vertices[j]).unwrap_or(v); + let mut d = [(b[0] - a[0]) / 6.0, (b[1] - a[1]) / 6.0]; + if d == [0.0, 0.0] { + d = [10.0, 0.0]; + } + sp.out_tangents[i] = d; + sp.in_tangents[i] = [-d[0], -d[1]]; + } else { + sp.in_tangents[i] = [0.0; 2]; + sp.out_tangents[i] = [0.0; 2]; + } + Ok(make_smooth) + })?; + Ok(json!({"smooth": smooth})) +} + +/// Split cubic segment `i` (vertex i → i+1) at parameter `t`; the path keeps its shape. +pub(crate) fn split_segment(sp: &mut ShapePath, seg: usize, t: f64) -> Option { + let n = sp.vertices.len(); + let j = if seg + 1 < n { + seg + 1 + } else if sp.closed && seg + 1 == n { + 0 + } else { + return None; + }; + sp.in_tangents.resize(n, [0.0; 2]); + sp.out_tangents.resize(n, [0.0; 2]); + let p0 = sp.vertices[seg]; + let p3 = sp.vertices[j]; + let p1 = [p0[0] + sp.out_tangents[seg][0], p0[1] + sp.out_tangents[seg][1]]; + let p2 = [p3[0] + sp.in_tangents[j][0], p3[1] + sp.in_tangents[j][1]]; + let lerp = |a: [f64; 2], b: [f64; 2]| [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]; + let (a, b, c) = (lerp(p0, p1), lerp(p1, p2), lerp(p2, p3)); + let (d, e) = (lerp(a, b), lerp(b, c)); + let m = lerp(d, e); + sp.out_tangents[seg] = [a[0] - p0[0], a[1] - p0[1]]; + sp.in_tangents[j] = [c[0] - p3[0], c[1] - p3[1]]; + let at = seg + 1; + sp.vertices.insert(at, m); + sp.in_tangents.insert(at, [d[0] - m[0], d[1] - m[1]]); + sp.out_tangents.insert(at, [e[0] - m[0], e[1] - m[1]]); + Some(at) +} + +/// Add Vertex tool: insert a vertex on segment `segment` at parameter `t` (default 0.5). +fn insert_vertex(s: &mut Session, p: &Value) -> Result { + let seg = + p.get("segment").and_then(Value::as_u64).ok_or_else(|| bad("mask.insertVertex", "missing `segment` (index of the segment's first vertex)"))? as usize; + let t = super::f_p(p, "t").unwrap_or(0.5).clamp(0.0, 1.0); + let (_, lid) = layer_p(s, p, "mask.insertVertex")?; + let (uid, i) = edit_path(s, p, "Add Vertex", |sp| split_segment(sp, seg, t).ok_or_else(|| bad("mask.insertVertex", "no such segment")))?; + s.state.selected_vertices = vec![VertexRef { layer: lid, mask: uid, index: i }]; + Ok(json!(i)) +} + fn remove_masks(s: &mut Session, p: &Value, all: bool) -> Result { let (cid, lid) = layer_p(s, p, "mask.remove")?; let key = p.get("mask").cloned(); @@ -263,6 +368,8 @@ pub fn specs() -> Vec { cmd!("mask.selectVertices", "Select Mask Vertices", [], None, "{vertices: [{layer, mask, index}], add?, toggle?}", has_comp, select_vertices), cmd!("mask.moveVertices", "Move Mask Vertices", [], None, "{vertices?: [{layer, mask, index}], delta: [dx,dy], merge?}", has_vertices, move_vertices), cmd!("mask.deleteVertices", "Delete Mask Vertices", [], None, "{vertices?}", has_vertices, delete_vertices), + cmd!("mask.convertVertex", "Convert Vertex", [], None, "{layer?, mask: uid (mask or shape Path item), index, smooth?}", has_comp, convert_vertex), + cmd!("mask.insertVertex", "Add Vertex", [], None, "{layer?, mask: uid (mask or shape Path item), segment, t?: 0..1}", has_comp, insert_vertex), cmd!("mask.remove", "Remove Mask", [], None, "{layer?, mask}", has_layers, |s, p| remove_masks(s, p, false)), cmd!("mask.removeAll", "Remove All Masks", [], None, "{layer?}", has_layers, |s, p| remove_masks(s, p, true)), ] diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index f61a2ba2..cea0955b 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -11,6 +11,7 @@ mod file; mod file_more; mod frontend; mod help; +mod key_transform; mod layer; mod layer_menu; mod layer_time; @@ -32,6 +33,9 @@ mod three_d; mod time; mod track; mod view; +pub mod viewer_cmds; +#[cfg(test)] +pub(crate) use mask::split_segment as split_segment_for_tests; use std::sync::OnceLock; @@ -108,6 +112,8 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(layer_menu::specs()); v.extend(animation::specs()); v.extend(view::specs()); + v.extend(viewer_cmds::specs()); + v.extend(key_transform::specs()); v.extend(file_more::specs()); v.extend(lottie::specs()); v.extend(comp_more::specs()); diff --git a/crates/engine/src/commands/paths.rs b/crates/engine/src/commands/paths.rs index 0c0436ab..57736101 100644 --- a/crates/engine/src/commands/paths.rs +++ b/crates/engine/src/commands/paths.rs @@ -63,6 +63,13 @@ fn masks_for(s: &Session, p: &Value) -> Result<(ItemId, Vec<(LayerId, Uid)>)> { v.dedup(); return Ok((cid, v)); } + // A shape layer's Path item by uid. + if let Some(uid) = p.get("mask").and_then(Value::as_u64) { + let (cid, lid) = layer_p(s, p, "path")?; + if s.project.comp(cid).and_then(|c| c.layer(lid)).and_then(|l| l.props.find_group(uid)).is_some_and(|g| g.match_id == "path") { + return Ok((cid, vec![(lid, uid)])); + } + } target_masks(s, p) } @@ -230,7 +237,7 @@ fn free_transform(s: &mut Session, p: &Value) -> Result { for (lid, uid) in &targets { let l = layer_mut(proj, cid, *lid)?; let lt = l.layer_time(t); - let Some(g) = l.props.sub_mut("masks").and_then(|m| m.find_group_mut(*uid)) else { continue }; + let Some(g) = l.props.find_group_mut(*uid).filter(|g| super::mask::is_path_group(g)) else { continue }; let roto = is_roto(g); let Some(pr) = g.get_mut("path") else { continue }; let KV::Path(mut sp) = pr.value_at(lt) else { continue }; diff --git a/crates/engine/src/commands/stubs.rs b/crates/engine/src/commands/stubs.rs index 7c4b5a70..0c9afe94 100644 --- a/crates/engine/src/commands/stubs.rs +++ b/crates/engine/src/commands/stubs.rs @@ -53,7 +53,6 @@ pub fn specs() -> Vec { stub!("material.duplicateAssign", "Duplicate and Assign Material", ["Layer", "Material"], "{}"), stub!("view.3d.default", "Default", ["View", "Switch 3D View"], "{}"), stub!("view.splitLockedViewer", "Split with New Locked Viewer", ["View"], "{}"), - stub!("view.res.custom", "Custom...", ["View", "Resolution"], "{}"), // Color management. stub!("view.displayColorManagement", "Use Display Color Management", ["View"], "{}"), stub!("view.simulateOutput", "Simulate Output", [], "{profile}"), diff --git a/crates/engine/src/commands/viewer_cmds.rs b/crates/engine/src/commands/viewer_cmds.rs new file mode 100644 index 00000000..c0161510 --- /dev/null +++ b/crates/engine/src/commands/viewer_cmds.rs @@ -0,0 +1,305 @@ +//! Composition viewer state that agents and the CLI can drive too: snapping, Show Channel and +//! exposure, snapshots, Fast Previews, guides dragged out of the rulers, and the Pen tool for +//! shape layers (`shape.newPath`). + +use effectcraft_keyframe::ShapePath; +use effectcraft_project::LayerSource; +use effectcraft_project::build::{self, Ids}; +use effectcraft_render::RenderOpts; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use super::{CommandSpec, always, b_p, bad, comp_id, f_p, has_comp, merge_p, str_p}; +use crate::viewer::{Channel, Snapshot}; +use crate::{EngineError, Result, Session, VertexRef, cmd}; + +/// Composition ▸ Preview ▸ Fast Previews modes. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum FastPreviews { + #[default] + Off, + AdaptiveResolution, + Draft, + FastDraft, + Wireframe, +} + +impl FastPreviews { + pub const ALL: [FastPreviews; 5] = + [FastPreviews::Off, FastPreviews::AdaptiveResolution, FastPreviews::Draft, FastPreviews::FastDraft, FastPreviews::Wireframe]; + pub fn id(self) -> &'static str { + match self { + FastPreviews::Off => "off", + FastPreviews::AdaptiveResolution => "adaptive", + FastPreviews::Draft => "draft", + FastPreviews::FastDraft => "fastDraft", + FastPreviews::Wireframe => "wireframe", + } + } + pub fn label(self) -> &'static str { + match self { + FastPreviews::Off => "Off (Final Quality)", + FastPreviews::AdaptiveResolution => "Adaptive Resolution", + FastPreviews::Draft => "Draft", + FastPreviews::FastDraft => "Fast Draft", + FastPreviews::Wireframe => "Wireframe", + } + } + pub fn from_name(s: &str) -> Option { + let n = s.to_ascii_lowercase().replace([' ', '_', '-'], ""); + FastPreviews::ALL.into_iter().find(|m| m.id().to_ascii_lowercase() == n || m.label().to_ascii_lowercase().replace(' ', "").starts_with(&n)) + } + /// Render options for the viewer: (draft quality, resolution factor while interacting). + pub fn render(self, interacting: bool) -> (bool, f64) { + match self { + FastPreviews::Off => (false, 1.0), + FastPreviews::AdaptiveResolution => (false, if interacting { 0.25 } else { 1.0 }), + FastPreviews::Draft => (true, 1.0), + FastPreviews::FastDraft => (true, if interacting { 0.25 } else { 1.0 }), + FastPreviews::Wireframe => (true, 1.0), + } + } +} + +/// Viewer display options (Show Channel, exposure, snapshot, Fast Previews). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct ViewOptions { + pub channel: Channel, + /// Show Channel ▸ Colorize: single channels shown in their colour instead of gray. + pub colorized: bool, + /// Adjust Exposure, in stops. + pub exposure: f32, + /// Show Snapshot (F5 held): the viewer shows the snapshot instead of the frame. + pub show_snapshot: bool, + pub fast_previews: FastPreviews, +} + +fn snapping(s: &mut Session, p: &Value) -> Result { + s.state.snapping = b_p(p, "value").unwrap_or(!s.state.snapping); + Ok(json!(s.state.snapping)) +} + +fn channel(s: &mut Session, p: &Value) -> Result { + let name = str_p(p, "channel").unwrap_or("rgb"); + let c = Channel::from_name(name).ok_or_else(|| bad("view.channel", format!("unknown channel `{name}` (rgb|red|green|blue|alpha|rgbStraight)")))?; + // Choosing the active single channel again returns to RGB, as in After Effects. + let c = if c == s.state.viewer.channel && c != Channel::Rgb && b_p(p, "toggle").unwrap_or(false) { Channel::Rgb } else { c }; + s.state.viewer.channel = c; + if let Some(v) = b_p(p, "colorized") { + s.state.viewer.colorized = v; + } + Ok(json!({"channel": c.id(), "colorized": s.state.viewer.colorized})) +} + +fn exposure(s: &mut Session, p: &Value) -> Result { + let cur = s.state.viewer.exposure as f64; + let v = match (f_p(p, "stops"), f_p(p, "delta")) { + (Some(v), _) => v, + (None, Some(d)) => cur + d, + _ => return Err(bad("view.exposure", "pass `stops` or `delta`")), + }; + s.state.viewer.exposure = v.clamp(-40.0, 40.0) as f32; + Ok(json!(s.state.viewer.exposure)) +} + +fn reset_exposure(s: &mut Session, _: &Value) -> Result { + s.state.viewer.exposure = 0.0; + Ok(json!(0.0)) +} + +fn take_snapshot(s: &mut Session, p: &Value) -> Result { + let cid = comp_id(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let t = s.time_of(cid); + let scale = f_p(p, "scale").unwrap_or(1.0).clamp(0.05, 1.0); + let img = s.render(cid, t, RenderOpts { scale, guides: true, view: s.view_camera(cid).filter(|_| comp.has_3d()), ..Default::default() }); + let (w, h) = (img.width, img.height); + s.snapshot = Some(Snapshot { comp: cid, time: t, scale, image: std::sync::Arc::new(img) }); + s.events.push(crate::Event::ProjectChanged { revision: s.revision }); + Ok(json!({"width": w, "height": h, "time": t.seconds()})) +} + +fn has_snapshot(s: &Session) -> std::result::Result<(), String> { + if s.snapshot.is_some() { Ok(()) } else { Err("take a snapshot first (Shift+F5)".into()) } +} + +fn show_snapshot(s: &mut Session, p: &Value) -> Result { + s.state.viewer.show_snapshot = b_p(p, "value").unwrap_or(!s.state.viewer.show_snapshot); + Ok(json!(s.state.viewer.show_snapshot)) +} + +fn fast_previews(s: &mut Session, p: &Value) -> Result { + let name = str_p(p, "mode").unwrap_or("off"); + let m = + FastPreviews::from_name(name).ok_or_else(|| bad("view.fastPreviewMode", format!("unknown mode `{name}` (off|adaptive|draft|fastDraft|wireframe)")))?; + s.state.viewer.fast_previews = m; + Ok(json!(m.id())) +} + +fn guide_index(s: &Session, p: &Value, cmd: &str) -> Result<(effectcraft_project::ItemId, usize)> { + let cid = comp_id(s, p)?; + let n = s.project.comp(cid).ok_or(EngineError::NoComp)?.guides.len(); + let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "missing `index`"))? as usize; + if i >= n { + return Err(bad(cmd, format!("no guide {i} (the comp has {n})"))); + } + Ok((cid, i)) +} + +fn move_guide(s: &mut Session, p: &Value) -> Result { + let (cid, i) = guide_index(s, p, "view.moveGuide")?; + let pos = f_p(p, "position").ok_or_else(|| bad("view.moveGuide", "missing `position` (comp px)"))?; + s.edit("Move Guide", merge_p(p), |proj, _| { + proj.comp_mut(cid).ok_or(EngineError::NoComp)?.guides[i].position = pos; + Ok(()) + })?; + Ok(json!(pos)) +} + +fn remove_guide(s: &mut Session, p: &Value) -> Result { + let (cid, i) = guide_index(s, p, "view.removeGuide")?; + s.edit("Remove Guide", None, |proj, _| { + proj.comp_mut(cid).ok_or(EngineError::NoComp)?.guides.remove(i); + Ok(()) + })?; + Ok(Value::Null) +} + +fn pts(v: Option<&Value>) -> Vec<[f64; 2]> { + v.and_then(Value::as_array).map(|a| a.iter().filter_map(|p| Some([p.get(0)?.as_f64()?, p.get(1)?.as_f64()?])).collect()).unwrap_or_default() +} + +fn rgba(v: Option<&Value>, d: [f64; 4]) -> [f64; 4] { + match v { + Some(Value::Array(a)) => { + let g = |i: usize, d: f64| a.get(i).and_then(Value::as_f64).unwrap_or(d); + [g(0, d[0]), g(1, d[1]), g(2, d[2]), g(3, 1.0)] + } + Some(Value::String(h)) => effectcraft_color::Rgba::from_hex(h).map(|c| [c.r as f64, c.g as f64, c.b as f64, 1.0]).unwrap_or(d), + _ => d, + } +} + +/// Pen tool on a shape layer: a new Shape group ("Shape n" with Path 1, Stroke 1 and Fill 1) on +/// the given / selected shape layer, or on a new shape layer when none is given. Vertices are +/// in the layer's space, or comp space (`space: "comp"`, the default for a new layer). +fn new_path(s: &mut Session, p: &Value) -> Result { + let cid = comp_id(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); + let mut v = pts(p.get("vertices")); + if v.is_empty() { + return Err(bad("shape.newPath", "need `vertices` [[x,y], …]")); + } + let n = v.len(); + let mut ins = pts(p.get("inTangents")); + let mut outs = pts(p.get("outTangents")); + ins.resize(n, [0.0; 2]); + outs.resize(n, [0.0; 2]); + let target = match p.get("layer") { + Some(l) => Some(super::resolve_layer(&comp, l).ok_or_else(|| bad("shape.newPath", format!("no layer {l}")))?), + None => s.state.selected_layers.iter().copied().find(|l| comp.layer(*l).is_some_and(|l| matches!(l.source, LayerSource::Shape) && !l.switches.locked)), + }; + if let Some(l) = target.and_then(|l| comp.layer(l)) + && !matches!(l.source, LayerSource::Shape) + { + return Err(bad("shape.newPath", "the layer is not a shape layer")); + } + let comp_space = str_p(p, "space").map(|s| s == "comp").unwrap_or(target.is_none()); + if comp_space { + // Comp → layer space of the target (a new layer sits at the comp centre, unrotated). + let inv = match target.and_then(|l| comp.layer(l)) { + Some(l) => { + let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, &comp, s.time_of(cid)); + ctx.layer_to_comp(l).0.inverse().unwrap_or(effectcraft_geom::Mat3::IDENTITY) + } + None => effectcraft_geom::Mat3::translate(effectcraft_geom::vec2(-(comp.width as f64) / 2.0, -(comp.height as f64) / 2.0)), + }; + for q in &mut v { + let r = inv.apply(effectcraft_geom::vec2(q[0], q[1])); + *q = [r.x, r.y]; + } + for t in ins.iter_mut().chain(outs.iter_mut()) { + let r = inv.apply_vec(effectcraft_geom::vec2(t[0], t[1])); + *t = [r.x, r.y]; + } + } + let path = ShapePath { vertices: v, in_tangents: ins, out_tangents: outs, closed: b_p(p, "closed").unwrap_or(false) }; + let fill = (!matches!(p.get("fill"), Some(Value::Null) | Some(Value::Bool(false)))).then(|| rgba(p.get("fill"), [0.25, 0.55, 1.0, 1.0])); + let width = f_p(p, "strokeWidth").unwrap_or(2.0); + let stroke = (width > 0.0).then(|| rgba(p.get("stroke"), [1.0, 1.0, 1.0, 1.0])); + let name = str_p(p, "name").map(str::to_string); + let (lid, group, path_uid) = s.edit("Pen Tool", None, |proj, st| { + let lid = match target { + Some(l) => l, + None => { + let count = comp.layers.iter().filter(|l| matches!(l.source, LayerSource::Shape)).count(); + let lname = name.clone().unwrap_or_else(|| format!("Shape Layer {}", count + 1)); + let l = build::layer(proj, &comp, &lname, LayerSource::Shape, (comp.width, comp.height), None); + super::layer::insert_layer(proj, st, cid, l)? + } + }; + let mut next = proj.next_id; + let l = super::layer_mut(proj, cid, lid)?; + let contents = l.props.sub_mut("contents").ok_or_else(|| bad("shape.newPath", "the layer has no contents"))?; + let k = contents.groups().filter(|g| g.match_id == "group").count(); + let mut ids = Ids(&mut next); + let pg = build::shape_path(&mut ids, path); + let path_uid = pg.uid; + let mut items = vec![pg]; + if let Some(c) = stroke { + items.push(build::shape_stroke(&mut ids, c, width)); + } + if let Some(c) = fill { + items.push(build::shape_fill(&mut ids, c)); + } + let g = build::shape_group(&mut ids, &format!("Shape {}", k + 1), items); + let group = g.uid; + contents.children.insert(0, g.into()); + proj.next_id = next; + st.selected_layers = vec![lid]; + st.selected_vertices = vec![VertexRef { layer: lid, mask: path_uid, index: n - 1 }]; + Ok((lid, group, path_uid)) + })?; + Ok(json!({"layer": lid.0, "group": group, "path": path_uid})) +} + +/// Snapshot image as straight 8-bit RGBA rows (for frontends / `--out`). +pub fn snapshot_rgba8(s: &Session) -> Option<(u32, u32, Vec)> { + let snap = s.snapshot.as_ref()?; + let img = &snap.image; + let mut out = Vec::with_capacity(img.data.len() * 4); + for p in &img.data { + let a = p[3].clamp(0.0, 1.0); + let un = |c: f32| if a > 0.0 { (c / a).clamp(0.0, 1.0) } else { 0.0 }; + out.extend([un(p[0]), un(p[1]), un(p[2]), a].map(|v| (v * 255.0 + 0.5) as u8)); + } + Some((img.width, img.height, out)) +} + +fn shape_layer_or_none(s: &Session) -> std::result::Result<(), String> { + has_comp(s) +} + +pub fn specs() -> Vec { + vec![ + cmd!("view.snapping", "Snapping", ["View"], None, "{value?}", always, snapping), + cmd!("view.channel", "Show Channel", [], None, "{channel: rgb|red|green|blue|alpha|rgbStraight, colorized?, toggle?}", always, channel), + cmd!("view.exposure", "Adjust Exposure", [], None, "{stops? | delta?}", always, exposure), + cmd!("view.resetExposure", "Reset Exposure", [], None, "{}", always, reset_exposure), + cmd!("view.takeSnapshot", "Take Snapshot", [], Some("Shift+F5"), "{comp?, scale?: 0.05..1}", has_comp, take_snapshot), + cmd!("view.showSnapshot", "Show Snapshot", [], None, "{value?}", has_snapshot, show_snapshot), + cmd!("view.fastPreviewMode", "Fast Previews", [], None, "{mode: off|adaptive|draft|fastDraft|wireframe}", always, fast_previews), + cmd!("view.moveGuide", "Move Guide", [], None, "{comp?, index, position (comp px), merge?}", has_comp, move_guide), + cmd!("view.removeGuide", "Remove Guide", [], None, "{comp?, index}", has_comp, remove_guide), + cmd!( + "shape.newPath", + "Pen Tool (Shape Path)", + [], + None, + "{layer?, vertices: [[x,y]…], inTangents?, outTangents?, closed?, space?: comp|layer, fill?: [r,g,b]|#hex|false, stroke?, strokeWidth?, name?}", + shape_layer_or_none, + new_path + ), + ] +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a5bcd29f..d18cd426 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -19,6 +19,7 @@ pub mod render_queue; mod session_settings; pub mod shortcuts; pub mod tracking; +pub mod viewer; use std::sync::Arc; @@ -185,6 +186,9 @@ pub struct EditorState { /// Text copied while editing (with its formatting), for Paste / Paste Text Formatting Only. #[serde(skip)] pub text_clipboard: Option, + /// Composition viewer display options (Show Channel, exposure, snapshot, Fast Previews). + #[serde(default)] + pub viewer: commands::viewer_cmds::ViewOptions, } fn one_view() -> u8 { @@ -261,6 +265,8 @@ pub struct Session { pub shortcut_table: std::sync::OnceLock, /// Auto-save bookkeeping. pub autosave: autosave::AutoSaveState, + /// The viewer snapshot (Take Snapshot / Show Snapshot). + pub snapshot: Option, } impl Default for Session { @@ -290,6 +296,7 @@ impl Default for Session { ui_commands: vec![], shortcut_table: std::sync::OnceLock::new(), autosave: autosave::AutoSaveState::default(), + snapshot: None, } } } @@ -415,6 +422,11 @@ impl Session { self.state.active_comp.filter(|c| self.project.comp(*c).is_some()) } + /// Current time of a comp. + pub fn time_of(&self, comp: ItemId) -> Tick { + self.state.times.get(&comp).copied().unwrap_or(Tick::ZERO) + } + /// Current time of the active comp. pub fn time(&self) -> Tick { self.state.active_comp.and_then(|c| self.state.times.get(&c).copied()).unwrap_or(Tick::ZERO) @@ -528,6 +540,8 @@ mod tests_3d; #[cfg(test)] mod tests_effects; #[cfg(test)] +mod tests_fidelity; +#[cfg(test)] mod tests_lottie; #[cfg(test)] mod tests_menu_cmds; @@ -545,6 +559,8 @@ mod tests_text_edit; mod tests_timeline; #[cfg(test)] mod tests_track; +#[cfg(test)] +mod tests_viewer; /// Font families available to text layers (bundled + scanned system fonts). /// Text animation presets: (id, name), for menus. diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index 606ad18c..a935ebc0 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -165,6 +165,7 @@ pub fn checked(s: &Session, command: &str, params: &Value) -> Option { "layer.mask.hideLocked" => Some(s.state.hide_locked_masks), "view.layout" => Some(params.get("views").and_then(Value::as_u64) == Some(s.state.view_layout.max(1) as u64)), "view.shareViewOptions" => Some(s.state.share_view_options), + "view.snapping" => Some(s.state.snapping), "layer.mask.motionBlur" | "layer.mask.featherFalloff" | "path.rotoBezier" => { use effectcraft_project::{FeatherFalloff, GroupKind, MaskMotionBlur}; let GroupKind::Mask { motion_blur, feather_falloff, roto_bezier, .. } = layer?.masks()?.groups().next()?.kind else { return None }; @@ -869,6 +870,7 @@ View --- Show Grid | view.grid Snap to Grid | view.snapToGrid + Snapping | view.snapping --- View Options... | view.options Show Layer Controls | view.layerControls diff --git a/crates/engine/src/tests_fidelity.rs b/crates/engine/src/tests_fidelity.rs new file mode 100644 index 00000000..e5e84c03 --- /dev/null +++ b/crates/engine/src/tests_fidelity.rs @@ -0,0 +1,83 @@ +//! Render-fidelity commands: project colour settings, footage colour profile, slip edit. + +use effectcraft_project::{BitDepth, ColorSpace}; +use serde_json::json; + +use crate::Session; + +#[test] +fn project_colour_settings_are_undoable() { + let mut s = Session::default(); + s.execute("file.projectSettings", json!({"bitDepth": 32, "workingSpace": "rec2020", "linearize": true, "blendLinear": true})).unwrap(); + let st = &s.project.settings; + assert_eq!((st.bit_depth, st.working_space, st.linearize, st.blend_linear), (BitDepth::Bpc32, Some(ColorSpace::Rec2020), true, true)); + s.execute("file.projectSettings", json!({"workingSpace": "Display P3"})).unwrap(); + assert_eq!(s.project.settings.working_space, Some(ColorSpace::DisplayP3)); + s.execute("file.projectSettings", json!({"workingSpace": "none"})).unwrap(); + assert_eq!(s.project.settings.working_space, None); + assert!(s.execute("file.projectSettings", json!({"workingSpace": "acescg-ish"})).is_err()); + assert!(s.undo()); + assert_eq!(s.project.settings.working_space, Some(ColorSpace::DisplayP3)); + assert!(s.undo() && s.undo()); + assert_eq!(s.project.settings.bit_depth, BitDepth::Bpc8); + assert!(!s.project.settings.blend_linear); + // The Project panel's depth button cycles 8 → 16 → 32 → 8. + for want in ["16 bpc", "32 bpc", "8 bpc"] { + assert_eq!(s.execute("file.cycleBitDepth", json!({})).unwrap(), json!(want)); + } +} + +#[test] +fn footage_colour_profile_is_interpreted() { + let mut s = Session::default(); + let id = s.execute("file.importPlaceholder", json!({"name": "clip", "width": 64, "height": 36, "frameRate": 24, "duration": 2})).unwrap(); + let item = id["item"].as_u64().or_else(|| id.as_u64()).map(effectcraft_project::ItemId).unwrap_or_else(|| *s.project.items.keys().last().unwrap()); + let profile = |s: &Session| match &s.project.item(item).unwrap().kind { + effectcraft_project::ItemKind::Footage(f) => f.color_profile, + _ => panic!("not footage"), + }; + s.execute("file.interpretFootage", json!({"items": [item.0], "colorProfile": "rec2020"})).unwrap(); + assert_eq!(profile(&s), Some(ColorSpace::Rec2020)); + s.execute("file.interpretFootage", json!({"items": [item.0], "colorProfile": "auto"})).unwrap(); + assert_eq!(profile(&s), None); + assert!(s.undo()); + assert_eq!(profile(&s), Some(ColorSpace::Rec2020)); +} + +#[test] +fn slip_moves_the_source_under_fixed_in_and_out() { + use effectcraft_time::Tick; + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 100, "height": 100, "frameRate": 30, "duration": 10})).unwrap(); + let nested = s.execute("comp.new", json!({"name": "Nested", "duration": 4, "open": false})).unwrap()["comp"].as_u64().unwrap(); + s.execute("layer.addItem", json!({"item": nested})).unwrap(); + let id = s.active_comp().unwrap().layers[0].id.0; + // Trim to 1 s .. 3 s of a 4 s source. + s.execute("layer.timing", json!({"layers": [id], "in": 1.0, "out": 3.0})).unwrap(); + let get = |s: &Session| s.active_comp().unwrap().layers[0].clone(); + let before = get(&s); + assert_eq!((before.start_time, before.in_point.seconds(), before.out_point.seconds()), (Tick::ZERO, 1.0, 3.0)); + // Slip the source 0.5 s later: in/out stay, the source (start) moves. + s.execute("layer.slip", json!({"layers": [id], "delta": 0.5})).unwrap(); + let l = get(&s); + assert_eq!((l.start_time.seconds(), l.in_point, l.out_point), (0.5, before.in_point, before.out_point)); + // The source time at comp 2 s is now 1.5 s. + assert_eq!(l.layer_time(Tick::from_seconds_f64(2.0)).seconds(), 1.5); + // Clamped so the source still covers in..out: at most start = in (1 s). + let r = s.execute("layer.slip", json!({"layers": [id], "delta": 5.0})).unwrap(); + assert_eq!(get(&s).start_time.seconds(), 1.0); + assert_eq!(r["slipped"][0]["delta"], json!(0.5)); + // Alt+PageUp / Alt+PageDown nudge by a frame. + s.execute("layer.slipBack", json!({"layers": [id]})).unwrap(); + assert!((get(&s).start_time.seconds() - (1.0 - 1.0 / 30.0)).abs() < 1e-9); + s.execute("layer.slipForward", json!({"layers": [id]})).unwrap(); + assert_eq!(get(&s).start_time.seconds(), 1.0); + // Undo walks back through the slips. + assert!(s.undo() && s.undo() && s.undo() && s.undo()); + assert_eq!(get(&s).start_time, Tick::ZERO); + assert_eq!(crate::find_command("layer.slipBack").and_then(|c| c.shortcut), Some("Alt+PageUp")); + assert_eq!(crate::find_command("layer.slipForward").and_then(|c| c.shortcut), Some("Alt+PageDown")); + // Layers without a source duration can't slip. + s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap(); + assert!(s.execute("layer.slip", json!({"delta": 0.5})).is_err()); +} diff --git a/crates/engine/src/tests_stubs.rs b/crates/engine/src/tests_stubs.rs index c1356ff2..141858ca 100644 --- a/crates/engine/src/tests_stubs.rs +++ b/crates/engine/src/tests_stubs.rs @@ -256,6 +256,7 @@ impl Importer for MockImporter { codec: "mock".into(), missing: false, sequence: vec![], + color_profile: None, }) } } diff --git a/crates/engine/src/tests_track.rs b/crates/engine/src/tests_track.rs index b3c2a174..7ea69cfa 100644 --- a/crates/engine/src/tests_track.rs +++ b/crates/engine/src/tests_track.rs @@ -100,6 +100,7 @@ fn setup(make: impl Fn(u32) -> Image + Send + Sync + 'static) -> (Session, Layer codec: String::new(), missing: false, sequence: vec![], + color_profile: None, }; let item = p.add_item("clip", Label::Aqua, None, ItemKind::Footage(footage)); let comp = p.comp(cid).unwrap().clone(); @@ -408,6 +409,7 @@ fn analyze_1080p_single_point_speed() { codec: String::new(), missing: false, sequence: vec![], + color_profile: None, }; let item = p.add_item("hd", Label::Aqua, None, ItemKind::Footage(footage)); let comp = p.comp(cid).unwrap().clone(); diff --git a/crates/engine/src/tests_viewer.rs b/crates/engine/src/tests_viewer.rs new file mode 100644 index 00000000..d6072c58 --- /dev/null +++ b/crates/engine/src/tests_viewer.rs @@ -0,0 +1,384 @@ +//! Composition viewer parity: snapping maths, channel / exposure display transforms, snapshots, +//! guides, the shape Pen, vertex add / delete / convert, motion-path key and tangent edits and +//! the Graph Editor transform box. Every edit undoes. + +use effectcraft_keyframe::Value as KV; +use effectcraft_project::LayerId; +use effectcraft_time::Tick; +use serde_json::json; + +use crate::Session; +use crate::viewer::{Channel, PRI_GRID, PRI_GUIDE, PRI_LAYER, PRI_VERTEX, SnapKind, SnapSource, SnapTarget, display_transform, snap}; + +fn comp() -> Session { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 640, "height": 360, "frameRate": 30, "duration": 10})).unwrap(); + s +} + +fn layer(s: &Session, id: u64) -> effectcraft_project::Layer { + s.active_comp().unwrap().layer(LayerId(id)).unwrap().clone() +} + +// ---------------------------------------------------------------- snapping + +#[test] +fn snap_picks_nearest_target_within_tolerance() { + let t = [SnapTarget::point([100.0, 100.0], PRI_LAYER, SnapSource::Comp), SnapTarget::point([108.0, 100.0], PRI_LAYER, SnapSource::Comp)]; + let s = snap(&[[103.0, 101.0]], &t, 10.0).unwrap(); + assert_eq!(s.delta, [-3.0, -1.0]); + assert_eq!(s.at, [100.0, 100.0]); + // Out of tolerance: nothing. + assert!(snap(&[[130.0, 100.0]], &t, 10.0).is_none()); + // Of several sources, the one nearest a target snaps. + let s = snap(&[[0.0, 0.0], [107.0, 100.0]], &t, 10.0).unwrap(); + assert_eq!(s.delta, [1.0, 0.0]); +} + +#[test] +fn snap_lines_snap_axes_independently_and_priority_breaks_ties() { + let t = [ + SnapTarget::vline(50.0, PRI_GRID, SnapSource::Grid), + SnapTarget::vline(54.0, PRI_GUIDE, SnapSource::Guide), + SnapTarget::hline(20.0, PRI_GRID, SnapSource::Grid), + ]; + // x snaps to the nearest vertical line (equal distance: the guide wins over the grid), y to + // the horizontal one. + let s = snap(&[[52.0, 23.0]], &t, 5.0).unwrap(); + assert_eq!(s.delta, [2.0, -3.0]); + assert_eq!(s.hits.len(), 2); + assert_eq!(s.hits[0].source, SnapSource::Guide); + assert_eq!(s.at, [54.0, 20.0]); + // A point at the same distance as a line beats it (it snaps both axes). + let t2 = [SnapTarget::vline(10.0, PRI_GUIDE, SnapSource::Guide), SnapTarget::point([10.0, 12.0], PRI_VERTEX, SnapSource::Vertex(LayerId(1)))]; + let s = snap(&[[12.0, 12.0]], &t2, 5.0).unwrap(); + assert_eq!(s.hits[0].kind, SnapKind::Point); + assert_eq!(s.delta, [-2.0, 0.0]); + // Equal point distances: lower priority value (vertex) wins over a layer feature. + let t3 = + [SnapTarget::point([0.0, 3.0], PRI_LAYER, SnapSource::Layer(LayerId(2))), SnapTarget::point([3.0, 0.0], PRI_VERTEX, SnapSource::Vertex(LayerId(3)))]; + assert_eq!(snap(&[[0.0, 0.0]], &t3, 5.0).unwrap().hits[0].source, SnapSource::Vertex(LayerId(3))); +} + +#[test] +fn snap_targets_cover_layers_comp_guides_and_grid() { + let mut s = comp(); + let a = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 100, "height": 50})).unwrap()["layer"].as_u64().unwrap(); + let b = s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 40, "height": 40})).unwrap()["layer"].as_u64().unwrap(); + s.execute("view.addGuide", json!({"orientation": "vertical", "position": 77})).unwrap(); + let cid = s.active_comp_id().unwrap(); + let comp = s.project.comp(cid).unwrap().clone(); + let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, &comp, Tick::ZERO); + let opts = crate::viewer::SnapOptions { guides: true, grid: true, grid_spacing: 100.0 }; + let t = crate::viewer::targets(&ctx, &[LayerId(b)], opts); + // Layer a's corner (270, 155) is a target; layer b (dragged) is not. + assert!(t.iter().any(|x| x.source == SnapSource::Layer(LayerId(a)) && x.kind == SnapKind::Point && x.pos == [270.0, 155.0])); + assert!(!t.iter().any(|x| x.source == SnapSource::Layer(LayerId(b)))); + assert!(t.iter().any(|x| x.source == SnapSource::Guide && x.pos[0] == 77.0)); + assert!(t.iter().any(|x| x.source == SnapSource::Grid && x.kind == SnapKind::HLine && x.pos[1] == 300.0)); + assert!(t.iter().any(|x| x.source == SnapSource::Comp && x.kind == SnapKind::Point && x.pos == [320.0, 180.0])); + // Dragging a corner near layer a's corner lands on it (edges snap x and y). + let hit = snap(&[[272.0, 157.0]], &t, 6.0).unwrap(); + assert_eq!(hit.at, [270.0, 155.0]); + assert_eq!(hit.delta, [-2.0, -2.0]); +} + +// ---------------------------------------------------------------- channels, exposure, snapshot + +#[test] +fn channel_and_exposure_pixel_transforms() { + // Premultiplied: straight (200, 100, 50) at 50% alpha. + let px = [100u8, 50, 25, 128]; + let run = |c: Channel, col: bool, stops: f32| { + let mut v = [px]; + display_transform(&mut v, c, col, stops); + v[0] + }; + assert_eq!(run(Channel::Rgb, false, 0.0), px); + assert_eq!(run(Channel::Red, false, 0.0), [100, 100, 100, 255]); + assert_eq!(run(Channel::Green, true, 0.0), [0, 50, 0, 255]); + assert_eq!(run(Channel::Blue, false, 0.0), [25, 25, 25, 255]); + assert_eq!(run(Channel::Alpha, false, 0.0), [128, 128, 128, 255]); + let st = run(Channel::RgbStraight, false, 0.0); + assert!((st[0] as i32 - 199).abs() <= 1 && (st[1] as i32 - 100).abs() <= 1 && st[3] == 255, "{st:?}"); + // +1 stop doubles linear light: sRGB 50% gray → about 69%; -1 stop darkens; 0 is identity. + let mut g = [[128u8, 128, 128, 255]]; + display_transform(&mut g, Channel::Rgb, false, 1.0); + assert!((174..=178).contains(&g[0][0]), "{:?}", g[0]); + let mut g = [[128u8, 128, 128, 255]]; + display_transform(&mut g, Channel::Rgb, false, -1.0); + assert!((92..=96).contains(&g[0][0]), "{:?}", g[0]); + assert_eq!(crate::viewer::exposure_lut(0.0)[200], 200); + // Exposure applies to a single channel too, and keeps alpha untouched in RGB. + assert!(run(Channel::Red, false, 1.0)[0] > 100); + assert_eq!(run(Channel::Rgb, false, 2.0)[3], 128); +} + +#[test] +fn channel_exposure_and_fast_preview_commands() { + let mut s = comp(); + s.execute("view.channel", json!({"channel": "alpha"})).unwrap(); + assert_eq!(s.state.viewer.channel, Channel::Alpha); + s.execute("view.channel", json!({"channel": "alpha", "toggle": true})).unwrap(); + assert_eq!(s.state.viewer.channel, Channel::Rgb); + s.execute("view.channel", json!({"channel": "RGB Straight", "colorized": true})).unwrap(); + assert_eq!(s.state.viewer.channel, Channel::RgbStraight); + assert!(s.state.viewer.colorized); + s.execute("view.exposure", json!({"stops": 1.5})).unwrap(); + s.execute("view.exposure", json!({"delta": -0.5})).unwrap(); + assert_eq!(s.state.viewer.exposure, 1.0); + s.execute("view.resetExposure", json!({})).unwrap(); + assert_eq!(s.state.viewer.exposure, 0.0); + s.execute("view.fastPreviewMode", json!({"mode": "fastDraft"})).unwrap(); + assert_eq!(s.state.viewer.fast_previews, crate::commands::viewer_cmds::FastPreviews::FastDraft); + assert!(s.execute("view.fastPreviewMode", json!({"mode": "nope"})).is_err()); + let was = s.state.snapping; + s.execute("view.snapping", json!({})).unwrap(); + assert_eq!(s.state.snapping, !was); +} + +#[test] +fn snapshot_store_and_restore() { + let mut s = comp(); + assert!(!s.is_enabled("view.showSnapshot")); + s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 640, "height": 360})).unwrap(); + let r = s.execute("view.takeSnapshot", json!({"scale": 0.25})).unwrap(); + assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(160), Some(90))); + // The project changes; the snapshot keeps the old frame. + s.execute("layer.newSolid", json!({"color": "#0000ff", "width": 640, "height": 360})).unwrap(); + let snap = s.snapshot.clone().unwrap(); + assert_eq!(snap.image.get(10, 10), [1.0, 0.0, 0.0, 1.0]); + let now = s.render(s.active_comp_id().unwrap(), Tick::ZERO, effectcraft_render::RenderOpts { scale: 0.25, ..Default::default() }); + assert_eq!(now.get(10, 10), [0.0, 0.0, 1.0, 1.0]); + s.execute("view.showSnapshot", json!({"value": true})).unwrap(); + assert!(s.state.viewer.show_snapshot); + let (w, h, rgba) = crate::commands::viewer_cmds::snapshot_rgba8(&s).unwrap(); + assert_eq!((w, h), (160, 90)); + assert_eq!(&rgba[0..4], &[255, 0, 0, 255]); + s.execute("edit.purge", json!({"what": "snapshot"})).unwrap(); + assert!(s.snapshot.is_none() && !s.state.viewer.show_snapshot); +} + +#[test] +fn guides_move_and_remove_with_undo() { + let mut s = comp(); + s.execute("view.addGuide", json!({"orientation": "horizontal", "position": 40})).unwrap(); + s.execute("view.moveGuide", json!({"index": 0, "position": 90, "merge": "g"})).unwrap(); + s.execute("view.moveGuide", json!({"index": 0, "position": 95, "merge": "g"})).unwrap(); + assert_eq!(s.active_comp().unwrap().guides[0].position, 95.0); + s.undo(); + assert_eq!(s.active_comp().unwrap().guides[0].position, 40.0); + s.execute("view.removeGuide", json!({"index": 0})).unwrap(); + assert!(s.active_comp().unwrap().guides.is_empty()); + assert!(s.execute("view.removeGuide", json!({"index": 0})).is_err()); + s.undo(); + assert_eq!(s.active_comp().unwrap().guides.len(), 1); +} + +#[test] +fn region_of_interest_renders_and_crops() { + let mut s = comp(); + s.execute("layer.newSolid", json!({"color": "#ff8000", "width": 200, "height": 100})).unwrap(); + s.execute("view.setRegionOfInterest", json!({"rect": [200, 100, 240, 160]})).unwrap(); + let cid = s.active_comp_id().unwrap(); + let full = s.render(cid, Tick::ZERO, Default::default()); + let roi = s.render(cid, Tick::ZERO, effectcraft_render::RenderOpts { roi: s.state.region_of_interest, ..Default::default() }); + assert_eq!((roi.width, roi.height), (240, 160)); + for (x, y) in [(0, 0), (30, 50), (239, 159), (119, 79)] { + assert_eq!(roi.get(x, y), full.get(x + 200, y + 100)); + } + s.execute("comp.cropToRegionOfInterest", json!({})).unwrap(); + let c = s.active_comp().unwrap(); + assert_eq!((c.width, c.height), (240, 160)); + let cropped = s.render(cid, Tick::ZERO, Default::default()); + assert_eq!(cropped.get(30, 50), roi.get(30, 50)); + assert_eq!(cropped.get(30, 50)[3], 1.0); +} + +// ---------------------------------------------------------------- shape pen and vertices + +fn shape_path(s: &Session, lid: u64, uid: u64) -> effectcraft_keyframe::ShapePath { + let l = layer(s, lid); + l.props.find_group(uid).unwrap().get("path").unwrap().value.as_path().unwrap().clone() +} + +#[test] +fn shape_pen_builds_shape_group_and_renders() { + let mut s = comp(); + // Nothing selected: a new shape layer; vertices in comp space. + let r = s + .execute("shape.newPath", json!({"vertices": [[100, 100], [300, 100], [300, 250], [100, 250]], "closed": true, "fill": [0, 1, 0], "strokeWidth": 0})) + .unwrap(); + let (lid, group, path) = (r["layer"].as_u64().unwrap(), r["group"].as_u64().unwrap(), r["path"].as_u64().unwrap()); + let l = layer(&s, lid); + assert!(matches!(l.source, effectcraft_project::LayerSource::Shape)); + let g = l.props.find_group(group).unwrap(); + assert_eq!(g.name, "Shape 1"); + let items: Vec<&str> = g.sub("contents").unwrap().groups().map(|x| x.match_id.as_str()).collect(); + assert_eq!(items, vec!["path", "fill"]); + assert_eq!(g.sub("contents").unwrap().groups().next().unwrap().name, "Path 1"); + // Layer space: the layer sits at the comp centre. + assert_eq!(shape_path(&s, lid, path).vertices[0], [100.0 - 320.0, 100.0 - 180.0]); + let img = s.render(s.active_comp_id().unwrap(), Tick::ZERO, Default::default()); + assert_eq!(img.get(200, 170), [0.0, 1.0, 0.0, 1.0]); + assert_eq!(img.get(50, 50)[3], 0.0); + // With the shape layer selected the Pen adds a second group (with a stroke) to it. + let r2 = s.execute("shape.newPath", json!({"vertices": [[0, 0], [10, 10]], "space": "layer"})).unwrap(); + assert_eq!(r2["layer"].as_u64(), Some(lid)); + let l = layer(&s, lid); + let names: Vec = l.props.sub("contents").unwrap().groups().map(|g| g.name.clone()).collect(); + assert_eq!(names, vec!["Shape 2", "Shape 1"]); + let g2 = l.props.find_group(r2["group"].as_u64().unwrap()).unwrap(); + let items: Vec<&str> = g2.sub("contents").unwrap().groups().map(|x| x.match_id.as_str()).collect(); + assert_eq!(items, vec!["path", "stroke", "fill"]); + s.undo(); + assert_eq!(layer(&s, lid).props.sub("contents").unwrap().groups().count(), 1); + // Not a shape layer → error. + let sol = s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap(); + assert!(s.execute("shape.newPath", json!({"layer": sol, "vertices": [[0, 0]]})).is_err()); +} + +#[test] +fn vertex_add_delete_convert_on_shape_paths_and_masks_undo() { + let mut s = comp(); + let r = s.execute("shape.newPath", json!({"vertices": [[0, 0], [100, 0], [100, 100]], "space": "layer"})).unwrap(); + let (lid, path) = (r["layer"].as_u64().unwrap(), r["path"].as_u64().unwrap()); + // Pen continues the path: add a vertex, move it, close. + s.execute("mask.addVertex", json!({"layer": lid, "mask": path, "point": [0, 100]})).unwrap(); + s.execute("mask.setClosed", json!({"layer": lid, "mask": path, "closed": true})).unwrap(); + let sp = shape_path(&s, lid, path); + assert_eq!(sp.vertices.len(), 4); + assert!(sp.closed); + // Add Vertex on segment 0 at t=0.5 keeps the line and inserts its midpoint. + let i = s.execute("mask.insertVertex", json!({"layer": lid, "mask": path, "segment": 0})).unwrap(); + assert_eq!(i.as_u64(), Some(1)); + assert_eq!(shape_path(&s, lid, path).vertices[1], [50.0, 0.0]); + // Convert Vertex: corner → smooth → corner. + let c = s.execute("mask.convertVertex", json!({"layer": lid, "mask": path, "index": 2})).unwrap(); + assert_eq!(c["smooth"], json!(true)); + let sp = shape_path(&s, lid, path); + // Neighbours (50,0) and (100,100): tangent along them, a sixth of the way. + assert_eq!(sp.out_tangents[2], [50.0 / 6.0, 100.0 / 6.0]); + assert_eq!(sp.in_tangents[2], [-50.0 / 6.0, -100.0 / 6.0]); + s.execute("mask.convertVertex", json!({"layer": lid, "mask": path, "index": 2})).unwrap(); + assert_eq!(shape_path(&s, lid, path).out_tangents[2], [0.0, 0.0]); + s.undo(); + assert_ne!(shape_path(&s, lid, path).out_tangents[2], [0.0, 0.0]); + // Delete Vertex (selection-based). + s.execute("mask.selectVertices", json!({"vertices": [{"layer": lid, "mask": path, "index": 1}]})).unwrap(); + s.execute("mask.deleteVertices", json!({})).unwrap(); + assert_eq!(shape_path(&s, lid, path).vertices.len(), 4); + s.undo(); + assert_eq!(shape_path(&s, lid, path).vertices.len(), 5); + // Free transform of the whole shape path (scale 200% about the bbox centre). + s.execute("mask.selectVertices", json!({"vertices": []})).unwrap(); + s.execute("path.freeTransform", json!({"layer": lid, "mask": path, "scale": 200})).unwrap(); + assert_eq!(shape_path(&s, lid, path).vertices[0], [-50.0, -50.0]); + // Masks work through the same commands. + let sol = s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap(); + let m = s.execute("mask.new", json!({"layer": sol, "vertices": [[0, 0], [100, 0], [100, 100]], "closed": true})).unwrap()["mask"].as_u64().unwrap(); + s.execute("mask.insertVertex", json!({"layer": sol, "mask": m, "segment": 2, "t": 0.5})).unwrap(); + let l = layer(&s, sol); + let mp = l.masks().unwrap().find_group(m).unwrap().get("path").unwrap().value.as_path().unwrap().clone(); + assert_eq!(mp.vertices.len(), 4); + assert_eq!(mp.vertices[3], [50.0, 50.0]); +} + +#[test] +fn split_segment_keeps_curve_shape() { + use effectcraft_keyframe::ShapePath; + let mut sp = ShapePath { + vertices: vec![[0.0, 0.0], [100.0, 0.0]], + in_tangents: vec![[0.0; 2], [0.0, -50.0]], + out_tangents: vec![[30.0, 60.0], [0.0; 2]], + closed: false, + }; + let seg = |sp: &ShapePath, i: usize, t: f64| { + let (a, d) = (sp.vertices[i], sp.vertices[i + 1]); + let b = [a[0] + sp.out_tangents[i][0], a[1] + sp.out_tangents[i][1]]; + let c = [d[0] + sp.in_tangents[i + 1][0], d[1] + sp.in_tangents[i + 1][1]]; + let u = 1.0 - t; + let f = |k: usize| u * u * u * a[k] + 3.0 * u * u * t * b[k] + 3.0 * u * t * t * c[k] + t * t * t * d[k]; + [f(0), f(1)] + }; + let before: Vec<[f64; 2]> = (0..=2000).map(|i| seg(&sp, 0, i as f64 / 2000.0)).collect(); + crate::commands::split_segment_for_tests(&mut sp, 0, 0.3); + assert_eq!(sp.vertices.len(), 3); + for i in 0..2 { + for j in 0..=50 { + let p = seg(&sp, i, j as f64 / 50.0); + let d = before.iter().map(|q| ((p[0] - q[0]).powi(2) + (p[1] - q[1]).powi(2)).sqrt()).fold(f64::MAX, f64::min); + assert!(d < 0.2, "{p:?} {d}"); + } + } +} + +// ---------------------------------------------------------------- motion paths and graph box + +fn animated_position(s: &mut Session) -> u64 { + let lid = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap(); + for (t, v) in [(0.0, [100.0, 100.0]), (1.0, [300.0, 100.0]), (2.0, [300.0, 300.0])] { + s.execute("prop.addKey", json!({"layer": lid, "path": "transform/position", "time": t, "value": [v[0], v[1], 0.0]})).unwrap(); + } + lid +} + +#[test] +fn motion_path_key_drag_edits_value_and_tangents() { + let mut s = comp(); + let lid = animated_position(&mut s); + // Dragging the middle key's dot sets that key's value (merged drag = one undo step). + s.execute("keys.set", json!({"layer": lid, "path": "transform/position", "time": 1.0, "value": [310, 120, 0], "merge": "mp"})).unwrap(); + s.execute("keys.set", json!({"layer": lid, "path": "transform/position", "time": 1.0, "value": [320, 140, 0], "merge": "mp"})).unwrap(); + let pos = |s: &Session| layer(s, lid).props.prop("transform/position").unwrap().clone(); + assert_eq!(pos(&s).keys[1].value, KV::Vec3([320.0, 140.0, 0.0])); + assert_eq!(pos(&s).keys[0].value, KV::Vec3([100.0, 100.0, 0.0])); + // Tangent drag: out handle set, in mirrored, no longer auto. + s.execute("keys.setSpatialTangents", json!({"layer": lid, "path": "transform/position", "time": 1.0, "out": [40, 0], "merge": "tg"})).unwrap(); + let k = pos(&s).keys[1].clone(); + assert_eq!(k.spatial_out, [40.0, 0.0, 0.0]); + assert_eq!(k.spatial_in, [-40.0, 0.0, 0.0]); + assert!(!k.spatial_auto); + // The motion path passes through the key and bulges along the tangent. + let mid = pos(&s).value_at(Tick::from_seconds_f64(1.0)).as_vec3(); + assert_eq!([mid[0], mid[1]], [320.0, 140.0]); + // Alt-drag (break): only the in handle changes. + s.execute("keys.setSpatialTangents", json!({"layer": lid, "path": "transform/position", "time": 1.0, "in": [0, -30], "break": true})).unwrap(); + let k = pos(&s).keys[1].clone(); + assert_eq!((k.spatial_in, k.spatial_out), ([0.0, -30.0, 0.0], [40.0, 0.0, 0.0])); + s.undo(); + s.undo(); + assert!(pos(&s).keys[1].spatial_auto); + s.undo(); + assert_eq!(pos(&s).keys[1].value, KV::Vec3([300.0, 100.0, 0.0])); +} + +#[test] +fn graph_transform_box_scales_key_times_and_values() { + let mut s = comp(); + let lid = s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap(); + for (t, v) in [(0.0, 0.0), (1.0, 50.0), (2.0, 100.0)] { + s.execute("prop.addKey", json!({"layer": lid, "path": "transform/opacity", "time": t, "value": v})).unwrap(); + } + let uid = layer(&s, lid).props.prop("transform/opacity").unwrap().uid; + let keys: Vec<_> = [0.0, 1.0, 2.0].iter().map(|t| json!({"layer": lid, "prop": uid, "time": t})).collect(); + s.execute("keys.select", json!({"keys": keys})).unwrap(); + // Time: scale 2× about 0 s; value: scale 0.5 about 100. + s.execute("keys.transform", json!({"timeScale": 2.0, "timeAnchor": 0.0, "valueScale": 0.5, "valueAnchor": 100.0})).unwrap(); + let p = layer(&s, lid).props.prop("transform/opacity").unwrap().clone(); + let got: Vec<(f64, f64)> = p.keys.iter().map(|k| (k.time.seconds(), k.value.as_f64())).collect(); + assert_eq!(got, vec![(0.0, 50.0), (2.0, 75.0), (4.0, 100.0)]); + assert_eq!(s.state.selected_keys.len(), 3); + // Alt-drag of the last key in the timeline: scale the group about the first key; times + // land on frames. + s.execute("keys.transform", json!({"timeScale": 0.501, "timeAnchor": 0.0})).unwrap(); + let p = layer(&s, lid).props.prop("transform/opacity").unwrap().clone(); + let got: Vec = p.keys.iter().map(|k| k.time.seconds()).collect(); + assert_eq!(got, vec![0.0, 1.0, 2.0]); + s.undo(); + s.undo(); + let p = layer(&s, lid).props.prop("transform/opacity").unwrap().clone(); + assert_eq!(p.keys.iter().map(|k| k.time.seconds()).collect::>(), vec![0.0, 1.0, 2.0]); + assert_eq!(p.keys[1].value.as_f64(), 50.0); + assert!(s.execute("keys.transform", json!({"timeScale": 0})).is_err()); +} diff --git a/crates/engine/src/viewer.rs b/crates/engine/src/viewer.rs new file mode 100644 index 00000000..0c002fa1 --- /dev/null +++ b/crates/engine/src/viewer.rs @@ -0,0 +1,372 @@ +//! Composition viewer maths shared by the frontends and tests: snapping (targets, tolerance and +//! priority), the Show Channel / Exposure display transforms, and the viewer snapshot. +//! +//! Nothing here touches the project; edits go through commands. + +use std::sync::Arc; + +use effectcraft_geom::vec2; +use effectcraft_project::{Layer, LayerId}; +use effectcraft_raster::Image; +use effectcraft_render::EvalCtx; +use effectcraft_time::Tick; +use serde::{Deserialize, Serialize}; + +// ---------------------------------------------------------------- snapping + +/// What a snap target is: a point (snaps both axes) or an axis-aligned line. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub enum SnapKind { + Point, + /// A vertical line at `pos[0]` (snaps x only). + VLine, + /// A horizontal line at `pos[1]` (snaps y only). + HLine, +} + +/// Where a snap target comes from (for feedback: the highlighted box / cross). +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub enum SnapSource { + Layer(LayerId), + Comp, + Guide, + Grid, + /// A mask or shape path vertex of a layer. + Vertex(LayerId), +} + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct SnapTarget { + pub kind: SnapKind, + /// Comp pixels. + pub pos: [f64; 2], + /// Lower wins when two targets are equally near (vertices and layer features before + /// guides, guides before the grid). + pub priority: u8, + pub source: SnapSource, +} + +impl SnapTarget { + pub fn point(pos: [f64; 2], priority: u8, source: SnapSource) -> SnapTarget { + SnapTarget { kind: SnapKind::Point, pos, priority, source } + } + pub fn vline(x: f64, priority: u8, source: SnapSource) -> SnapTarget { + SnapTarget { kind: SnapKind::VLine, pos: [x, 0.0], priority, source } + } + pub fn hline(y: f64, priority: u8, source: SnapSource) -> SnapTarget { + SnapTarget { kind: SnapKind::HLine, pos: [0.0, y], priority, source } + } +} + +/// Priorities of the standard targets. +pub const PRI_VERTEX: u8 = 0; +pub const PRI_LAYER: u8 = 1; +pub const PRI_COMP: u8 = 2; +pub const PRI_GUIDE: u8 = 3; +pub const PRI_GRID: u8 = 4; + +/// A snap: move the dragged feature by `delta` (comp pixels); `hits` are the targets it lands on. +#[derive(Clone, Debug, PartialEq)] +pub struct Snap { + pub delta: [f64; 2], + pub hits: Vec, + /// The snapped feature position. + pub at: [f64; 2], +} + +fn better(a: (f64, u8), b: (f64, u8)) -> bool { + if (a.0 - b.0).abs() < 1e-6 { a.1 < b.1 } else { a.0 < b.0 } +} + +/// Snap feature points `sources` (comp pixels) to the nearest target within `tol` comp pixels. +/// A point target snaps both axes and wins when it is at least as near as the best lines; +/// otherwise the nearest vertical and horizontal lines snap x and y independently. Ties go to +/// the lower priority value. +pub fn snap(sources: &[[f64; 2]], targets: &[SnapTarget], tol: f64) -> Option { + let mut best_pt: Option<((f64, u8), [f64; 2], SnapTarget)> = None; + let mut best_x: Option<((f64, u8), [f64; 2], SnapTarget)> = None; + let mut best_y: Option<((f64, u8), [f64; 2], SnapTarget)> = None; + for s in sources { + for t in targets { + let (d, slot) = match t.kind { + SnapKind::Point => (((t.pos[0] - s[0]).powi(2) + (t.pos[1] - s[1]).powi(2)).sqrt(), &mut best_pt), + SnapKind::VLine => ((t.pos[0] - s[0]).abs(), &mut best_x), + SnapKind::HLine => ((t.pos[1] - s[1]).abs(), &mut best_y), + }; + if d > tol { + continue; + } + let key = (d, t.priority); + if slot.as_ref().is_none_or(|(k, _, _)| better(key, *k)) { + *slot = Some((key, *s, *t)); + } + } + } + let line_best = [best_x.map(|b| b.0.0), best_y.map(|b| b.0.0)].into_iter().flatten().fold(f64::INFINITY, f64::min); + if let Some((k, s, t)) = best_pt + && k.0 <= line_best + 1e-9 + { + return Some(Snap { delta: [t.pos[0] - s[0], t.pos[1] - s[1]], hits: vec![t], at: t.pos }); + } + if best_x.is_none() && best_y.is_none() { + return None; + } + let mut delta = [0.0; 2]; + let mut hits = vec![]; + let mut at = [f64::NAN; 2]; + if let Some((_, s, t)) = best_x { + delta[0] = t.pos[0] - s[0]; + at = [t.pos[0], s[1]]; + hits.push(t); + } + if let Some((_, s, t)) = best_y { + delta[1] = t.pos[1] - s[1]; + at = if at[0].is_nan() { [s[0], t.pos[1]] } else { [at[0], t.pos[1]] }; + hits.push(t); + } + Some(Snap { delta, hits, at }) +} + +/// Snap features of a layer in comp space: corners, edge midpoints, centre and anchor point +/// (2D layers; `None` for layers without bounds). +pub fn layer_features(ctx: &EvalCtx, layer: &Layer) -> Vec<[f64; 2]> { + let mut out = vec![]; + let (m, _) = ctx.layer_to_comp(layer); + if let Some(b) = effectcraft_render::content_bounds(ctx, layer) { + let (cx, cy) = ((b[0] + b[2]) / 2.0, (b[1] + b[3]) / 2.0); + for p in [[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]], [cx, b[1]], [b[2], cy], [cx, b[3]], [b[0], cy], [cx, cy]] { + let q = m.apply(vec2(p[0], p[1])); + out.push([q.x, q.y]); + } + } + if let Some(tr) = layer.transform() { + let a = ctx.v3(layer, tr, "anchor", [0.0; 3]); + let q = m.apply(vec2(a[0], a[1])); + out.push([q.x, q.y]); + } + out +} + +/// Mask and shape-path vertices of a layer in comp space. +pub fn layer_vertices(ctx: &EvalCtx, layer: &Layer) -> Vec<[f64; 2]> { + let (m, _) = ctx.layer_to_comp(layer); + let mut out = vec![]; + if let Some(masks) = layer.masks() { + for g in masks.groups() { + if let Some(sp) = g.get("path").map(|pr| ctx.value(layer, pr)).and_then(|v| v.as_path().cloned()) { + out.extend(sp.vertices.iter().map(|v| { + let q = m.apply(vec2(v[0], v[1])); + [q.x, q.y] + })); + } + } + } + for (uid, sp) in shape_paths(ctx, layer) { + let pm = m * effectcraft_render::shapes::item_matrix(ctx, layer, uid).unwrap_or(effectcraft_geom::Mat3::IDENTITY); + out.extend(sp.vertices.iter().map(|v| { + let q = pm.apply(vec2(v[0], v[1])); + [q.x, q.y] + })); + } + out +} + +/// Shape-layer Path items (uid, evaluated path in the item's group space). +pub fn shape_paths(ctx: &EvalCtx, layer: &Layer) -> Vec<(u64, effectcraft_keyframe::ShapePath)> { + let mut out = vec![]; + let Some(c) = layer.props.sub("contents") else { return out }; + fn walk(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup, out: &mut Vec<(u64, effectcraft_keyframe::ShapePath)>) { + for sub in g.groups() { + if sub.match_id == "path" + && let Some(sp) = sub.get("path").map(|pr| ctx.value(layer, pr)).and_then(|v| v.as_path().cloned()) + { + out.push((sub.uid, sp)); + } else if sub.match_id == "group" || sub.match_id == "contents" { + walk(ctx, layer, sub, out); + } + } + } + walk(ctx, layer, c, &mut out); + out +} + +/// Which target families are on. +#[derive(Clone, Copy, Debug, Default)] +pub struct SnapOptions { + pub guides: bool, + pub grid: bool, + pub grid_spacing: f64, +} + +/// Snap targets of a comp at the context time: other layers' features and vertices, comp edges +/// and centre, and (when on) guides and grid lines. Layers in `exclude` (the dragged ones) are +/// skipped. +pub fn targets(ctx: &EvalCtx, exclude: &[LayerId], opts: SnapOptions) -> Vec { + let comp = ctx.comp; + let (w, h) = (comp.width as f64, comp.height as f64); + let mut out = vec![ + SnapTarget::vline(0.0, PRI_COMP, SnapSource::Comp), + SnapTarget::vline(w, PRI_COMP, SnapSource::Comp), + SnapTarget::vline(w / 2.0, PRI_COMP, SnapSource::Comp), + SnapTarget::hline(0.0, PRI_COMP, SnapSource::Comp), + SnapTarget::hline(h, PRI_COMP, SnapSource::Comp), + SnapTarget::hline(h / 2.0, PRI_COMP, SnapSource::Comp), + SnapTarget::point([w / 2.0, h / 2.0], PRI_COMP, SnapSource::Comp), + ]; + for l in comp.layers.iter().filter(|l| l.is_active_at(ctx.time) && !exclude.contains(&l.id) && !l.is_camera() && !l.is_light() && !l.is_3d()) { + let f = layer_features(ctx, l); + // Edges of unrotated layers are lines too. + if f.len() >= 9 && (f[0][1] - f[1][1]).abs() < 1e-6 && (f[0][0] - f[3][0]).abs() < 1e-6 { + for x in [f[0][0], f[1][0]] { + out.push(SnapTarget { kind: SnapKind::VLine, pos: [x, f[0][1]], priority: PRI_LAYER, source: SnapSource::Layer(l.id) }); + } + for y in [f[0][1], f[3][1]] { + out.push(SnapTarget { kind: SnapKind::HLine, pos: [f[0][0], y], priority: PRI_LAYER, source: SnapSource::Layer(l.id) }); + } + } + out.extend(f.into_iter().map(|p| SnapTarget::point(p, PRI_LAYER, SnapSource::Layer(l.id)))); + out.extend(layer_vertices(ctx, l).into_iter().map(|p| SnapTarget::point(p, PRI_VERTEX, SnapSource::Vertex(l.id)))); + } + if opts.guides { + for g in &comp.guides { + out.push(if g.vertical { + SnapTarget::vline(g.position, PRI_GUIDE, SnapSource::Guide) + } else { + SnapTarget::hline(g.position, PRI_GUIDE, SnapSource::Guide) + }); + } + } + if opts.grid && opts.grid_spacing >= 1.0 { + let s = opts.grid_spacing; + let mut x = 0.0; + while x <= w + 1e-9 && out.len() < 20_000 { + out.push(SnapTarget::vline(x, PRI_GRID, SnapSource::Grid)); + x += s; + } + let mut y = 0.0; + while y <= h + 1e-9 && out.len() < 40_000 { + out.push(SnapTarget::hline(y, PRI_GRID, SnapSource::Grid)); + y += s; + } + } + out +} + +// ---------------------------------------------------------------- channels and exposure + +/// Show Channel and Color Management Settings. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum Channel { + #[default] + Rgb, + Red, + Green, + Blue, + Alpha, + RgbStraight, +} + +impl Channel { + pub const ALL: [Channel; 6] = [Channel::Rgb, Channel::Red, Channel::Green, Channel::Blue, Channel::Alpha, Channel::RgbStraight]; + pub fn id(self) -> &'static str { + match self { + Channel::Rgb => "rgb", + Channel::Red => "red", + Channel::Green => "green", + Channel::Blue => "blue", + Channel::Alpha => "alpha", + Channel::RgbStraight => "rgbStraight", + } + } + pub fn label(self) -> &'static str { + match self { + Channel::Rgb => "RGB", + Channel::Red => "Red", + Channel::Green => "Green", + Channel::Blue => "Blue", + Channel::Alpha => "Alpha", + Channel::RgbStraight => "RGB Straight", + } + } + pub fn from_name(s: &str) -> Option { + let n = s.to_ascii_lowercase().replace([' ', '_', '-'], ""); + Channel::ALL.into_iter().find(|c| c.id().to_ascii_lowercase() == n || c.label().to_ascii_lowercase().replace(' ', "") == n) + } +} + +fn srgb_to_linear(v: f32) -> f32 { + if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } +} +fn linear_to_srgb(v: f32) -> f32 { + let v = v.max(0.0); + if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 } +} + +/// 8-bit exposure lookup table: `stops` applied in linear light. +pub fn exposure_lut(stops: f32) -> [u8; 256] { + let k = 2f32.powf(stops); + let mut lut = [0u8; 256]; + for (i, o) in lut.iter_mut().enumerate() { + let lin = srgb_to_linear(i as f32 / 255.0) * k; + *o = (linear_to_srgb(lin).clamp(0.0, 1.0) * 255.0 + 0.5) as u8; + } + lut +} + +/// Apply the viewer's channel and exposure to premultiplied 8-bit RGBA pixels in place. +/// RGB shows the image (exposure only); Red/Green/Blue/Alpha show one channel as an opaque +/// grayscale image (or tinted in that channel's colour when `colorized`); RGB Straight shows the +/// unpremultiplied colour, opaque. +pub fn display_transform(px: &mut [[u8; 4]], channel: Channel, colorized: bool, stops: f32) { + let lut = (stops != 0.0).then(|| exposure_lut(stops)); + let ex = |v: u8| lut.as_ref().map(|l| l[v as usize]).unwrap_or(v); + for p in px.iter_mut() { + let [r, g, b, a] = *p; + *p = match channel { + Channel::Rgb => { + if lut.is_none() { + continue; + } + // Exposure on straight colour, re-premultiplied. + let un = |c: u8| if a == 0 { 0 } else { ((c as u32 * 255 + a as u32 / 2) / a as u32).min(255) as u8 }; + let pm = |c: u8| ((ex(un(c)) as u32 * a as u32 + 127) / 255) as u8; + [pm(r), pm(g), pm(b), a] + } + Channel::Red | Channel::Green | Channel::Blue => { + let (i, c) = match channel { + Channel::Red => (0, r), + Channel::Green => (1, g), + _ => (2, b), + }; + let v = ex(c); + if colorized { + let mut o = [0, 0, 0, 255]; + o[i] = v; + o + } else { + [v, v, v, 255] + } + } + Channel::Alpha => { + let v = ex(a); + [v, v, v, 255] + } + Channel::RgbStraight => { + let un = |c: u8| if a == 0 { 0 } else { ((c as u32 * 255 + a as u32 / 2) / a as u32).min(255) as u8 }; + [ex(un(r)), ex(un(g)), ex(un(b)), 255] + } + }; + } +} + +// ---------------------------------------------------------------- snapshot + +/// Take Snapshot (Shift+F5): a rendered frame kept for comparison (Show Snapshot, F5). +#[derive(Clone, Debug)] +pub struct Snapshot { + pub comp: effectcraft_project::ItemId, + pub time: Tick, + /// Render scale the image was taken at. + pub scale: f64, + pub image: Arc, +} diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 1616926d..b2daf0f9 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -172,6 +172,7 @@ pub(crate) fn render_frame(job: &Job, comp: &Comp, i: u64) -> Image { draft: job.settings.quality == RenderQuality::Draft, // Output renders always look through the comp's active camera. view: None, + roi: None, }; let mut r = Renderer::new(job.project, job.footage, opts); r.expr = job.expr; diff --git a/crates/lottie/src/import.rs b/crates/lottie/src/import.rs index 9ad38f39..c87dc187 100644 --- a/crates/lottie/src/import.rs +++ b/crates/lottie/src/import.rs @@ -170,6 +170,7 @@ pub fn import( codec: String::new(), missing: false, sequence: vec![], + color_profile: None, }; let item = project.add_item(&nm, Label::Lavender, Some(folder), ItemKind::Footage(foot)); cx.items.push(item); diff --git a/crates/lottie/src/tests.rs b/crates/lottie/src/tests.rs index 0cd38a09..5140133c 100644 --- a/crates/lottie/src/tests.rs +++ b/crates/lottie/src/tests.rs @@ -591,6 +591,7 @@ fn images_embed_as_base64_and_come_back() { codec: String::new(), missing: false, sequence: vec![], + color_profile: None, }), ); let c = comp(&p, cid); diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index a248cfc6..4e1c5611 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -71,6 +71,20 @@ pub(crate) fn footage_from_info(path: &str, info: &filmcraft_media::MediaInfo) - codec, missing: false, sequence: Vec::new(), + color_profile: v.and_then(|v| profile_of(&v.color)), + } +} + +/// The colour profile a video stream declares (H.273 primaries / transfer from the bitstream or +/// container). `None` (interpreted as sRGB) for Rec. 709 primaries with an sRGB curve. +fn profile_of(c: &filmcraft_color::ColorInfo) -> Option { + use effectcraft_project::ColorSpace; + use filmcraft_color::{Primaries, Transfer}; + match (c.primaries, c.transfer) { + (Primaries::Bt2020, _) => Some(ColorSpace::Rec2020), + (Primaries::P3D65, _) => Some(ColorSpace::DisplayP3), + (_, Transfer::Bt709) => Some(ColorSpace::Rec709), + _ => None, } } @@ -99,6 +113,7 @@ fn still_footage(path: &str, w: u32, h: u32, fmt: image::ImageFormat, has_alpha: codec, missing: false, sequence: Vec::new(), + color_profile: None, } } diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 722f3fe1..88fc6d02 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -13,6 +13,7 @@ pub mod tracking; use std::collections::BTreeMap; use std::sync::Arc; +pub use effectcraft_color::ColorSpace; use effectcraft_color::{BlendMode, Label}; use effectcraft_time::{FrameRate, Tick}; pub use props::{Expression, FeatherFalloff, GroupKind, MaskMode, MaskMotionBlur, Node, ParamUi, PropGroup, Property, Uid, parse_path}; @@ -70,6 +71,22 @@ impl BitDepth { pub fn is_float(self) -> bool { self == BitDepth::Bpc32 } + /// Quantisation levels per channel of the integer depths (8 bpc: 255; 16 bpc: 32768, After + /// Effects' "15 + 1" bit range), `None` for 32 bpc float. + pub fn levels(self) -> Option { + match self { + BitDepth::Bpc8 => Some(255.0), + BitDepth::Bpc16 => Some(32768.0), + BitDepth::Bpc32 => None, + } + } + pub fn bits(self) -> u32 { + match self { + BitDepth::Bpc8 => 8, + BitDepth::Bpc16 => 16, + BitDepth::Bpc32 => 32, + } + } } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -82,8 +99,16 @@ pub enum TimeDisplayStyle { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct ProjectSettings { pub bit_depth: BitDepth, - /// Blend in linear light instead of the sRGB-encoded working space. + /// Linearize Working Space: the working space uses linear light (1.0 gamma) for everything + /// (sources, effects, blending). Needs a working space. pub linearize: bool, + /// Working Space (colour management). `None` = no colour management. + #[serde(default)] + pub working_space: Option, + /// Blend Colors Using 1.0 Gamma: layers blend in linear light (sources and effects stay in + /// the working space's encoding). + #[serde(default)] + pub blend_linear: bool, pub time_display: TimeDisplayStyle, /// Frame numbering starts at 0 (or 1). pub frame_start: i64, @@ -92,7 +117,15 @@ pub struct ProjectSettings { impl Default for ProjectSettings { fn default() -> Self { - ProjectSettings { bit_depth: BitDepth::Bpc8, linearize: false, time_display: TimeDisplayStyle::Timecode, frame_start: 0, audio_sample_rate: 48_000 } + ProjectSettings { + bit_depth: BitDepth::Bpc8, + linearize: false, + working_space: None, + blend_linear: false, + time_display: TimeDisplayStyle::Timecode, + frame_start: 0, + audio_sample_rate: 48_000, + } } } @@ -520,6 +553,9 @@ pub struct Footage { /// Image sequence files (when kind = Sequence). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub sequence: Vec, + /// Colour profile (from the file's metadata, or Interpret Footage). `None` = sRGB. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub color_profile: Option, } fn one() -> u32 { diff --git a/crates/raster/src/flow.rs b/crates/raster/src/flow.rs new file mode 100644 index 00000000..c398eadd --- /dev/null +++ b/crates/raster/src/flow.rs @@ -0,0 +1,290 @@ +//! Optical flow by block matching, and motion-compensated frame interpolation (the Pixel Motion +//! mode of frame blending). +//! +//! The method is the textbook hierarchical block matcher used in video coding and frame-rate +//! conversion: build a luma pyramid of both frames; at the coarsest level, find for each block of +//! frame A the integer displacement into frame B with the smallest sum of absolute differences +//! (SAD) in a search window; at each finer level, start from the doubled coarse vectors (and the +//! neighbours' vectors) and refine in a small window; finish with a sub-pixel parabola fit of the +//! SAD around the best vector and a 3×3 vector median to remove outliers. +//! +//! Interpolation at a fraction `w` between the frames warps both towards the in-between time — +//! A forward by `w·F`, B back by `(1 − w)·F` — and cross-fades them, so moving detail stays sharp +//! instead of double-exposing as with a plain frame mix. + +use rayon::prelude::*; + +use crate::Image; + +/// A block motion field: one vector per `block × block` tile of frame A (in pixels, A → B). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Flow { + pub width: u32, + pub height: u32, + pub block: u32, + /// Tiles across / down. + pub cols: usize, + pub rows: usize, + pub v: Vec<[f32; 2]>, +} + +impl Flow { + /// The motion at pixel position (x, y), bilinearly interpolated between tile centres. + pub fn at(&self, x: f64, y: f64) -> [f32; 2] { + if self.v.is_empty() { + return [0.0; 2]; + } + let b = self.block as f64; + let fx = (x / b - 0.5).clamp(0.0, (self.cols - 1) as f64); + let fy = (y / b - 0.5).clamp(0.0, (self.rows - 1) as f64); + let (x0, y0) = (fx.floor() as usize, fy.floor() as usize); + let (x1, y1) = ((x0 + 1).min(self.cols - 1), (y0 + 1).min(self.rows - 1)); + let (tx, ty) = ((fx - x0 as f64) as f32, (fy - y0 as f64) as f32); + let g = |c: usize, r: usize| self.v[r * self.cols + c]; + let mut o = [0.0; 2]; + for i in 0..2 { + let top = g(x0, y0)[i] + (g(x1, y0)[i] - g(x0, y0)[i]) * tx; + let bot = g(x0, y1)[i] + (g(x1, y1)[i] - g(x0, y1)[i]) * tx; + o[i] = top + (bot - top) * ty; + } + o + } +} + +/// A single-channel plane. +struct Plane { + w: usize, + h: usize, + d: Vec, +} + +impl Plane { + fn luma(img: &Image) -> Plane { + let d = img.data.par_iter().map(|p| 0.2126 * p[0] + 0.7152 * p[1] + 0.0722 * p[2] + 0.25 * p[3]).collect(); + Plane { w: img.width as usize, h: img.height as usize, d } + } + fn half(&self) -> Plane { + let (w, h) = ((self.w / 2).max(1), (self.h / 2).max(1)); + let d = (0..w * h) + .into_par_iter() + .map(|i| { + let (x, y) = (i % w * 2, i / w * 2); + let g = |dx: usize, dy: usize| self.d[(y + dy).min(self.h - 1) * self.w + (x + dx).min(self.w - 1)]; + (g(0, 0) + g(1, 0) + g(0, 1) + g(1, 1)) * 0.25 + }) + .collect(); + Plane { w, h, d } + } + #[inline] + fn get(&self, x: i64, y: i64) -> f32 { + self.d[y.clamp(0, self.h as i64 - 1) as usize * self.w + x.clamp(0, self.w as i64 - 1) as usize] + } +} + +/// SAD of the `bs` block at (bx, by) in `a` against `b` displaced by (dx, dy). +fn sad(a: &Plane, b: &Plane, bx: i64, by: i64, bs: i64, dx: i64, dy: i64) -> f32 { + let mut s = 0.0; + for y in by..by + bs { + for x in bx..bx + bs { + s += (a.get(x, y) - b.get(x + dx, y + dy)).abs(); + } + } + s +} + +/// Block-matching flow from `a` to `b` (same size). `block` is the tile size at full resolution; +/// `radius` the search range at the coarsest level (motion up to about `radius · 2^levels`). +pub fn block_flow(a: &Image, b: &Image, block: u32, radius: i32) -> Flow { + let bs = block.max(4) as usize; + let (w, h) = (a.width as usize, a.height as usize); + let cols = w.div_ceil(bs).max(1); + let rows = h.div_ceil(bs).max(1); + let mut flow = Flow { width: a.width, height: a.height, block: bs as u32, cols, rows, v: vec![[0.0; 2]; cols * rows] }; + if a.width != b.width || a.height != b.height || w < bs || h < bs { + return flow; + } + // Pyramids (level 0 = full resolution); stop while blocks still fit. + let mut pa = vec![Plane::luma(a)]; + let mut pb = vec![Plane::luma(b)]; + while pa.len() < 4 && pa.last().is_some_and(|p| p.w / 2 >= bs * 2 && p.h / 2 >= bs * 2) { + let (na, nb) = (pa.last().map(Plane::half), pb.last().map(Plane::half)); + pa.extend(na); + pb.extend(nb); + } + let top = pa.len() - 1; + // Integer vectors at the current level, per full-resolution tile. + let mut cur: Vec<[i64; 2]> = vec![[0, 0]; cols * rows]; + for level in (0..=top).rev() { + let (la, lb) = (&pa[level], &pb[level]); + let k = 1i64 << level; + // The block covers the tile's footprint at this level (at least 4 pixels). + let lbs = ((bs as i64) / k).max(4); + let r = if level == top { radius as i64 } else { 1 }; + let prev = cur.clone(); + cur = (0..cols * rows) + .into_par_iter() + .map(|i| { + let (c, rr) = ((i % cols) as i64, (i / cols) as i64); + let bx = c * bs as i64 / k; + let by = rr * bs as i64 / k; + // Candidates: this tile's and its neighbours' vectors from the coarser level. + let mut seeds: Vec<[i64; 2]> = vec![[0, 0]]; + if level != top { + for (dc, dr) in [(0, 0), (-1, 0), (1, 0), (0, -1), (0, 1)] { + let (nc, nr) = (c + dc, rr + dr); + if nc >= 0 && nr >= 0 && (nc as usize) < cols && (nr as usize) < rows { + let v = prev[nr as usize * cols + nc as usize]; + seeds.push([v[0] * 2, v[1] * 2]); + } + } + } + seeds.sort_unstable(); + seeds.dedup(); + let mut best = ([0i64, 0i64], f32::INFINITY); + for s in seeds { + for dy in -r..=r { + for dx in -r..=r { + let d = [s[0] + dx, s[1] + dy]; + // A tiny bias towards short vectors breaks ties in flat areas. + let e = sad(la, lb, bx, by, lbs, d[0], d[1]) + 1e-4 * (d[0].abs() + d[1].abs()) as f32; + if e < best.1 { + best = (d, e); + } + } + } + } + best.0 + }) + .collect(); + } + // Sub-pixel refinement (parabola through the SAD of the neighbours) at full resolution. + let (la, lb) = (&pa[0], &pb[0]); + let bsi = bs as i64; + let fine: Vec<[f32; 2]> = (0..cols * rows) + .into_par_iter() + .map(|i| { + let (bx, by) = ((i % cols) as i64 * bsi, (i / cols) as i64 * bsi); + let d = cur[i]; + let e0 = sad(la, lb, bx, by, bsi, d[0], d[1]); + let fit = |em: f32, ep: f32| { + let den = em - 2.0 * e0 + ep; + if den > 1e-6 { (0.5 * (em - ep) / den).clamp(-0.5, 0.5) } else { 0.0 } + }; + let sx = fit(sad(la, lb, bx, by, bsi, d[0] - 1, d[1]), sad(la, lb, bx, by, bsi, d[0] + 1, d[1])); + let sy = fit(sad(la, lb, bx, by, bsi, d[0], d[1] - 1), sad(la, lb, bx, by, bsi, d[0], d[1] + 1)); + [d[0] as f32 + sx, d[1] as f32 + sy] + }) + .collect(); + // 3×3 vector median (component-wise) against outliers. + flow.v = (0..cols * rows) + .into_par_iter() + .map(|i| { + let (c, r) = ((i % cols) as i64, (i / cols) as i64); + let mut xs = Vec::with_capacity(9); + let mut ys = Vec::with_capacity(9); + for dr in -1..=1 { + for dc in -1..=1 { + let (nc, nr) = (c + dc, r + dr); + if nc >= 0 && nr >= 0 && (nc as usize) < cols && (nr as usize) < rows { + let v = fine[nr as usize * cols + nc as usize]; + xs.push(v[0]); + ys.push(v[1]); + } + } + } + xs.sort_by(f32::total_cmp); + ys.sort_by(f32::total_cmp); + [xs[xs.len() / 2], ys[ys.len() / 2]] + }) + .collect(); + flow +} + +/// Cross-fade of two frames: `a·(1 − w) + b·w` (Frame Mix). +pub fn mix(a: &Image, b: &Image, w: f32) -> Image { + if a.width != b.width || a.height != b.height { + return if w < 0.5 { a.clone() } else { b.clone() }; + } + let mut out = a.clone(); + out.data.par_iter_mut().zip(b.data.par_iter()).for_each(|(p, q)| { + for c in 0..4 { + p[c] += (q[c] - p[c]) * w; + } + }); + out +} + +/// Motion-compensated frame at fraction `w` (0 = `a`, 1 = `b`) between two frames (Pixel Motion). +pub fn interpolate(a: &Image, b: &Image, w: f32) -> Image { + if a.width != b.width || a.height != b.height { + return mix(a, b, w); + } + let flow = block_flow(a, b, 8, 4); + let mut out = Image::new(a.width, a.height); + let wd = w as f64; + out.rows_mut().for_each(|(y, row)| { + let cy = y as f64 + 0.5; + for (x, px) in row.iter_mut().enumerate() { + let cx = x as f64 + 0.5; + let f = flow.at(cx, cy); + let (fx, fy) = (f[0] as f64, f[1] as f64); + let pa = a.sample_bilinear_clamped(cx - wd * fx, cy - wd * fy); + let pb = b.sample_bilinear_clamped(cx + (1.0 - wd) * fx, cy + (1.0 - wd) * fy); + for c in 0..4 { + px[c] = pa[c] + (pb[c] - pa[c]) * w; + } + } + }); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A textured image shifted right by `dx` pixels. + fn texture(w: u32, h: u32, dx: f64) -> Image { + let mut img = Image::new(w, h); + img.rows_mut().for_each(|(y, row)| { + for (x, p) in row.iter_mut().enumerate() { + let (u, v) = (x as f64 - dx, y as f64); + let s = 0.5 + 0.2 * (u * 0.21 + v * 0.07).sin() + 0.15 * (u * 0.05 - v * 0.19).cos() + 0.1 * (u * 0.013 * v * 0.011).sin(); + let s = s as f32; + *p = [s, s * 0.8, 1.0 - s, 1.0]; + } + }); + img + } + + fn mae(a: &Image, b: &Image, margin: u32) -> f32 { + let mut s = 0.0; + let mut n = 0; + for y in margin..a.height - margin { + for x in margin..a.width - margin { + let (p, q) = (a.get(x as i64, y as i64), b.get(x as i64, y as i64)); + s += (0..3).map(|c| (p[c] - q[c]).abs()).sum::(); + n += 3; + } + } + s / n as f32 + } + + #[test] + fn flow_finds_translation() { + let a = texture(128, 96, 0.0); + let b = texture(128, 96, 6.0); + let f = block_flow(&a, &b, 8, 4); + let m = f.at(64.0, 48.0); + assert!((m[0] - 6.0).abs() < 0.3 && m[1].abs() < 0.3, "{m:?}"); + } + + #[test] + fn pixel_motion_beats_frame_mix_on_translation() { + let a = texture(128, 96, 0.0); + let b = texture(128, 96, 6.0); + let truth = texture(128, 96, 3.0); + let pm = mae(&interpolate(&a, &b, 0.5), &truth, 12); + let fm = mae(&mix(&a, &b, 0.5), &truth, 12); + assert!(pm < fm * 0.25, "pixel motion {pm} vs frame mix {fm}"); + } +} diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index 643de9e7..dfe53848 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -4,6 +4,7 @@ //! heavy operation is row-parallel with rayon. pub mod blur; +pub mod flow; pub mod warp; use effectcraft_color::{BlendMode, blend_pixel}; diff --git a/crates/render/src/audio.rs b/crates/render/src/audio.rs index 246ff683..4884411f 100644 --- a/crates/render/src/audio.rs +++ b/crates/render/src/audio.rs @@ -329,6 +329,7 @@ mod tests { codec: String::new(), missing: false, sequence: vec![], + color_profile: None, }; let fid = p.add_item("x.wav", effectcraft_color::Label::SeaFoam, None, ItemKind::Footage(f)); let comp = Comp::new(64, 64, FrameRate::new(25, 1), Tick::from_seconds_f64(2.0)); diff --git a/crates/render/src/cache.rs b/crates/render/src/cache.rs index d1dc692c..8cd27d59 100644 --- a/crates/render/src/cache.rs +++ b/crates/render/src/cache.rs @@ -288,9 +288,13 @@ fn key_with(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, footage: bool hash_debug(&mut h, f); item.hash(&mut h); ctx.source_time(layer).0.hash(&mut h); + // Frame blending mixes neighbouring source frames. + ctx.comp.enable_frame_blending.hash(&mut h); } _ => return None, } + // Bit depth and colour management change the pixels. + crate::color::Pipe::of(&ctx.project.settings).key().hash(&mut h); ctx.comp_id.hash(&mut h); ctx.comp.width.hash(&mut h); ctx.comp.height.hash(&mut h); diff --git a/crates/render/src/color.rs b/crates/render/src/color.rs new file mode 100644 index 00000000..7b190b7d --- /dev/null +++ b/crates/render/src/color.rs @@ -0,0 +1,114 @@ +//! Project colour settings in the compositor: bit depth, working space, linear blending. +//! +//! * **Bit depth.** Pixels are `f32` throughout, but 8 and 16 bpc projects behave like integer +//! pipelines: each layer's pixels are clamped to 0..1 and quantised to the depth after its +//! source and masks and after every effect, and the comp is clamped and quantised after every +//! layer is blended. Over-range values (Add/Screen of bright layers, Exposure…) therefore only +//! survive in 32 bpc. 16 bpc uses After Effects' 0..32768 range. +//! * **Working space** (colour management). Footage is converted from its colour profile (its +//! metadata, else sRGB) into the working space; the top-level comp output is converted from +//! the working space to the sRGB display. Without a working space nothing is converted. +//! * **Linearize Working Space**: the working space is linear light, so sources (footage and +//! the colours of solids, text and shapes, which are authored in the working space's encoding) +//! are linearised before masks and effects, and everything runs in linear. +//! * **Blend Colors Using 1.0 Gamma**: only blending is linear. Each layer's finished pixels +//! are linearised just before they are transformed and blended, the comp accumulates in linear +//! and is encoded back to the working space when the comp is done (so precomps hand encoded +//! pixels to their parent). Without a working space the sRGB curve is used. + +use effectcraft_color::{ColorSpace, Conversion}; +use effectcraft_project::ProjectSettings; +use effectcraft_raster::Image; +use rayon::prelude::*; + +/// The colour pipeline of a project (see the module docs). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Pipe { + /// Quantisation levels (255, 32768) or `None` for 32 bpc float. + pub levels: Option, + /// Working space (`None` = unmanaged). + pub space: Option, + /// The working space is linear. + pub linear: bool, + /// Blend in linear light (and the working space is not already linear). + pub linear_blend: bool, +} + +impl Pipe { + pub fn of(s: &ProjectSettings) -> Pipe { + let linear = s.linearize && s.working_space.is_some(); + Pipe { levels: s.bit_depth.levels(), space: s.working_space, linear, linear_blend: s.blend_linear && !linear } + } + + /// The space whose curve encodes working-space pixels (sRGB when unmanaged). + fn curve(&self) -> ColorSpace { + self.space.unwrap_or(ColorSpace::Srgb) + } + + /// Footage with colour profile `profile` (`None` = sRGB) → working space. + pub fn media_in(&self, profile: Option) -> Option { + let ws = self.space?; + Conversion::new(profile.unwrap_or(ColorSpace::Srgb), false, ws, self.linear) + } + + /// Authored colours (solids, text, shapes) → working space: linearised in a linear working + /// space. + pub fn authored_in(&self) -> Option { + self.linear.then(|| Conversion::linearize(self.curve())) + } + + /// Working space → blending space (linear when blending with 1.0 gamma). + pub fn to_blend(&self) -> Option { + self.linear_blend.then(|| Conversion::linearize(self.curve())) + } + + /// Blending space → working space. + pub fn from_blend(&self) -> Option { + self.linear_blend.then(|| Conversion::delinearize(self.curve())) + } + + /// Working space → sRGB display (top-level comp output). + pub fn output(&self) -> Option { + let ws = self.space?; + Conversion::new(ws, self.linear, ColorSpace::Srgb, false) + } + + /// Clamp and quantise to the bit depth (no-op in 32 bpc). + pub fn quantize(&self, img: &mut Image) { + if let Some(l) = self.levels { + quantize(img, l); + } + } + + /// Hash of everything that changes pixels (for cache keys). + pub fn key(&self) -> u64 { + let mut k = self.levels.map_or(0, |l| l as u64); + k = k.wrapping_mul(31).wrapping_add(self.space.map_or(7, |s| s as u64 + 11)); + k = k.wrapping_mul(31).wrapping_add(self.linear as u64 * 2 + self.linear_blend as u64); + k + } +} + +/// Clamp premultiplied pixels to 0..1 (colour ≤ alpha) and round to `levels` steps. +pub fn quantize(img: &mut Image, levels: f32) { + let inv = 1.0 / levels; + img.data.par_iter_mut().for_each(|p| { + let a = (p[3].clamp(0.0, 1.0) * levels).round() * inv; + p[3] = a; + for c in 0..3 { + p[c] = (p[c].clamp(0.0, a) * levels).round() * inv; + } + }); +} + +/// Convert premultiplied pixels (on straight colour). +pub fn convert(img: &mut Image, c: &Conversion) { + img.data.par_iter_mut().for_each(|p| { + let a = p[3]; + if a <= 0.0 { + return; + } + let o = c.apply([p[0] / a, p[1] / a, p[2] / a]); + *p = [o[0] * a, o[1] * a, o[2] * a, a]; + }); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index dc7db2d4..6aa7ebd0 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -8,6 +8,7 @@ pub mod audio; pub mod cache; +pub mod color; pub mod eval; pub mod masks; pub mod shapes; @@ -20,11 +21,11 @@ use std::sync::Arc; pub use cache::{CacheStats, LayerCache}; use effectcraft_color::BlendMode; use effectcraft_effects::{Buf, EffectCtx, EffectEnv, EffectHost, LayerPixels, Params}; -use effectcraft_geom::{Mat3, vec2}; -use effectcraft_project::{Comp, Footage, GroupKind, ItemId, ItemKind, Layer, LayerSource, MatteKind, Project, Quality, Sampling}; +use effectcraft_geom::{Mat3, Mat4, vec2}; +use effectcraft_project::{Comp, Footage, FootageKind, FrameBlend, GroupKind, ItemId, ItemKind, Layer, LayerSource, MatteKind, Project, Quality, Sampling}; pub use effectcraft_raster::Image; use effectcraft_raster::{WarpOpts, composite_warp}; -use effectcraft_time::Tick; +use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick}; pub use eval::{EvalCtx, ExprHost, source_size}; use rayon::prelude::*; @@ -64,7 +65,7 @@ impl EffectHost for FxHost<'_, '_, '_> { if other.id == self.layer.id || self.r.depth > MAX_FX_DEPTH { return None; } - let sub = Renderer { depth: self.r.depth + 1, ..*self.r }; + let sub = self.r.nested(); // 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)? }; let size = source_size(self.r.project, other); @@ -77,7 +78,7 @@ impl EffectHost for FxHost<'_, '_, '_> { if other.id == self.layer.id || self.r.depth > MAX_FX_DEPTH { return None; } - let sub = Renderer { depth: self.r.depth + 1, ..*self.r }; + let sub = self.r.nested(); let buf = (*sub.layer_input(self.ctx, other, 0)?).clone(); let size = source_size(self.r.project, other); let size = if size.0 == 0 { [self.ctx.comp.width as f64, self.ctx.comp.height as f64] } else { [size.0 as f64, size.1 as f64] }; @@ -101,7 +102,7 @@ impl EffectHost for FxHost<'_, '_, '_> { } let n = effects.min(self.index.load(std::sync::atomic::Ordering::Relaxed)); let t = self.layer.comp_time(Tick::from_seconds_f64(layer_time)); - let sub = Renderer { depth: self.r.depth + 1, ..*self.r }; + let sub = self.r.nested(); sub.layer_input(&self.ctx.at(t), self.layer, n).map(|b| (*b).clone()) } @@ -111,7 +112,7 @@ impl EffectHost for FxHost<'_, '_, '_> { return None; } let ctx = self.ctx.at(Tick::from_seconds_f64(comp_time)); - let sub = Renderer { depth: self.r.depth + 1, ..*self.r }; + let sub = self.r.nested(); let buf = if masks_and_effects { (*sub.content_buf(&ctx, other)?).clone() } else { (*sub.layer_input(&ctx, other, 0)?).clone() }; let size = source_size(self.r.project, other); let size = if size.0 == 0 { [self.ctx.comp.width as f64, self.ctx.comp.height as f64] } else { [size.0 as f64, size.1 as f64] }; @@ -138,11 +139,14 @@ pub struct RenderOpts { pub draft: bool, /// 3D view camera override (viewer Front/Top/Custom views); None = the comp's active camera. pub view: Option, + /// Region of interest `[x, y, w, h]` in comp pixels (viewer only): the top-level frame covers + /// just this rectangle, and only its pixels are composited. + pub roi: Option<[f64; 4]>, } impl Default for RenderOpts { fn default() -> Self { - RenderOpts { scale: 1.0, motion_blur: true, guides: false, draft: false, view: None } + RenderOpts { scale: 1.0, motion_blur: true, guides: false, draft: false, view: None, roi: None } } } @@ -157,6 +161,26 @@ pub struct Renderer<'a> { pub profile: Option<&'a std::sync::Mutex>>, /// Current nesting depth (precomp recursion guard). depth: usize, + /// The project's colour pipeline (bit depth, working space, linear blending). + pub(crate) pipe: color::Pipe, + /// Collapsed precomp being drawn into its parent: nested comp pixels → parent comp pixels + /// (before the output scale). + outer: Option, + /// Opacity of the collapsed precomp layers this comp is drawn through. + opacity_mul: f32, + /// Collapsed precomp: nested 3D layers join the parent's 3D space. + pub(crate) collapse3d: Option>, +} + +/// How a collapsed precomp's 3D layers are placed in the parent (see `Renderer::collapse_into`). +#[derive(Clone, Copy)] +pub(crate) struct Collapse3d<'a> { + /// Nested comp world → parent world (the precomp layer's world transform). + pub world: Mat4, + /// The parent's camera. + pub cam: three_d::CameraState, + /// The outermost comp's context (its lights light the nested layers). + pub parent: EvalCtx<'a>, } /// Time spent on one layer of one frame (milliseconds). @@ -176,7 +200,139 @@ pub struct LayerTiming { impl<'a> Renderer<'a> { pub fn new(project: &'a Project, footage: &'a dyn FootageSource, opts: RenderOpts) -> Renderer<'a> { - Renderer { project, footage, expr: None, opts, cache: None, profile: None, depth: 0 } + Renderer { + project, + footage, + expr: None, + opts, + cache: None, + profile: None, + depth: 0, + pipe: color::Pipe::of(&project.settings), + outer: None, + opacity_mul: 1.0, + collapse3d: None, + } + } + + /// A renderer for nested work (precomps, layers read by effects): one level deeper. + fn nested(&self) -> Renderer<'a> { + Renderer { depth: self.depth + 1, outer: None, opacity_mul: 1.0, collapse3d: None, ..*self } + } + + /// 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(crate) fn collapsed(&self, ctx: &EvalCtx, layer: &Layer) -> Option { + if !layer.switches.collapse || layer.switches.adjustment || self.depth > 12 { + return None; + } + let LayerSource::Comp { item } = &layer.source else { return None }; + if self.project.comp_contains(*item, ctx.comp_id) || self.project.comp(*item).is_none() { + return None; + } + if layer.masks().is_some_and(|m| !m.children.is_empty()) { + return None; + } + if layer.switches.effects && layer.effects().is_some_and(|fx| fx.groups().any(|g| g.enabled)) { + return None; + } + if styles::active(ctx, layer) { + return None; + } + Some(*item) + } + + /// 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(crate) fn collapse_into(&self, ctx: &EvalCtx<'a>, layer: &Layer, item: ItemId, opacity: f32) -> Option<(Renderer<'a>, EvalCtx<'a>)> { + let nc = self.project.comp(item)?; + let nctx = EvalCtx { comp_id: item, comp: nc, time: ctx.source_time(layer), ..*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); + let c3 = Collapse3d { + world: self.collapse3d.map_or(world, |c| c.world * world), + cam: three_d::compose::camera_for(self, ctx), + parent: self.collapse3d.map_or(*ctx, |c| c.parent), + }; + Some((Renderer { depth: self.depth + 1, outer, opacity_mul: opacity, collapse3d: Some(c3), ..*self }, nctx)) + } + + pub(crate) fn opacity_mul(&self) -> f32 { + self.opacity_mul + } + + /// Draw a collapsed 2D precomp layer: nested layers blend straight into `canvas` with their + /// own modes (Normal precomp, no matte); otherwise they are drawn into an isolated buffer + /// that then takes the precomp layer's track matte, Preserve Transparency and blend mode. + fn draw_collapsed(&self, ctx: &EvalCtx<'a>, layer: &Layer, item: ItemId, canvas: &mut Image) { + let opacity = ctx.opacity(layer) as f32 * self.opacity_mul; + if opacity <= 0.0 { + return; + } + 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) { + sub.draw_comp(&nctx, canvas); + } + return; + } + let Some((sub, nctx)) = self.collapse_into(ctx, layer, item, 1.0) else { return }; + let mut iso = Image::new(canvas.width, canvas.height); + sub.draw_comp(&nctx, &mut iso); + self.composite_iso(ctx, layer, iso, canvas, opacity); + } + + /// Rasterisation scale of a layer's source: the output scale, or — for Continuously + /// Rasterize text and shape layers — the output scale times the layer's on-screen scale + /// (quarter-octave steps, rounded up, so vector content is drawn at its final size and + /// stays sharp when scaled up). + pub fn raster_scale(&self, ctx: &EvalCtx, layer: &Layer) -> f64 { + let s = self.opts.scale; + if !layer.switches.collapse || !matches!(layer.source, LayerSource::Text | LayerSource::Shape) { + return s; + } + let (l2c, _) = ctx.layer_to_comp(layer); + let m = self.outer.map_or(l2c, |o| o * l2c); + let a = layer.transform().map(|tr| ctx.v3(layer, tr, "anchor", [0.0; 3])).unwrap_or([0.0; 3]); + let p0 = m.apply(vec2(a[0], a[1])); + let px = m.apply(vec2(a[0] + 1.0, a[1])); + let py = m.apply(vec2(a[0], a[1] + 1.0)); + let k = ((px.x - p0.x).hypot(px.y - p0.y)).max((py.x - p0.x).hypot(py.y - p0.y)); + if !k.is_finite() || k <= 1e-6 { + return s; + } + let mut k = 2f64.powf((k.log2() * 4.0).ceil() / 4.0).clamp(1.0 / 16.0, 64.0); + // Keep the buffer within about 16 million pixels. + if let Some(b) = content_bounds(ctx, layer) { + let area = ((b[2] - b[0]) * (b[3] - b[1])).max(1.0) * s * s; + k = k.min((16.0e6 / area).sqrt().max(1.0)); + } + s * k + } + + /// Draw a Wireframe-quality layer: its bounds as a one-pixel outline. + fn draw_wireframe(&self, ctx: &EvalCtx, layer: &Layer, canvas: &mut Image) { + let Some(b) = content_bounds(ctx, layer) else { return }; + let s = self.opts.scale; + let (l2c, _) = ctx.layer_to_comp(layer); + let m = Mat3::scale(vec2(s, s)) * self.outer.map_or(l2c, |o| o * l2c); + let c = [(b[0], b[1]), (b[2], b[1]), (b[2], b[3]), (b[0], b[3])].map(|(x, y)| m.apply(vec2(x, y))); + let (w, h) = (canvas.width as i64, canvas.height as i64); + for i in 0..4 { + let (p, q) = (c[i], c[(i + 1) % 4]); + let n = ((q.x - p.x).abs().max((q.y - p.y).abs()) * 2.0).ceil().clamp(1.0, 1.0e5) as usize; + for j in 0..=n { + let t = j as f64 / n as f64; + let (x, y) = ((p.x + (q.x - p.x) * t).floor() as i64, (p.y + (q.y - p.y) * t).floor() as i64); + if x >= 0 && y >= 0 && x < w && y < h { + canvas.set(x as u32, y as u32, [1.0, 1.0, 1.0, 1.0]); + } + } + } } fn ctx(&self, comp_id: ItemId, comp: &'a Comp, t: Tick) -> EvalCtx<'a> { @@ -189,11 +345,49 @@ impl<'a> Renderer<'a> { let s = self.opts.scale; let w = ((comp.width as f64 * s).round() as u32).max(1); let h = ((comp.height as f64 * s).round() as u32).max(1); + // Region of interest on a comp with 3D layers: render the frame and crop it. + if self.depth == 0 + && let Some(r) = self.opts.roi + && comp.has_3d() + { + let full = Renderer { opts: RenderOpts { roi: None, ..self.opts }, ..*self }.comp_frame(comp_id, t); + let (x0, y0) = ((r[0] * s).round().max(0.0) as u32, (r[1] * s).round().max(0.0) as u32); + let (rw, rh) = (((r[2] * s).round() as u32).max(1), ((r[3] * s).round() as u32).max(1)); + let mut out = Image::new(rw, rh); + for y in 0..rh.min(full.height.saturating_sub(y0)) { + for x in 0..rw.min(full.width.saturating_sub(x0)) { + out.data[(y * rw + x) as usize] = full.data[((y + y0) * full.width + x + x0) as usize]; + } + } + return out; + } + let (w, h) = match self.roi_offset() { + Some((_, _, rw, rh)) => (rw, rh), + None => (w, h), + }; let mut canvas = Image::new(w, h); if self.depth > 16 { return canvas; } let ctx = self.ctx(comp_id, comp, t); + self.draw_comp(&ctx, &mut canvas); + if let Some(c) = self.pipe.from_blend() { + color::convert(&mut canvas, &c); + } + self.pipe.quantize(&mut canvas); + // The top-level comp leaves the working space for the (sRGB) display / output. + if self.depth == 0 + && let Some(c) = self.pipe.output() + { + color::convert(&mut canvas, &c); + self.pipe.quantize(&mut canvas); + } + canvas + } + + /// Draw a comp's layers (bottom to top) into `canvas` (blending space). + fn draw_comp(&self, ctx: &EvalCtx<'a>, canvas: &mut Image) { + let (comp, t) = (ctx.comp, ctx.time); let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.source.is_av() && l.is_active_at(t)); // Bottom-to-top, with runs of consecutive 3D layers depth-sorted (farthest first). let visible: Vec<&Layer> = comp @@ -209,17 +403,18 @@ impl<'a> Renderer<'a> { while j < visible.len() && visible[j].is_3d() { j += 1; } - three_d::compose::draw_run(self, &ctx, &visible[i..j], &mut canvas); + three_d::compose::draw_run(self, ctx, &visible[i..j], canvas); i = j; } else { - self.draw_layer(&ctx, visible[i], &mut canvas); + match self.collapsed(ctx, visible[i]) { + Some(item) => self.draw_collapsed(ctx, visible[i], item, canvas), + None => self.draw_layer(ctx, visible[i], canvas), + } i += 1; } + // 8/16 bpc: the comp is an integer buffer after every layer. + self.pipe.quantize(canvas); } - if !self.project.settings.bit_depth.is_float() { - canvas.clamp01(); - } - canvas } /// Evaluate an effect instance's parameters. @@ -271,6 +466,8 @@ impl<'a> Renderer<'a> { let ectx = EffectCtx { params: ¶ms, time: lt.seconds(), layer_size, seed: g.uid as u32, adjustment, env }; let t0 = web_time::Instant::now(); buf = effectcraft_effects::apply(spec, &ectx, buf); + // 8/16 bpc effects write integer pixels. + self.pipe.quantize(&mut buf.img); if let Some(v) = timing.as_deref_mut() { v.push((spec.id.to_string(), t0.elapsed().as_secs_f64() * 1e3)); } @@ -289,15 +486,30 @@ impl<'a> Renderer<'a> { if layer.switches.adjustment { return Some(Buf { img: Image::filled(w, h, [1.0; 4]), offset: [0.0; 2], scale: s }); } - let c = sol.color; + let mut c = sol.color; + if let Some(conv) = self.pipe.authored_in() { + c = conv.apply(c); + } Some(Buf { img: Image::filled(w, h, [c[0], c[1], c[2], 1.0]), offset: [0.0; 2], scale: s }) } LayerSource::Comp { item } => { if self.project.comp_contains(*item, ctx.comp_id) { return None; } - let sub = Renderer { depth: self.depth + 1, ..*self }; + let sub = self.nested(); let lt = ctx.source_time(layer); + // Frame blending between the nested comp's frames (time-stretched/remapped). + let mode = frame_blend_mode(ctx, layer); + if mode != FrameBlend::Off + && let Some(nc) = self.project.comp(*item) + { + let (i, w) = frame_position(lt, nc.frame_rate); + if w > 1e-6 { + let a = sub.comp_frame(*item, nc.frame_rate.tick_of(i)); + let b = sub.comp_frame(*item, nc.frame_rate.tick_of(i + 1)); + return Some(Buf { img: blend_frames(mode, &a, &b, w as f32), offset: [0.0; 2], scale: s }); + } + } Some(Buf { img: sub.comp_frame(*item, lt), offset: [0.0; 2], scale: s }) } LayerSource::Footage { item } => { @@ -307,40 +519,123 @@ impl<'a> Renderer<'a> { return None; } let lt = ctx.source_time(layer); - let img = self.footage.frame(*item, f, lt)?; + let img = self.footage_frame(ctx, layer, *item, f, lt)?; let k = if img.width > 0 { f.width.max(1) as f64 / img.width as f64 } else { 1.0 }; // Keep native pixels when downsampling is small; resample otherwise. - if s < 0.75 { + let mut buf = if s < 0.75 { let w = ((f.width as f64 * s).round() as u32).max(1); let h = ((f.height as f64 * s).round() as u32).max(1); - return Some(Buf { img: effectcraft_raster::resample(&img, w, h), offset: [0.0; 2], scale: s }); + Buf { img: effectcraft_raster::resample(&img, w, h), offset: [0.0; 2], scale: s } + } else { + Buf { img: (*img).clone(), offset: [0.0; 2], scale: 1.0 / k } + }; + if let Some(c) = self.pipe.media_in(f.color_profile) { + color::convert(&mut buf.img, &c); } - Some(Buf { img: (*img).clone(), offset: [0.0; 2], scale: 1.0 / k }) + Some(buf) } - LayerSource::Text => Some(text::render(ctx, layer, s)), - LayerSource::Shape => layer.props.sub("contents").map(|c| shapes::render(ctx, layer, c, s)), + LayerSource::Text => Some(self.authored(text::render(ctx, layer, self.raster_scale(ctx, layer)))), + LayerSource::Shape => layer.props.sub("contents").map(|c| self.authored(shapes::render(ctx, layer, c, self.raster_scale(ctx, layer)))), _ => None, } } + /// Authored colours into the working space (linear working spaces). + fn authored(&self, mut buf: Buf) -> Buf { + if let Some(c) = self.pipe.authored_in() { + color::convert(&mut buf.img, &c); + } + buf + } + + /// A footage frame at source time `t`, frame-blended between the two nearest source frames + /// when the layer's Frame Blending switch (and the comp's Enable Frame Blending) is on and + /// `t` falls between frames (footage rate ≠ comp rate, time stretch, time remapping). + fn footage_frame(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, f: &Footage, t: Tick) -> Option> { + let mode = frame_blend_mode(ctx, layer); + if mode != FrameBlend::Off && matches!(f.kind, FootageKind::Video | FootageKind::Sequence) { + let (i, w) = frame_position(t, f.frame_rate); + if w > 1e-6 { + // Blended frames are cached (Pixel Motion is expensive). + let key = self.cache.map(|_| { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + (0x00f8_a3e5_u64, item, i, (w as f32).to_bits(), mode as u8, f.frame_rate.num, f.frame_rate.den, &f.path).hash(&mut h); + h.finish() + }); + if let (Some(c), Some(k)) = (self.cache, key) + && let Some(b) = c.get(k) + { + return Some(Arc::new(b.img.clone())); + } + let a = self.footage.frame(item, f, f.frame_rate.tick_of(i)); + let b = self.footage.frame(item, f, f.frame_rate.tick_of(i + 1)); + if let (Some(a), Some(b)) = (a, b) { + let img = blend_frames(mode, &a, &b, w as f32); + if let (Some(c), Some(k)) = (self.cache, key) { + c.insert(k, Arc::new(Buf { img: img.clone(), offset: [0.0; 2], scale: 1.0 })); + } + return Some(Arc::new(img)); + } + } + } + self.footage.frame(item, f, t) + } + + /// Source → masks, clamped/quantised to the project depth. + fn masked_source(&self, ctx: &EvalCtx, layer: &Layer) -> Option { + let mut buf = self.source(ctx, layer)?; + masks::apply(ctx, layer, &mut buf); + self.pipe.quantize(&mut buf.img); + Some(buf) + } + /// Fully processed layer buffer (source → masks → effects → layer styles, flattened). /// Served from the layer cache when the layer's content key matches. pub fn layer_buf(&self, ctx: &EvalCtx, layer: &Layer) -> Option> { + self.layer_buf_keyed(ctx, layer).map(|(b, _)| b) + } + + /// [`Self::layer_buf`] in the blending space (linear with Blend Colors Using 1.0 Gamma). + pub(crate) fn blend_layer_buf(&self, ctx: &EvalCtx, layer: &Layer) -> Option> { + let (b, k) = self.layer_buf_keyed(ctx, layer)?; + Some(self.to_blend(b, k)) + } + + /// A buffer converted to the blending space (cached under a key derived from `key`). + pub(crate) fn to_blend(&self, b: Arc, key: Option) -> Arc { + let Some(c) = self.pipe.to_blend() else { return b }; + let key = key.map(|k| cache::derive(k, 0x1ea7)); + if let (Some(cache), Some(k)) = (self.cache, key) + && let Some(x) = cache.get(k) + { + return x; + } + let mut nb = (*b).clone(); + color::convert(&mut nb.img, &c); + let nb = Arc::new(nb); + if let (Some(cache), Some(k)) = (self.cache, key) { + cache.insert(k, nb.clone()); + } + nb + } + + fn layer_buf_keyed(&self, ctx: &EvalCtx, layer: &Layer) -> Option<(Arc, Option)> { let st = self.styled(ctx, layer, None)?; if st.passes.is_empty() { - return Some(st.body); + return Some((st.body, st.key)); } let key = st.key.map(|k| cache::derive(k, 0xf1a7)); if let (Some(c), Some(k)) = (self.cache, key) && let Some(b) = c.get(k) { - return Some(b); + return Some((b, key)); } let flat = Arc::new(styles::Styled { passes: st.passes.iter().map(|(b, m)| ((**b).clone(), *m)).collect(), body: (*st.body).clone() }.flatten()); if let (Some(c), Some(k)) = (self.cache, key) { c.insert(k, flat.clone()); } - Some(flat) + Some((flat, key)) } /// A layer's source pixels at the context time, before masks, effects and the transform @@ -356,7 +651,7 @@ impl<'a> Renderer<'a> { } fn content_buf_timed(&self, ctx: &EvalCtx, layer: &Layer, mut timing: Option<&mut LayerTiming>) -> Option<(Arc, Option)> { - let key = self.cache.and_then(|_| cache::layer_key(ctx, layer, self.opts.scale, self.opts.draft)); + let key = self.cache.and_then(|_| cache::layer_key(ctx, layer, self.raster_scale(ctx, layer), self.opts.draft)); if let (Some(c), Some(k)) = (self.cache, key) && let Some(b) = c.get(k) { @@ -365,8 +660,7 @@ impl<'a> Renderer<'a> { } return Some((b, key)); } - let mut buf = self.source(ctx, layer)?; - masks::apply(ctx, layer, &mut buf); + let buf = self.masked_source(ctx, layer)?; let buf = Arc::new(self.apply_effects_timed(ctx, layer, buf, false, usize::MAX, timing.map(|t| &mut t.effects))); if let (Some(c), Some(k)) = (self.cache, key) { c.insert(k, buf.clone()); @@ -378,14 +672,13 @@ impl<'a> Renderer<'a> { /// (what Time effects read at neighbouring times). Served from / stored in the layer cache /// under its own key, so scrubbing reuses frames rendered for earlier output frames. pub fn layer_input(&self, ctx: &EvalCtx, layer: &Layer, effects: usize) -> Option> { - let key = self.cache.and_then(|_| cache::input_key(ctx, layer, self.opts.scale, self.opts.draft, effects)); + let key = self.cache.and_then(|_| cache::input_key(ctx, layer, self.raster_scale(ctx, layer), self.opts.draft, effects)); if let (Some(c), Some(k)) = (self.cache, key) && let Some(b) = c.get(k) { return Some(b); } - let mut buf = self.source(ctx, layer)?; - masks::apply(ctx, layer, &mut buf); + let mut buf = self.masked_source(ctx, layer)?; if effects > 0 { buf = self.apply_effects_timed(ctx, layer, buf, false, effects, None); } @@ -400,7 +693,7 @@ impl<'a> Renderer<'a> { fn styled(&self, ctx: &EvalCtx, layer: &Layer, mut timing: Option<&mut LayerTiming>) -> Option { let (content, ckey) = self.content_buf_timed(ctx, layer, timing.as_deref_mut())?; if !styles::active(ctx, layer) { - return Some(StyledLayer { passes: vec![], body: content.clone(), content, key: None, plain: true }); + return Some(StyledLayer { passes: vec![], body: content.clone(), content, key: ckey, ckey, plain: true }); } let modes = styles::pass_modes(ctx, layer); let key = ckey.map(|k| cache::styles_key(ctx, layer, k)); @@ -409,7 +702,7 @@ impl<'a> Renderer<'a> { { let passes: Option> = modes.iter().enumerate().map(|(i, m)| c.get(cache::derive(k, i as u64 + 1)).map(|b| (b, *m))).collect(); if let Some(passes) = passes { - return Some(StyledLayer { passes, body, content, key, plain: false }); + return Some(StyledLayer { passes, body, content, key, ckey, plain: false }); } } let t0 = web_time::Instant::now(); @@ -428,11 +721,25 @@ impl<'a> Renderer<'a> { c.insert(cache::derive(k, i as u64 + 1), b.clone()); } } - Some(StyledLayer { passes, body, content, key, plain: false }) + Some(StyledLayer { passes, body, content, key, ckey, plain: false }) + } + + /// The styled layer's buffers in the blending space. + fn styled_to_blend(&self, st: StyledLayer) -> StyledLayer { + if self.pipe.to_blend().is_none() { + return st; + } + let body = self.to_blend(st.body, st.key); + let content = if st.plain { body.clone() } else { self.to_blend(st.content, st.ckey) }; + let passes = st.passes.into_iter().enumerate().map(|(i, (b, m))| (self.to_blend(b, st.key.map(|k| cache::derive(k, i as u64 + 1))), m)).collect(); + StyledLayer { passes, body, content, ..st } } fn sampling(&self, layer: &Layer) -> effectcraft_raster::Sampling { - if self.opts.draft || layer.switches.quality == Quality::Draft { + if layer.switches.quality == Quality::Draft { + // Draft quality: no interpolation (nearest neighbour), as After Effects' Draft. + effectcraft_raster::Sampling::Nearest + } else if self.opts.draft { effectcraft_raster::Sampling::Bilinear } else if layer.switches.sampling == Sampling::Bicubic { effectcraft_raster::Sampling::Bicubic @@ -441,11 +748,35 @@ impl<'a> Renderer<'a> { } } + /// The region of interest of a top-level 2D frame: (x, y) offset of the output in output + /// pixels and its size. `None` renders the whole comp (no ROI, or a nested comp). + fn roi_offset(&self) -> Option<(f64, f64, u32, u32)> { + let r = self.opts.roi.filter(|_| self.depth == 0)?; + let s = self.opts.scale; + if r[2] <= 0.0 || r[3] <= 0.0 { + return None; + } + Some(((r[0] * s).round(), (r[1] * s).round(), ((r[2] * s).round() as u32).max(1), ((r[3] * s).round() as u32).max(1))) + } + + /// Scaled comp pixel → output pixel: the region of interest's offset. + fn out_matrix(&self) -> Mat3 { + match self.roi_offset() { + Some((x, y, _, _)) => Mat3::translate(vec2(-x, -y)), + None => Mat3::IDENTITY, + } + } + /// Buffer pixel → output pixel matrix for the layer at the context time. fn buf_matrix(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf) -> Mat3 { let s = self.opts.scale; let (l2c, _) = ctx.layer_to_comp(layer); - Mat3::scale(vec2(s, s)) * l2c * Mat3::scale(vec2(1.0 / buf.scale, 1.0 / buf.scale)) * Mat3::translate(vec2(-buf.offset[0], -buf.offset[1])) + let l2c = self.outer.map_or(l2c, |o| o * l2c); + self.out_matrix() + * Mat3::scale(vec2(s, s)) + * l2c + * Mat3::scale(vec2(1.0 / buf.scale, 1.0 / buf.scale)) + * Mat3::translate(vec2(-buf.offset[0], -buf.offset[1])) } /// Draw a processed layer into `target` (transform, motion blur) with `mode`/`opacity`. @@ -477,7 +808,11 @@ impl<'a> Renderer<'a> { } fn draw_layer(&self, ctx: &EvalCtx, layer: &Layer, canvas: &mut Image) { - let opacity = ctx.opacity(layer) as f32; + let opacity = ctx.opacity(layer) as f32 * self.opacity_mul; + if layer.switches.quality == Quality::Wireframe && !layer.switches.adjustment { + self.draw_wireframe(ctx, layer, canvas); + return; + } if opacity <= 0.0 && !layer.switches.adjustment { return; } @@ -488,6 +823,7 @@ impl<'a> Renderer<'a> { let mut timing = self.profile.map(|_| LayerTiming { depth: self.depth, layer: layer.name.clone(), ..Default::default() }); let t0 = web_time::Instant::now(); let Some(st) = self.styled(ctx, layer, timing.as_mut()) else { return }; + let st = self.styled_to_blend(st); let t1 = web_time::Instant::now(); if st.plain { self.composite_layer(ctx, layer, &st.body, canvas, opacity); @@ -595,8 +931,16 @@ impl<'a> Renderer<'a> { masks::apply(ctx, layer, &mut foot); let mut matte = Image::new(canvas.width, canvas.height); self.place(ctx, layer, &foot, &mut matte, BlendMode::Normal, 1.0); - let below = Buf { img: canvas.clone(), offset: [0.0; 2], scale: self.opts.scale }; - let adjusted = self.apply_effects(ctx, layer, below, true); + let o = self.roi_offset().map(|(x, y, _, _)| [-x, -y]).unwrap_or([0.0; 2]); + let mut below = Buf { img: canvas.clone(), offset: o, scale: self.opts.scale }; + // Effects run in the working space, not the linear blending space. + if let Some(c) = self.pipe.from_blend() { + color::convert(&mut below.img, &c); + } + let mut adjusted = self.apply_effects(ctx, layer, below, true); + if let Some(c) = self.pipe.to_blend() { + color::convert(&mut adjusted.img, &c); + } if adjusted.img.width != canvas.width || adjusted.img.height != canvas.height { return; } @@ -615,12 +959,37 @@ struct StyledLayer { body: Arc, /// Pre-style pixels (knockout shape). content: Arc, - /// Styled cache key. + /// Styled cache key (the content key when plain). key: Option, + /// Content cache key. + ckey: Option, /// No layer styles: composite `body` the usual way. plain: bool, } +/// The layer's frame blending mode, if the comp's Enable Frame Blending switch is on. +fn frame_blend_mode(ctx: &EvalCtx, layer: &Layer) -> FrameBlend { + if ctx.comp.enable_frame_blending { layer.switches.frame_blend } else { FrameBlend::Off } +} + +/// Position of source time `t` among frames at `rate`: the frame at or before `t` and the +/// fraction (0..1) of the way to the next frame. Exact (integer tick arithmetic), so frames that +/// line up exactly report a fraction of 0. +pub fn frame_position(t: Tick, rate: FrameRate) -> (i64, f64) { + let num = t.0 as i128 * rate.num as i128; + let den = TICKS_PER_SECOND as i128 * rate.den as i128; + (num.div_euclid(den) as i64, num.rem_euclid(den) as f64 / den as f64) +} + +/// Blend two neighbouring frames `w` of the way from `a` to `b`: a cross-fade (Frame Mix) or a +/// motion-compensated interpolation (Pixel Motion). +pub fn blend_frames(mode: FrameBlend, a: &Image, b: &Image, w: f32) -> Image { + match mode { + FrameBlend::PixelMotion => effectcraft_raster::flow::interpolate(a, b, w), + _ => effectcraft_raster::flow::mix(a, b, w), + } +} + /// Convenience: render one frame of a comp with no footage source. pub fn render_frame(project: &Project, comp: ItemId, t: Tick, scale: f64) -> Image { Renderer::new(project, &NoFootage, RenderOpts { scale, ..Default::default() }).comp_frame(comp, t) @@ -631,6 +1000,8 @@ mod tests; #[cfg(test)] mod tests_remap; #[cfg(test)] +mod tests_roi; +#[cfg(test)] mod tests_styles; /// Layer-space bounds `[x0, y0, x1, y1]` of a layer's content at the context time (source size @@ -659,6 +1030,12 @@ pub fn kurbo_path(sp: &effectcraft_keyframe::ShapePath) -> kurbo::BezPath { #[cfg(test)] mod tests_audio_fx; #[cfg(test)] +mod tests_collapse; +#[cfg(test)] +mod tests_color; +#[cfg(test)] +mod tests_frame_blend; +#[cfg(test)] mod tests_paint; #[cfg(test)] mod tests_time; diff --git a/crates/render/src/shapes.rs b/crates/render/src/shapes.rs index dfcbe8d8..25eb6090 100644 --- a/crates/render/src/shapes.rs +++ b/crates/render/src/shapes.rs @@ -72,6 +72,28 @@ fn group_matrix(ctx: &EvalCtx, layer: &Layer, tr: &PropGroup) -> (Mat3, f32) { (m, (ctx.f(layer, tr, "opacity", 100.0) / 100.0) as f32) } +/// Shape item (a Path, Rectangle… inside nested Shape groups) → layer space: the product of the +/// enclosing groups' transforms. `None` when `uid` is not in the layer's contents. +pub fn item_matrix(ctx: &EvalCtx, layer: &Layer, uid: u64) -> Option { + fn walk(ctx: &EvalCtx, layer: &Layer, g: &PropGroup, uid: u64, m: Mat3) -> Option { + for sub in g.groups() { + if sub.uid == uid { + return Some(m); + } + if sub.match_id == "group" + && let Some(inner) = sub.sub("contents") + { + let gm = sub.sub("transform").map(|t| group_matrix(ctx, layer, t).0).unwrap_or(Mat3::IDENTITY); + if let Some(r) = walk(ctx, layer, inner, uid, m * gm) { + return Some(r); + } + } + } + None + } + walk(ctx, layer, layer.props.sub("contents")?, uid, Mat3::IDENTITY) +} + fn rule(ctx: &EvalCtx, layer: &Layer, g: &PropGroup) -> FillRule { if ctx.e(layer, g, "rule") == 1 { FillRule::EvenOdd } else { FillRule::NonZero } } diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index dc1f3cda..a5a7d8b6 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -8,6 +8,8 @@ use crate::render_frame; fn setup() -> (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(200, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); (p, cid, comp) diff --git a/crates/render/src/tests_audio_fx.rs b/crates/render/src/tests_audio_fx.rs index 173ffc88..dfd49ccc 100644 --- a/crates/render/src/tests_audio_fx.rs +++ b/crates/render/src/tests_audio_fx.rs @@ -50,6 +50,7 @@ fn audio_footage() -> Footage { codec: String::new(), missing: false, sequence: vec![], + color_profile: None, } } diff --git a/crates/render/src/tests_collapse.rs b/crates/render/src/tests_collapse.rs new file mode 100644 index 00000000..d3cf954c --- /dev/null +++ b/crates/render/src/tests_collapse.rs @@ -0,0 +1,202 @@ +//! Collapse Transformations, Continuously Rasterize and the Quality switch. + +use effectcraft_color::{BlendMode, Label}; +use effectcraft_keyframe::Value; +use effectcraft_project::build::{self, Ids}; +use effectcraft_project::{BitDepth, Comp, ItemId, ItemKind, Layer, LayerSource, Project, Quality, Solid}; +use effectcraft_time::{FrameRate, Tick}; + +use crate::{Image, render_frame}; + +fn project() -> Project { + let mut p = Project::default(); + p.settings.bit_depth = BitDepth::Bpc32; + p +} + +fn solid(p: &mut Project, comp: &Comp, color: [f32; 3], size: (u32, u32), pos: [f64; 3]) -> Layer { + let sid = p.add_item("S", Label::Red, None, ItemKind::Solid(Solid { color, width: size.0, height: size.1, pixel_aspect: 1.0 })); + let mut l = build::layer(p, comp, "S", LayerSource::Solid { item: sid }, size, None); + l.props.prop_mut("transform/position").unwrap().value = Value::Vec3(pos); + l +} + +fn set(l: &mut Layer, path: &str, v: Value) { + l.props.prop_mut(path).unwrap().value = v; +} + +fn max_diff(a: &Image, b: &Image) -> f32 { + a.data.iter().zip(&b.data).flat_map(|(p, q)| (0..4).map(move |c| (p[c] - q[c]).abs())).fold(0.0, f32::max) +} + +/// Outer 200×100 comp: a grey background, and either a precomp (scaled 150%, rotated) of two +/// solids — the top one in Add mode — or the same two solids parented to a null that carries the +/// precomp layer's transform (the manual flattening). +fn scene(collapse: bool, flatten: bool) -> (Project, ItemId) { + let mut p = project(); + let outer = Comp::new(200, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let inner = Comp::new(100, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let mut a = solid(&mut p, &inner, [0.6, 0.1, 0.1], (40, 40), [40.0, 50.0, 0.0]); + let mut b = solid(&mut p, &inner, [0.1, 0.3, 0.6], (40, 40), [60.0, 50.0, 0.0]); + b.blend_mode = BlendMode::Add; + let bg = solid(&mut p, &outer, [0.3, 0.3, 0.3], (200, 100), [100.0, 50.0, 0.0]); + let xf = |l: &mut Layer| { + set(l, "transform/anchor", Value::Vec3([50.0, 50.0, 0.0])); + set(l, "transform/position", Value::Vec3([100.0, 50.0, 0.0])); + set(l, "transform/scale", Value::Vec3([150.0, 150.0, 100.0])); + set(l, "transform/rotation", Value::Scalar(20.0)); + }; + let cid; + if flatten { + let mut null = build::layer(&mut p, &outer, "Null", LayerSource::Null, (100, 100), None); + xf(&mut null); + a.parent = Some(null.id); + b.parent = Some(null.id); + let mut c = outer.clone(); + c.layers = vec![null, b, a, bg]; + cid = p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(c.into())); + } else { + let mut inner = inner; + inner.layers = vec![b, a]; + 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]; + cid = p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(c.into())); + } + (p, cid) +} + +#[test] +fn collapsed_precomp_equals_manual_flattening() { + let (p, cid) = scene(false, true); + let flat = render_frame(&p, cid, Tick::ZERO, 1.0); + let (p, cid) = scene(true, false); + let collapsed = render_frame(&p, cid, Tick::ZERO, 1.0); + assert!(max_diff(&flat, &collapsed) < 1e-4, "collapsed differs from flattening by {}", max_diff(&flat, &collapsed)); + // Without collapsing, Add only sees the precomp's own layers (not the grey below), and the + // precomp is resampled twice. + let (p, cid) = scene(false, false); + let nested = render_frame(&p, cid, Tick::ZERO, 1.0); + assert!(max_diff(&flat, &nested) > 0.2); + // Opacity of the collapsed layer fades every nested layer. + let (mut p, cid) = scene(true, false); + set(&mut p.comp_mut(cid).unwrap().layers[0], "transform/opacity", Value::Scalar(50.0)); + let half = render_frame(&p, cid, Tick::ZERO, 1.0); + let (x, y) = (100, 50); + let (bg, full) = (0.3, collapsed.get(x, y)[0]); + assert!((half.get(x, y)[0] - (bg + (full - bg) * 0.5)).abs() < 0.03, "{:?} vs {full}", half.get(x, y)); +} + +#[test] +fn masks_on_a_collapsed_layer_force_a_flattened_render() { + let (mut p, cid) = scene(true, false); + let (p2, cid2) = scene(false, false); + // Add a mask covering everything: the layer renders like an uncollapsed precomp. + let mut next = p.next_id; + let mask = build::mask( + &mut Ids(&mut next), + "Mask 1", + effectcraft_keyframe::ShapePath::rect([50.0, 50.0], 4000.0, 4000.0), + effectcraft_project::MaskMode::Add, + [255, 255, 0], + ); + p.next_id = next; + p.comp_mut(cid).unwrap().layers[0].props.sub_mut("masks").unwrap().children.push(mask.into()); + let a = render_frame(&p, cid, Tick::ZERO, 1.0); + let b = render_frame(&p2, cid2, Tick::ZERO, 1.0); + assert!(max_diff(&a, &b) < 1e-4, "{}", max_diff(&a, &b)); +} + +#[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 + // blue 3D solid at z = −200 (nearer the camera). Collapsed, depth sorting puts blue in front; + // uncollapsed, the precomp is one plane at z = 0 drawn below the red. + let run = |collapse: bool| { + let mut p = project(); + let outer = Comp::new(200, 200, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let inner = Comp::new(200, 200, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + 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]; + 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; + pre.switches.collapse = collapse; + 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]; + let cid = p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(c.into())); + render_frame(&p, cid, Tick::ZERO, 1.0).get(100, 100) + }; + let c = run(true); + assert!(c[2] > 0.99 && c[0] < 0.01, "collapsed: blue in front {c:?}"); + let n = run(false); + assert!(n[0] > 0.99 && n[2] < 0.01, "uncollapsed: red on top {n:?}"); +} + +/// Pixels along row `y` from `x0` to `x1` with partial coverage (an edge's width). +fn edge_width(img: &Image, y: i64, x0: i64, x1: i64) -> usize { + (x0..x1).filter(|&x| (0.02..0.98).contains(&img.get(x, y)[3])).count() +} + +#[test] +fn continuously_rasterized_shape_stays_sharp_when_scaled_up() { + let run = |cr: bool| { + let mut p = project(); + let comp = Comp::new(200, 200, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let mut l = build::layer(&mut p, &comp, "Shape", LayerSource::Shape, (200, 200), None); + let mut next = p.next_id; + let mut ids = Ids(&mut next); + let rect = build::shape_rect(&mut ids, [10.0, 10.0], [0.0, 0.0], 0.0); + let fill = build::shape_fill(&mut ids, [1.0, 1.0, 1.0, 1.0]); + let g = build::shape_group(&mut ids, "Rectangle 1", vec![rect, fill]); + p.next_id = next; + l.props.sub_mut("contents").unwrap().children.push(g.into()); + set(&mut l, "transform/anchor", Value::Vec3([0.0, 0.0, 0.0])); + set(&mut l, "transform/position", Value::Vec3([100.3, 100.3, 0.0])); + set(&mut l, "transform/scale", Value::Vec3([800.0, 800.0, 100.0])); + l.switches.collapse = cr; + let mut c = comp; + c.layers = vec![l]; + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into())); + render_frame(&p, cid, Tick::ZERO, 1.0) + }; + // The 10 px square scaled 800% spans 60.3..140.3. + let soft = run(false); + let sharp = run(true); + let ws = edge_width(&soft, 100, 50, 75); + let wc = edge_width(&sharp, 100, 50, 75); + assert!(ws >= 4, "bitmap scaling blurs the edge: {ws}"); + assert!(wc <= 1, "continuously rasterized edge: {wc}"); + assert!(sharp.get(100, 100)[3] > 0.99 && sharp.get(55, 100)[3] < 0.01); +} + +#[test] +fn wireframe_quality_draws_the_layer_bounds() { + let mut p = project(); + let comp = Comp::new(100, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + 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]; + 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"); + assert_eq!(img.get(30, 50), [1.0, 1.0, 1.0, 1.0], "outline"); + // Draft quality samples nearest-neighbour: no partial pixels at a half-pixel offset. + let mut p = project(); + let comp = Comp::new(100, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + 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]; + 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)); +} diff --git a/crates/render/src/tests_color.rs b/crates/render/src/tests_color.rs new file mode 100644 index 00000000..87f9dd1a --- /dev/null +++ b/crates/render/src/tests_color.rs @@ -0,0 +1,227 @@ +//! Bit depth, working space and linear blending. + +use std::sync::Arc; + +use effectcraft_color::{BlendMode, ColorSpace, Label}; +use effectcraft_project::build; +use effectcraft_project::{AlphaMode, BitDepth, Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid}; +use effectcraft_time::{FrameRate, Tick}; + +use crate::{FootageSource, Image, LayerCache, RenderOpts, Renderer, render_frame}; + +fn setup(depth: BitDepth) -> (Project, ItemId, Comp) { + let mut p = Project::default(); + p.settings.bit_depth = depth; + let comp = Comp::new(40, 20, 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) +} + +fn add_solid(p: &mut Project, cid: ItemId, color: [f32; 3], mode: BlendMode, opacity: f64) { + let comp = p.comp(cid).unwrap().clone(); + let sid = p.add_item("S", Label::Red, None, ItemKind::Solid(Solid { color, width: 40, height: 20, pixel_aspect: 1.0 })); + let mut l = build::layer(p, &comp, "S", LayerSource::Solid { item: sid }, (40, 20), None); + l.blend_mode = mode; + l.props.prop_mut("transform/opacity").unwrap().value = effectcraft_keyframe::Value::Scalar(opacity); + // New layers go on top. + p.comp_mut(cid).unwrap().layers.insert(0, l); +} + +fn px(p: &Project, cid: ItemId) -> [f32; 4] { + render_frame(p, cid, Tick::ZERO, 1.0).get(10, 10) +} + +#[test] +fn add_clamps_in_8_and_16_but_not_32_bpc() { + for (depth, want) in [(BitDepth::Bpc8, 1.0), (BitDepth::Bpc16, 1.0), (BitDepth::Bpc32, 1.6)] { + let (mut p, cid, _) = setup(depth); + add_solid(&mut p, cid, [0.8, 0.8, 0.8], BlendMode::Normal, 100.0); + add_solid(&mut p, cid, [0.8, 0.8, 0.8], BlendMode::Add, 100.0); + let v = px(&p, cid)[0]; + assert!((v - want).abs() < 1e-5, "{depth:?}: {v}"); + } + // Screen of over-range values survives in 32 bpc only: a 1.6 base screened with 0.5. + let (mut p, cid, _) = setup(BitDepth::Bpc32); + add_solid(&mut p, cid, [0.8, 0.8, 0.8], BlendMode::Normal, 100.0); + add_solid(&mut p, cid, [0.8, 0.8, 0.8], BlendMode::Add, 100.0); + add_solid(&mut p, cid, [0.5, 0.5, 0.5], BlendMode::Screen, 100.0); + let v = px(&p, cid)[0]; + assert!((v - (1.6 + 0.5 - 0.8)).abs() < 1e-5, "{v}"); +} + +#[test] +fn quantization_steps_per_depth() { + let want = |depth| match depth { + BitDepth::Bpc8 => (0.3f32 * 255.0).round() / 255.0, + BitDepth::Bpc16 => (0.3f32 * 32768.0).round() / 32768.0, + BitDepth::Bpc32 => 0.3, + }; + for depth in [BitDepth::Bpc8, BitDepth::Bpc16, BitDepth::Bpc32] { + let (mut p, cid, _) = setup(depth); + add_solid(&mut p, cid, [0.3, 0.3, 0.3], BlendMode::Normal, 100.0); + let v = px(&p, cid)[0]; + assert!((v - want(depth)).abs() < 1e-7, "{depth:?}: {v} vs {}", want(depth)); + } + // 8 bpc: 77/255 = 0.30196 (one step is 1/255); 16 bpc is within 1/65536. + let (mut p8, c8, _) = setup(BitDepth::Bpc8); + add_solid(&mut p8, c8, [0.3, 0.3, 0.3], BlendMode::Normal, 100.0); + assert!((px(&p8, c8)[0] - 77.0 / 255.0).abs() < 1e-7); + // A 1% opacity layer of white over black lands on a whole 8-bit step. + let (mut p, cid, _) = setup(BitDepth::Bpc8); + add_solid(&mut p, cid, [0.0, 0.0, 0.0], BlendMode::Normal, 100.0); + add_solid(&mut p, cid, [1.0, 1.0, 1.0], BlendMode::Normal, 1.0); + let v = px(&p, cid)[0] * 255.0; + assert!((v - v.round()).abs() < 1e-4 && (v - 2.55).abs() < 1.0, "{v}"); +} + +#[test] +fn linear_blending_of_red_over_green() { + // 50% red over green: gamma blending gives (0.5, 0.5, 0); 1.0-gamma blending mixes linear + // light, so each channel is the sRGB encoding of 0.5 linear ≈ 0.735. + let run = |linear: bool| { + let (mut p, cid, _) = setup(BitDepth::Bpc32); + p.settings.blend_linear = linear; + add_solid(&mut p, cid, [0.0, 1.0, 0.0], BlendMode::Normal, 100.0); + add_solid(&mut p, cid, [1.0, 0.0, 0.0], BlendMode::Normal, 50.0); + px(&p, cid) + }; + let g = run(false); + assert!((g[0] - 0.5).abs() < 1e-5 && (g[1] - 0.5).abs() < 1e-5 && g[2].abs() < 1e-6, "{g:?}"); + let l = run(true); + let e = effectcraft_color::linear_to_srgb(0.5); + assert!((l[0] - e).abs() < 1e-4 && (l[1] - e).abs() < 1e-4 && l[2].abs() < 1e-6, "{l:?}"); + assert!((e - 0.7354).abs() < 1e-3); + // Unblended (opaque) layers are unchanged by linear blending. + let (mut p, cid, _) = setup(BitDepth::Bpc32); + p.settings.blend_linear = true; + add_solid(&mut p, cid, [0.25, 0.5, 0.75], BlendMode::Normal, 100.0); + let o = px(&p, cid); + assert!((o[0] - 0.25).abs() < 1e-5 && (o[1] - 0.5).abs() < 1e-5 && (o[2] - 0.75).abs() < 1e-5, "{o:?}"); +} + +#[test] +fn linearized_working_space_blends_linear_and_round_trips_colours() { + let (mut p, cid, _) = setup(BitDepth::Bpc32); + p.settings.working_space = Some(ColorSpace::Srgb); + p.settings.linearize = true; + add_solid(&mut p, cid, [0.0, 1.0, 0.0], BlendMode::Normal, 100.0); + add_solid(&mut p, cid, [1.0, 0.0, 0.0], BlendMode::Normal, 50.0); + let l = px(&p, cid); + let e = effectcraft_color::linear_to_srgb(0.5); + assert!((l[0] - e).abs() < 1e-4 && (l[1] - e).abs() < 1e-4, "{l:?}"); + // An opaque solid shows its own colour (linearised in, encoded out). + let (mut p, cid, _) = setup(BitDepth::Bpc32); + p.settings.working_space = Some(ColorSpace::Srgb); + p.settings.linearize = true; + add_solid(&mut p, cid, [0.2, 0.5, 0.9], BlendMode::Normal, 100.0); + let o = px(&p, cid); + assert!((o[0] - 0.2).abs() < 1e-4 && (o[1] - 0.5).abs() < 1e-4 && (o[2] - 0.9).abs() < 1e-4, "{o:?}"); + // Linearize needs a working space (like AE, where it is disabled without one). + let (mut p, cid, _) = setup(BitDepth::Bpc32); + p.settings.linearize = true; + add_solid(&mut p, cid, [0.0, 1.0, 0.0], BlendMode::Normal, 100.0); + add_solid(&mut p, cid, [1.0, 0.0, 0.0], BlendMode::Normal, 50.0); + assert!((px(&p, cid)[0] - 0.5).abs() < 1e-5); +} + +#[test] +fn wide_working_space_colours_leave_the_display_gamut() { + // Pure Rec. 2020 red is outside sRGB: in 32 bpc the display conversion keeps the + // over-range/negative values; 8 bpc clamps them. + let (mut p, cid, _) = setup(BitDepth::Bpc32); + p.settings.working_space = Some(ColorSpace::Rec2020); + add_solid(&mut p, cid, [1.0, 0.0, 0.0], BlendMode::Normal, 100.0); + let o = px(&p, cid); + assert!(o[0] > 1.0 && o[1] < 0.0 && o[2] < 0.0, "{o:?}"); + p.settings.bit_depth = BitDepth::Bpc8; + let o = px(&p, cid); + assert!((o[0] - 1.0).abs() < 1e-6 && o[1] == 0.0 && o[2] == 0.0, "{o:?}"); + // sRGB working space: no conversion at all. + let (mut p, cid, _) = setup(BitDepth::Bpc32); + p.settings.working_space = Some(ColorSpace::Srgb); + add_solid(&mut p, cid, [0.3, 0.6, 0.9], BlendMode::Normal, 100.0); + let o = px(&p, cid); + assert!((o[0] - 0.3).abs() < 1e-6 && (o[2] - 0.9).abs() < 1e-6); +} + +/// One flat-colour footage frame. +struct Flat([f32; 3]); +impl FootageSource for Flat { + fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option> { + let c = self.0; + Some(Arc::new(Image::filled(f.width, f.height, [c[0], c[1], c[2], 1.0]))) + } +} + +fn footage(profile: Option) -> Footage { + Footage { + path: "x.png".into(), + kind: FootageKind::Still, + width: 40, + height: 20, + pixel_aspect: 1.0, + frame_rate: FrameRate::FPS_30, + native_rate: None, + duration: Tick::ZERO, + has_video: true, + has_audio: false, + alpha: AlphaMode::Ignore, + premul_color: [0.0; 3], + loop_count: 1, + codec: String::new(), + missing: false, + sequence: vec![], + color_profile: profile, + } +} + +#[test] +fn footage_profile_is_converted_into_the_working_space() { + let render = |ws: Option, profile: Option| { + let (mut p, cid, comp) = setup(BitDepth::Bpc32); + p.settings.working_space = ws; + let fid = p.add_item("F", Label::Aqua, None, ItemKind::Footage(footage(profile))); + let l = build::layer(&mut p, &comp, "F", LayerSource::Footage { item: fid }, (40, 20), None); + p.comp_mut(cid).unwrap().layers.push(l); + let src = Flat([1.0, 0.0, 0.0]); + Renderer::new(&p, &src, RenderOpts::default()).comp_frame(cid, Tick::ZERO).get(5, 5) + }; + // Unmanaged: the file's values pass straight through. + let o = render(None, Some(ColorSpace::Rec2020)); + assert_eq!(o, [1.0, 0.0, 0.0, 1.0]); + // Managed: Rec. 2020 red shown on an sRGB display is beyond sRGB red. + let o = render(Some(ColorSpace::Srgb), Some(ColorSpace::Rec2020)); + assert!(o[0] > 1.0 && o[1] < 0.0, "{o:?}"); + // Untagged footage is sRGB: a round trip through a Rec. 2020 working space. + let o = render(Some(ColorSpace::Rec2020), None); + assert!((o[0] - 1.0).abs() < 2e-3 && o[1].abs() < 2e-3 && o[2].abs() < 2e-3, "{o:?}"); +} + +#[test] +fn layer_cache_is_keyed_by_colour_settings() { + let (mut p, cid, _) = setup(BitDepth::Bpc8); + add_solid(&mut p, cid, [0.3, 0.3, 0.3], BlendMode::Normal, 100.0); + let cache = LayerCache::default(); + let frame = |p: &Project| { + let mut r = Renderer::new(p, &crate::NoFootage, RenderOpts::default()); + r.cache = Some(&cache); + r.comp_frame(cid, Tick::ZERO).get(10, 10)[0] + }; + let a = frame(&p); + assert!((a - 77.0 / 255.0).abs() < 1e-7); + assert_eq!(cache.stats().entries, 1); + p.settings.bit_depth = BitDepth::Bpc32; + let b = frame(&p); + assert!((b - 0.3).abs() < 1e-7, "stale 8 bpc pixels: {b}"); + p.settings.working_space = Some(ColorSpace::Srgb); + p.settings.linearize = true; + let _ = frame(&p); + assert_eq!(cache.stats().entries, 3, "each setting renders its own entry"); + // Back to 8 bpc reuses the first entry. + p.settings.bit_depth = BitDepth::Bpc8; + p.settings.working_space = None; + p.settings.linearize = false; + let hits = cache.stats().hits; + assert!((frame(&p) - a).abs() < 1e-9); + assert_eq!(cache.stats().hits, hits + 1); +} diff --git a/crates/render/src/tests_frame_blend.rs b/crates/render/src/tests_frame_blend.rs new file mode 100644 index 00000000..c172b81c --- /dev/null +++ b/crates/render/src/tests_frame_blend.rs @@ -0,0 +1,168 @@ +//! Frame blending (Frame Mix and Pixel Motion) for footage and precomp layers. + +use std::sync::Arc; + +use rayon::prelude::*; + +use effectcraft_color::Label; +use effectcraft_keyframe::{Keyframe, Value}; +use effectcraft_project::build; +use effectcraft_project::{AlphaMode, BitDepth, Comp, Footage, FootageKind, FrameBlend, ItemId, ItemKind, LayerSource, Project, Solid}; +use effectcraft_time::{FrameRate, Tick}; + +use crate::{FootageSource, Image, RenderOpts, Renderer, frame_position}; + +#[test] +fn frame_mix_weights_for_24_to_30_conform() { + // Comp frame k at 30 fps sits at source frame 0.8·k of 24 fps footage. + let want = [(0, 0.0), (0, 0.8), (1, 0.6), (2, 0.4), (3, 0.2), (4, 0.0), (4, 0.8)]; + for (k, (i, w)) in want.into_iter().enumerate() { + let t = FrameRate::FPS_30.tick_of(k as i64); + let (fi, fw) = frame_position(t, FrameRate::FPS_24); + assert_eq!(fi, i, "comp frame {k}"); + assert!((fw - w).abs() < 1e-12, "comp frame {k}: {fw} vs {w}"); + } + // NTSC 29.97 footage in a 29.97 comp lines up exactly. + for k in [0, 1, 7, 1001, 30_000] { + assert_eq!(frame_position(FrameRate::FPS_29_97.tick_of(k), FrameRate::FPS_29_97), (k, 0.0)); + } +} + +/// Footage whose frame `i` is a flat grey of `i / 10` (24 fps), or a textured pattern moving +/// 6 px right per frame. +struct Frames { + moving: bool, +} + +fn pattern(w: u32, h: u32, dx: f64) -> Image { + let mut img = Image::new(w, h); + img.rows_mut().for_each(|(y, row)| { + for (x, p) in row.iter_mut().enumerate() { + let (u, v) = (x as f64 - dx, y as f64); + let s = (0.5 + 0.2 * (u * 0.21 + v * 0.07).sin() + 0.15 * (u * 0.05 - v * 0.19).cos()) as f32; + *p = [s, s * 0.8, 1.0 - s, 1.0]; + } + }); + img +} + +impl FootageSource for Frames { + fn frame(&self, _: ItemId, f: &Footage, t: Tick) -> Option> { + let i = f.frame_rate.frame_at(t); + if self.moving { + return Some(Arc::new(pattern(f.width, f.height, 6.0 * i as f64))); + } + let g = i as f32 / 10.0; + Some(Arc::new(Image::filled(f.width, f.height, [g, g, g, 1.0]))) + } +} + +fn setup(mode: FrameBlend, size: (u32, u32)) -> (Project, ItemId) { + let mut p = Project::default(); + p.settings.bit_depth = BitDepth::Bpc32; + let comp = Comp::new(size.0, size.1, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + let f = Footage { + path: "clip.mov".into(), + kind: FootageKind::Video, + width: size.0, + height: size.1, + pixel_aspect: 1.0, + frame_rate: FrameRate::FPS_24, + native_rate: None, + duration: Tick::from_seconds_f64(2.0), + has_video: true, + has_audio: false, + alpha: AlphaMode::Ignore, + premul_color: [0.0; 3], + loop_count: 1, + codec: String::new(), + missing: false, + sequence: vec![], + color_profile: None, + }; + let fid = p.add_item("clip", Label::Aqua, None, ItemKind::Footage(f)); + let mut l = build::layer(&mut p, &comp, "clip", LayerSource::Footage { item: fid }, size, None); + l.switches.frame_blend = mode; + p.comp_mut(cid).unwrap().layers.push(l); + (p, cid) +} + +fn frame(p: &Project, cid: ItemId, k: i64, moving: bool) -> Image { + let src = Frames { moving }; + Renderer::new(p, &src, RenderOpts::default()).comp_frame(cid, FrameRate::FPS_30.tick_of(k)) +} + +#[test] +fn frame_mix_blends_footage_frames() { + let (p, cid) = setup(FrameBlend::FrameMix, (16, 8)); + // Comp frame 1 → source 0.8: 0.2·frame 0 + 0.8·frame 1 = 0.08. + let v = frame(&p, cid, 1, false).get(4, 4)[0]; + assert!((v - 0.08).abs() < 1e-6, "{v}"); + let v = frame(&p, cid, 2, false).get(4, 4)[0]; + assert!((v - (0.4 * 0.1 + 0.6 * 0.2)).abs() < 1e-6, "{v}"); + // Off (layer switch or comp switch): the frame at or before. + let (p, cid) = setup(FrameBlend::Off, (16, 8)); + assert!((frame(&p, cid, 1, false).get(4, 4)[0] - 0.0).abs() < 1e-6); + let (mut p, cid) = setup(FrameBlend::FrameMix, (16, 8)); + p.comp_mut(cid).unwrap().enable_frame_blending = false; + assert!((frame(&p, cid, 1, false).get(4, 4)[0] - 0.0).abs() < 1e-6); +} + +fn mae(a: &Image, b: &Image, margin: u32) -> f32 { + let mut s = 0.0; + let mut n = 0; + for y in margin..a.height - margin { + for x in margin..a.width - margin { + let (p, q) = (a.get(x as i64, y as i64), b.get(x as i64, y as i64)); + s += (0..3).map(|c| (p[c] - q[c]).abs()).sum::(); + n += 3; + } + } + s / n as f32 +} + +#[test] +fn pixel_motion_beats_frame_mix_on_moving_footage() { + let size = (128, 96); + // Comp frame 2 → source frame 1.6: the pattern 9.6 px along. + let truth = pattern(size.0, size.1, 6.0 * 1.6); + let (p, cid) = setup(FrameBlend::FrameMix, size); + let mix = mae(&frame(&p, cid, 2, true), &truth, 14); + let (p, cid) = setup(FrameBlend::PixelMotion, size); + let pm = mae(&frame(&p, cid, 2, true), &truth, 14); + assert!(pm < mix * 0.3, "pixel motion {pm} vs frame mix {mix}"); +} + +#[test] +fn time_stretched_precomp_frame_mixes() { + // Inner comp: a white solid moving 10 px per frame from x = 10. The precomp is stretched to + // 200%, so odd outer frames fall halfway between inner frames. + let mut p = Project::default(); + p.settings.bit_depth = BitDepth::Bpc32; + let mut inner = Comp::new(400, 20, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let sid = p.add_item("S", Label::Red, None, ItemKind::Solid(Solid { color: [1.0; 3], width: 10, height: 20, pixel_aspect: 1.0 })); + let mut l = build::layer(&mut p, &inner, "S", LayerSource::Solid { item: sid }, (10, 20), None); + // Anchor at the left edge: x = 10 + 10 per frame. + l.props.prop_mut("transform/anchor").unwrap().value = Value::Vec3([0.0, 10.0, 0.0]); + l.props.prop_mut("transform/position").unwrap().keys = + vec![Keyframe::new(Tick::ZERO, Value::Vec3([10.0, 10.0, 0.0])), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Vec3([310.0, 10.0, 0.0]))]; + inner.layers.push(l); + let iid = p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.into())); + let outer = Comp::new(400, 20, FrameRate::FPS_30, Tick::from_seconds_f64(4.0)); + let cid = p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(outer.clone().into())); + let mut pre = build::layer(&mut p, &outer, "Inner", LayerSource::Comp { item: iid }, (400, 20), None); + pre.stretch = 200.0; + pre.out_point = Tick::from_seconds_f64(4.0); + pre.switches.frame_blend = FrameBlend::FrameMix; + p.comp_mut(cid).unwrap().layers.push(pre); + // Outer frame 1 → inner frame 0.5: solids at 10..20 (frame 0) and 20..30 (frame 1), half each. + let img = crate::render_frame(&p, cid, FrameRate::FPS_30.tick_of(1), 1.0); + for x in [12, 17, 22, 27] { + assert!((img.get(x, 10)[3] - 0.5).abs() < 5e-3, "x {x}: {:?}", img.get(x, 10)); + } + // Without frame blending the nested comp renders at the in-between time: one solid, 15..25. + p.comp_mut(cid).unwrap().layers[0].switches.frame_blend = FrameBlend::Off; + let img = crate::render_frame(&p, cid, FrameRate::FPS_30.tick_of(1), 1.0); + assert!(img.get(12, 10)[3] < 1e-3 && (img.get(17, 10)[3] - 1.0).abs() < 1e-3 && (img.get(22, 10)[3] - 1.0).abs() < 1e-3 && img.get(27, 10)[3] < 1e-3); +} diff --git a/crates/render/src/tests_roi.rs b/crates/render/src/tests_roi.rs new file mode 100644 index 00000000..9adc8a8e --- /dev/null +++ b/crates/render/src/tests_roi.rs @@ -0,0 +1,73 @@ +//! Region of interest: a ROI render equals the matching crop of the full render. + +use effectcraft_color::{BlendMode, Label}; +use effectcraft_keyframe::Value; +use effectcraft_project::build; +use effectcraft_project::{Comp, ItemKind, LayerSource, Project, Solid}; +use effectcraft_time::{FrameRate, Tick}; + +use crate::{Image, NoFootage, RenderOpts, Renderer}; + +fn project(three_d: bool) -> (Project, effectcraft_project::ItemId) { + let mut p = Project::default(); + let comp = Comp::new(160, 120, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + let mut layers = vec![]; + for (i, (c, w, h, pos, rot)) in + [([1.0, 0.2, 0.1], 90, 50, [60.0, 50.0], 17.0), ([0.1, 0.4, 1.0], 70, 70, [100.0, 75.0], -30.0), ([0.9, 0.9, 0.2], 40, 30, [30.0, 95.0], 0.0)] + .into_iter() + .enumerate() + { + let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color: c, width: w, height: h, pixel_aspect: 1.0 })); + let mut l = build::layer(&mut p, &comp, "Solid", LayerSource::Solid { item: sid }, (w, h), None); + l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([pos[0], pos[1], 0.0]); + l.props.prop_mut("transform/rotation").unwrap().value = Value::Scalar(rot); + l.props.prop_mut("transform/opacity").unwrap().value = Value::Scalar(80.0); + if i == 1 { + l.blend_mode = BlendMode::Screen; + l.switches.three_d = three_d; + } + layers.push(l); + } + p.comp_mut(cid).unwrap().layers = layers; + (p, cid) +} + +fn crop(img: &Image, x: u32, y: u32, w: u32, h: u32) -> Image { + let mut out = Image::new(w, h); + for yy in 0..h { + for xx in 0..w { + out.data[(yy * w + xx) as usize] = img.data[((yy + y) * img.width + xx + x) as usize]; + } + } + out +} + +fn assert_same(a: &Image, b: &Image) { + assert_eq!((a.width, a.height), (b.width, b.height)); + for (i, (p, q)) in a.data.iter().zip(&b.data).enumerate() { + for c in 0..4 { + assert!((p[c] - q[c]).abs() < 1e-4, "pixel {i} channel {c}: {} vs {}", p[c], q[c]); + } + } +} + +#[test] +fn roi_render_equals_cropped_full_render() { + for three_d in [false, true] { + let (p, cid) = project(three_d); + let full = Renderer::new(&p, &NoFootage, RenderOpts::default()).comp_frame(cid, Tick::ZERO); + let roi = Renderer::new(&p, &NoFootage, RenderOpts { roi: Some([30.0, 20.0, 80.0, 60.0]), ..Default::default() }).comp_frame(cid, Tick::ZERO); + assert_eq!((roi.width, roi.height), (80, 60)); + assert_same(&roi, &crop(&full, 30, 20, 80, 60)); + } +} + +#[test] +fn roi_at_half_resolution() { + let (p, cid) = project(false); + let full = Renderer::new(&p, &NoFootage, RenderOpts { scale: 0.5, ..Default::default() }).comp_frame(cid, Tick::ZERO); + let roi = Renderer::new(&p, &NoFootage, RenderOpts { scale: 0.5, roi: Some([40.0, 20.0, 60.0, 40.0]), ..Default::default() }).comp_frame(cid, Tick::ZERO); + assert_eq!((roi.width, roi.height), (30, 20)); + assert_same(&roi, &crop(&full, 20, 10, 30, 20)); +} diff --git a/crates/render/src/three_d/compose.rs b/crates/render/src/three_d/compose.rs index 53023c50..53ba8cdb 100644 --- a/crates/render/src/three_d/compose.rs +++ b/crates/render/src/three_d/compose.rs @@ -150,6 +150,10 @@ fn unproject(g: &Geo, sx: f64, sy: f64) -> Option<(f64, f64, f64)> { /// The camera a renderer uses at a context time (view override only for the top-level comp). pub(crate) fn camera_for(r: &Renderer, ctx: &EvalCtx) -> CameraState { + // Collapsed precomps see through the parent's camera. + if let Some(c) = r.collapse3d { + return c.cam; + } match r.opts.view { Some(v) if r.depth == 0 => v, _ => active_camera(ctx), @@ -167,7 +171,7 @@ 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> { - let buf = r.layer_buf(ctx, layer)?; + let buf = r.blend_layer_buf(ctx, layer)?; prepare_with(r, ctx, layer, order, out, in_run, buf, Mat4::IDENTITY) } @@ -176,7 +180,7 @@ pub(crate) fn prepare_all<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer, if crate::text::per_char_3d(ctx, layer) { return crate::text::per_char_planes(ctx, layer, r.opts.scale) .into_iter() - .filter_map(|(buf, m)| prepare_with(r, ctx, layer, order, out, in_run, Arc::new(buf), m)) + .filter_map(|(buf, m)| prepare_with(r, ctx, layer, order, out, in_run, r.to_blend(Arc::new(buf), None), m)) .collect(); } prepare(r, ctx, layer, order, out, in_run).into_iter().collect() @@ -200,7 +204,8 @@ fn prepare_with<'a>( let comp = (ctx.comp.width as f64, ctx.comp.height as f64); let s = r.opts.scale; let cam = camera_for(r, ctx); - let world = ctx.world_matrix(layer) * local; + let outer = r.collapse3d.map_or(Mat4::IDENTITY, |c| c.world); + let world = outer * ctx.world_matrix(layer) * local; // Depth of field: blur the layer by its circle of confusion at its centre. if let Some(dof) = cam.dof.filter(|_| !ctx.comp.draft_3d && !r.opts.draft) { let b = buf_bounds(&buf); @@ -234,7 +239,7 @@ fn prepare_with<'a>( let f = phase + angle * i as f64 / (n - 1) as f64; let sub = ctx.at(ctx.time + Tick::from_seconds_f64(f * fd)); let c = camera_for(r, &sub); - geo(&c, sub.world_matrix(layer) * local, &buf, comp, s, out) + geo(&c, outer * sub.world_matrix(layer) * local, &buf, comp, s, out) }) .collect() }; @@ -253,7 +258,7 @@ fn prepare_with<'a>( bicubic: matches!(r.sampling(layer), effectcraft_raster::Sampling::Bicubic), buf, geos, - opacity: ctx.opacity(layer) as f32, + opacity: ctx.opacity(layer) as f32 * r.opacity_mul(), draw: in_run && mat.casts_shadows != 2, mat, matte: None, @@ -319,12 +324,18 @@ pub(crate) fn draw_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], canvas: &mut return; } let out = (canvas.width, canvas.height); - let lights = scene_lights(ctx); + // A collapsed precomp's 3D layers are lit by the outer comp's lights. + let lights = scene_lights(&r.collapse3d.map_or(*ctx, |c| c.parent)); let mut items: Vec = run .par_iter() .enumerate() - .filter(|(_, l)| ctx.opacity(l) > 0.0) + .filter(|(_, l)| ctx.opacity(l) > 0.0 && l.switches.quality != effectcraft_project::Quality::Wireframe) .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); + } let mut its = prepare_all(r, ctx, l, i, out, true); if !its.is_empty() { let m = matte_for(r, ctx, l, out); @@ -351,6 +362,49 @@ pub(crate) fn draw_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], canvas: &mut items.sort_by_key(|i| i.order); let casters: Vec<&Item> = items.iter().chain(extra.iter()).filter(|i| i.mat.casts_shadows != 0 && !i.geos.is_empty()).collect(); composite(canvas, &items, &lights, &casters, run[0].id.0 as u32); + for l in run.iter().filter(|l| l.switches.quality == effectcraft_project::Quality::Wireframe) { + r.draw_layer(ctx, l, canvas); + } +} + +/// A collapsed 3D precomp layer inside a 3D run: its nested layers become planes in this comp's +/// 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> { + 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 t = nctx.time; + let any_solo = nctx.comp.layers.iter().any(|l| l.switches.solo && l.source.is_av() && l.is_active_at(t)); + let nested: Vec<&'a Layer> = nctx + .comp + .layers + .iter() + .rev() + .filter(|l| l.is_active_at(t) && l.has_video() && !l.switches.adjustment && (!any_solo || l.switches.solo) && (r.opts.guides || !l.switches.guide)) + .collect(); + let mut items = Vec::new(); + for (k, l) in nested.into_iter().enumerate() { + if nctx.opacity(l) <= 0.0 { + continue; + } + let mut its = prepare_all(&sub, &nctx, l, order + 1 + k.min(1022), out, true); + if !its.is_empty() { + let m = matte_for(&sub, &nctx, l, out); + for it in &mut its { + it.matte = m.clone(); + } + } + items.extend(its); + } + items } /// Per-pixel depth-sorted compositing of prepared planes. diff --git a/crates/render/src/three_d/tests.rs b/crates/render/src/three_d/tests.rs index 2a068e3a..01e0399d 100644 --- a/crates/render/src/three_d/tests.rs +++ b/crates/render/src/three_d/tests.rs @@ -14,6 +14,8 @@ const H: u32 = 100; fn setup() -> (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 cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); (p, cid, comp) diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index 07e5b844..b2fa142e 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -113,9 +113,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { x = puppet_options(app, ui, &p, x, cy); } let snap = Rect::from_min_size(pos2(x, cy - 10.0), vec2(20.0, 20.0)); + // The engine owns snapping (View ▸ Snapping); the checkbox mirrors it. + app.ui.snapping = app.session.state.snapping; if widgets::checkbox(ui, snap, app.ui.snapping, &t, egui::Id::new("snapping")).clicked() { - app.ui.snapping = !app.ui.snapping; - app.session.state.snapping = app.ui.snapping; + let _ = app.session.execute("view.snapping", serde_json::json!({})); + app.ui.snapping = app.session.state.snapping; } app.auto.add("header.snapping", snap, "Snapping"); p.text(pos2(snap.max.x + 4.0, cy), Align2::LEFT_CENTER, "Snapping", Tokens::ui(12.0), t.text_dim); diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 816da289..d28cb950 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -23,6 +23,10 @@ pub enum Icon { Polygon, Star, Pen, + PenAdd, + PenDelete, + PenConvert, + MaskFeather, Type, TypeVertical, Brush, @@ -557,6 +561,25 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) { Sparkle => { pen.fill(&[(8.0, 1.5), (9.4, 6.6), (14.5, 8.0), (9.4, 9.4), (8.0, 14.5), (6.6, 9.4), (1.5, 8.0), (6.6, 6.6)]); } + PenAdd | PenDelete => { + // A small pen nib with a plus or minus beside it. + pen.closed(&[(6.0, 2.5), (9.0, 8.0), (6.0, 12.0), (3.0, 8.0)]); + pen.line(&[(6.0, 2.5), (6.0, 7.0)]); + pen.line(&[(10.0, 11.5), (14.5, 11.5)]); + if icon == PenAdd { + pen.line(&[(12.25, 9.25), (12.25, 13.75)]); + } + } + PenConvert => { + // An open caret: a corner vertex with two straight sides. + pen.line(&[(2.5, 13.0), (8.0, 3.5), (13.5, 13.0)]); + pen.dot(8.0, 3.5, 1.4); + } + MaskFeather => { + pen.closed(&[(6.0, 2.5), (9.0, 8.0), (6.0, 12.0), (3.0, 8.0)]); + pen.line(&[(6.0, 2.5), (6.0, 7.0)]); + pen.line(&[(10.5, 4.0), (12.5, 6.5), (13.5, 10.0), (12.5, 13.5)]); + } } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 7627c096..ffbf41f5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -283,7 +283,13 @@ impl EffectcraftApp { let full = self.ui.viewer.res.scale(zoom, ppp); return full.min((full * 0.5).max(self.session.prefs.adaptive_limit())); } - self.ui.viewer.res.scale(zoom, ppp) + let full = self.ui.viewer.res.scale(zoom, ppp); + // Fast Previews ▸ Adaptive Resolution / Fast Draft: lower resolution while dragging. + let (_, k) = self.session.state.viewer.fast_previews.render(self.ui.viewer.interacting); + if k < 1.0 && self.ui.viewer.res == state::Resolution::Auto { + return full.min((full * 0.5).max(self.session.prefs.adaptive_limit())); + } + full } pub fn frame_key(&self, comp: ItemId, frame: i64, scale: f64) -> FrameKey { @@ -293,8 +299,13 @@ impl EffectcraftApp { /// Hash of the comp viewer's 3D view camera (0 for the active camera view). pub fn view_hash(&self, comp: ItemId) -> u64 { use std::hash::{Hash, Hasher}; - let Some(cam) = self.session.view_camera(comp) else { return 0 }; let mut h = std::collections::hash_map::DefaultHasher::new(); + // The region of interest changes what is rendered too. + let roi = self.session.state.region_of_interest.filter(|_| self.session.active_comp_id() == Some(comp)); + if let Some(r) = roi { + r.map(f64::to_bits).hash(&mut h); + } + let Some(cam) = self.session.view_camera(comp) else { return if roi.is_some() { h.finish() | 1 } else { 0 } }; for row in cam.view.0 { for v in row { v.to_bits().hash(&mut h); @@ -319,7 +330,10 @@ impl EffectcraftApp { 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 = RenderOpts { scale, motion_blur: true, guides: true, draft: self.ui.viewer.fast_preview, view: self.session.view_camera(comp) }; + let (draft, _) = self.session.state.viewer.fast_previews.render(self.ui.viewer.interacting); + let roi = self.session.state.region_of_interest.filter(|_| self.session.active_comp_id() == Some(comp)); + let opts = + RenderOpts { scale, motion_blur: true, guides: true, draft: self.ui.viewer.fast_preview || draft, view: self.session.view_camera(comp), roi }; if urgent { self.frames.request_urgent(&self.render_source(), key, comp, t, opts); } else { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 7ec29b30..595538e8 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -131,6 +131,7 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V // Slot shortcuts cycle through the slot's tools. let slot = match t { "shape" => Some(8), + "pen" => Some(9), "type" => Some(10), "brush" => Some(11), "puppet" => Some(15), @@ -195,6 +196,11 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V "view.safeMargins" => app.ui.viewer.safe_margins = !app.ui.viewer.safe_margins, "view.transparencyGrid" => app.ui.viewer.transparency_grid = !app.ui.viewer.transparency_grid, "view.fastPreviews" => { + // With a mode: Fast Previews ▸ Off / Adaptive Resolution / Draft / Fast Draft / + // Wireframe; without: toggle Draft quality (Settings ▸ Previews). + if params.get("mode").is_some() { + return run_engine(app, ctx, "view.fastPreviewMode", params); + } let on = !app.ui.viewer.fast_preview; app.set_pref("previews.fastPreviews", json!(on))?; app.ui.viewer.fast_preview = on; @@ -395,6 +401,30 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu v.res = Resolution::ALL.into_iter().find(|x| x.label().eq_ignore_ascii_case(r)).ok_or("unknown resolution")?; Value::Null } + "view.res.custom" => { + match p.get("factor").and_then(Value::as_u64) { + Some(n) => { + v.res = match n.clamp(1, 40) { + 1 => Resolution::Full, + n => Resolution::Custom(n as u8), + }; + } + None => { + let cur = match v.res { + Resolution::Custom(n) => n as f64, + _ => 2.0, + }; + crate::panels::dialogs::form( + app, + "Custom Resolution", + "view.res.custom", + json!({}), + vec![crate::panels::dialogs::Field::num("factor", "Render every n-th pixel", cur)], + ); + } + } + Value::Null + } "view.rulers" => toggle(&mut v.rulers, &p), "view.guides" => toggle(&mut v.guides, &p), "view.snapToGuides" => toggle(&mut v.snap_guides, &p), diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs index 77e432b9..e0f1a5f2 100644 --- a/crates/ui-egui/src/panels/forms.rs +++ b/crates/ui-egui/src/panels/forms.rs @@ -218,7 +218,7 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool { ), ], ), - "file.interpretFootage" if !has(p, &["frameRate", "alpha", "loop", "pixelAspect"]) => { + "file.interpretFootage" if !has(p, &["frameRate", "alpha", "loop", "pixelAspect", "colorProfile"]) => { let f = s.state.project_selection.first().and_then(|i| s.project.item(*i)).and_then(|it| match &it.kind { effectcraft_engine::project::ItemKind::Footage(f) => Some(f.clone()), _ => None, @@ -236,6 +236,18 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool { ), Field::num("loop", "Loop (times)", f.loop_count as f64), Field::num("pixelAspect", "Pixel aspect ratio", f.pixel_aspect), + Field::choice( + "colorProfile", + "Color profile", + &[ + ("Embedded / sRGB", json!("auto")), + ("sRGB IEC61966-2.1", json!("srgb")), + ("HDTV (Rec. 709)", json!("rec709")), + ("Rec. 2020", json!("rec2020")), + ("Display P3", json!("p3")), + ], + f.color_profile.map_or(0, |c| 1 + effectcraft_engine::project::ColorSpace::ALL.iter().position(|x| *x == c).unwrap_or(0)), + ), ], ) } @@ -261,7 +273,20 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool { &[("Timecode", json!("timecode")), ("Frames", json!("frames"))], usize::from(matches!(st.time_display, effectcraft_engine::project::TimeDisplayStyle::Frames)), ), + Field::choice( + "workingSpace", + "Working space", + &[ + ("None", json!("none")), + ("sRGB IEC61966-2.1", json!("srgb")), + ("HDTV (Rec. 709)", json!("rec709")), + ("Rec. 2020", json!("rec2020")), + ("Display P3", json!("p3")), + ], + st.working_space.map_or(0, |c| 1 + effectcraft_engine::project::ColorSpace::ALL.iter().position(|x| *x == c).unwrap_or(0)), + ), Field::bool("linearize", "Linearize working space", st.linearize), + Field::bool("blendLinear", "Blend colors using 1.0 gamma", st.blend_linear), ], ) } diff --git a/crates/ui-egui/src/panels/graph.rs b/crates/ui-egui/src/panels/graph.rs index 7a3072e6..655fe619 100644 --- a/crates/ui-egui/src/panels/graph.rs +++ b/crates/ui-egui/src/panels/graph.rs @@ -231,11 +231,45 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte p.text(plot.center(), Align2::CENTER_CENTER, "Select animated properties to show them in the Graph Editor", Tokens::ui(12.0), t.text_faint); } + // Show Reference Graph: the other graph type, faint, scaled to the plot. + if app.ui.timeline.graph_reference && !cs.is_empty() { + let other = curves(app, comp, ectx, tm, plot, !speed); + let (mut rlo, mut rhi) = (f64::INFINITY, f64::NEG_INFINITY); + for c in &other { + for (_, v) in &c.pts { + rlo = rlo.min(*v); + rhi = rhi.max(*v); + } + } + if rlo.is_finite() { + let span = (rhi - rlo).max(1e-9); + let lines: Vec<(Color32, Vec)> = other + .iter() + .map(|c| { + ( + c.color, + c.pts + .iter() + .map(|(ct, v)| pos2(tm.x(*ct), plot.max.y - ((v - rlo) / span) as f32 * plot.height() * 0.9 - plot.height() * 0.05)) + .collect(), + ) + }) + .collect(); + super::graph_tools::reference_graph(&pc, plot, &lines); + } + } // Curves. for c in &cs { let pts: Vec = c.pts.iter().map(|(ct, v)| pos2(tm.x(*ct), ymap(*v))).collect(); pc.add(egui::Shape::line(pts, Stroke::new(1.5, c.color))); } + // Transform box around several selected keys (inside below the keys, handles above). + let sel_keys: Vec = cs + .iter() + .flat_map(|c| c.keys.iter().filter(|k| k.sel).map(move |k| super::graph_tools::SelKey { t: k.t, v: k.v, dim: c.dim, editable: c.editable })) + .collect(); + let all_key_times: Vec<(u64, usize, f64)> = cs.iter().flat_map(|c| c.keys.iter().map(move |k| (c.prop.uid, k.idx, k.t))).collect(); + super::graph_tools::transform_box(app, ui, &pc, &sel_keys, tm, &ymap, &vmap, speed, false, actions); // Keys and handles. let mut key_screens: Vec<(serde_json::Value, Pos2)> = vec![]; let fr = comp.frame_rate; @@ -298,7 +332,10 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte { ctx.data_mut(|dd| dd.insert_temp(drag_id, true)); let merge = format!("gkey-{uid}-{}", k.idx); - let nt = c.layer.layer_time(fr.snap_nearest(Tick::from_seconds_f64(tm.t(pt.x).max(0.0)))); + // Graph Editor ▸ Snap: to the current time and other keys. + let others: Vec = all_key_times.iter().filter(|(u, i, _)| !(*u == uid && *i == k.idx)).map(|x| x.2).collect(); + let ct = super::graph_tools::snap_time(app, tm, tm.t(pt.x).max(0.0), &others); + let nt = c.layer.layer_time(fr.snap_nearest(Tick::from_seconds_f64(ct))); let mut params = json!({"layer": lid.0, "prop": uid, "time": k.time.seconds(), "newTime": nt.seconds(), "merge": merge}); if !speed && c.editable { let mut comps = c.prop.keys[k.idx].value.components(); @@ -316,6 +353,8 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte } } + super::graph_tools::transform_box(app, ui, &pc, &sel_keys, tm, &ymap, &vmap, speed, true, actions); + // Background: click deselects keys, drag box-selects. if bg.clicked() { actions.push(("keys.select".into(), json!({"keys": []}))); @@ -374,6 +413,17 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte fit_selection(app, &cs, tm, plot); } x += 6.0; + let (snap, reference, tbox) = (app.ui.timeline.graph_snap, app.ui.timeline.graph_reference, app.ui.timeline.graph_transform_box); + if text_button(app, ui, p, &mut x, y, "Snap", snap, "snap", "Snap (key drags snap to the current time and other keys)") { + app.ui.timeline.graph_snap = !snap; + } + if text_button(app, ui, p, &mut x, y, "Reference", reference, "reference", "Show Reference Graph") { + app.ui.timeline.graph_reference = !reference; + } + if text_button(app, ui, p, &mut x, y, "Transform Box", tbox, "transformBox", "Show Transform Box when multiple keys are selected") { + app.ui.timeline.graph_transform_box = !tbox; + } + x += 6.0; let pos_sel = app.session.state.selected_props.iter().find_map(|(l, u)| { let layer = comp.layer(*l)?; let tr = layer.transform()?; diff --git a/crates/ui-egui/src/panels/graph_tools.rs b/crates/ui-egui/src/panels/graph_tools.rs new file mode 100644 index 00000000..c42fd329 --- /dev/null +++ b/crates/ui-egui/src/panels/graph_tools.rs @@ -0,0 +1,281 @@ +//! Graph Editor and timeline keyframe tools: the transform box around several selected keys +//! (scale and move them in time and value), key-time snapping, and Alt-drag of a key group's +//! first or last key in the timeline to scale the group in time. Edits are `keys.transform`. + +use effectcraft_engine::project::Comp; +use egui::{Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; +use serde_json::{Value, json}; + +use super::timeline::TMap; +use crate::EffectcraftApp; + +type Actions = Vec<(String, Value)>; + +/// A selected key point in the graph: comp seconds, graph value, the dimension it shows, and +/// whether its value is editable there. +#[derive(Clone, Copy)] +pub(crate) struct SelKey { + pub t: f64, + pub v: f64, + pub dim: usize, + pub editable: bool, +} + +/// Transform box geometry (data space): [t0, v0, t1, v1]. +#[derive(Clone, Copy, Debug)] +struct BoxDrag { + /// 0–3 corners (t0v1, t1v1, t1v0, t0v0 = screen TL, TR, BR, BL), 4–7 edges (top, right, + /// bottom, left), 8 = move. + handle: usize, + /// Fixed opposite point (comp s, value). + anchor: [f64; 2], + /// Pointer data position of the previous frame. + last: [f64; 2], +} + +fn box_id() -> egui::Id { + egui::Id::new("graph-transform-box") +} + +/// Bounds of the selected keys: (t0, v0, t1, v1). +fn bounds(keys: &[SelKey]) -> Option<[f64; 4]> { + if keys.len() < 2 { + return None; + } + let mut b = [f64::MAX, f64::MAX, f64::MIN, f64::MIN]; + for k in keys { + b = [b[0].min(k.t), b[1].min(k.v), b[2].max(k.t), b[3].max(k.v)]; + } + (b[2] > b[0] || b[3] > b[1]).then_some(b) +} + +fn handle_points(r: Rect) -> [Pos2; 8] { + let c = r.center(); + [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom(), pos2(c.x, r.min.y), pos2(r.max.x, c.y), pos2(c.x, r.max.y), pos2(r.min.x, c.y)] +} + +/// The transform box for two or more selected keys. Call before the keys are drawn (its inside +/// sits below them) with `handles = false`, and after them with `handles = true` (the handles sit +/// on top). Dragging a corner scales time and value about the opposite corner, an edge one axis, +/// the inside moves the keys; times land on frames. +#[allow(clippy::too_many_arguments)] +pub(crate) fn transform_box( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + p: &egui::Painter, + keys: &[SelKey], + tm: TMap, + ymap: &dyn Fn(f64) -> f32, + vmap: &dyn Fn(f32) -> f64, + speed: bool, + handles: bool, + actions: &mut Actions, +) { + if !app.ui.timeline.graph_transform_box { + return; + } + let Some(b) = bounds(keys) else { return }; + let r = Rect::from_min_max(pos2(tm.x(b[0]), ymap(b[3])), pos2(tm.x(b[2]), ymap(b[1]))).expand(6.0); + let value_ok = !speed && keys.iter().all(|k| k.editable); + let col = Color32::from_rgb(0xd8, 0xd8, 0xd8); + let ctx = ui.ctx().clone(); + let drag_id = egui::Id::new("graph-dragging"); + let hp = handle_points(r); + let data = |q: Pos2| [tm.t(q.x), vmap(q.y)]; + // Which data point each handle sits on, and its opposite. + let corner = |i: usize| -> [f64; 2] { + match i { + 0 => [b[0], b[3]], + 1 => [b[2], b[3]], + 2 => [b[2], b[1]], + 3 => [b[0], b[1]], + 4 => [(b[0] + b[2]) / 2.0, b[3]], + 5 => [b[2], (b[1] + b[3]) / 2.0], + 6 => [(b[0] + b[2]) / 2.0, b[1]], + _ => [b[0], (b[1] + b[3]) / 2.0], + } + }; + let mut start: Option = None; + let mut resp_any: Option = None; + if !handles { + p.rect_stroke(r, 0.0, Stroke::new(1.0, col.gamma_multiply(0.7)), StrokeKind::Middle); + let resp = ui.interact(r, egui::Id::new("graph-box-inside"), Sense::drag()); + app.auto.add("timeline.graph.transformBox", r, "Transform box"); + if resp.drag_started() + && let Some(o) = ui.input(|i| i.pointer.press_origin()) + { + start = Some(BoxDrag { handle: 8, anchor: [0.0; 2], last: data(o) }); + } + resp_any = Some(resp); + } else { + for (i, h) in hp.iter().enumerate() { + // Value handles only when values can change. + if !value_ok && matches!(i, 4 | 6) { + continue; + } + let hr = Rect::from_center_size(*h, vec2(7.0, 7.0)); + p.rect_filled(hr, 0.0, Color32::from_gray(0x20)); + p.rect_stroke(hr, 0.0, Stroke::new(1.0, col), StrokeKind::Middle); + let resp = ui.interact(hr.expand(2.0), egui::Id::new(("graph-box-handle", i)), Sense::drag()); + app.auto.add(&format!("timeline.graph.transformBox.{i}"), hr, "Transform box handle"); + if resp.drag_started() { + let opp = [2, 3, 0, 1, 6, 7, 4, 5][i]; + start = Some(BoxDrag { handle: i, anchor: corner(opp), last: corner(i) }); + } + if resp.dragged() || resp.drag_stopped() { + resp_any = Some(resp); + } + } + } + if let Some(s) = start { + ctx.data_mut(|d| { + d.insert_temp(box_id(), s); + d.insert_temp(drag_id, true); + }); + } + let Some(resp) = resp_any else { return }; + let Some(mut st) = ctx.data(|d| d.get_temp::(box_id())) else { return }; + if (st.handle == 8) == handles { + return; + } + if resp.dragged() + && let Some(pt) = resp.interact_pointer_pos() + { + let cur = data(pt); + let mut params = json!({"merge": "graph-box"}); + if st.handle == 8 { + params["timeOffset"] = json!(cur[0] - st.last[0]); + if value_ok { + params["valueOffset"] = json!(cur[1] - st.last[1]); + } + st.last = cur; + } else { + let (time_axis, value_axis) = match st.handle { + 4 | 6 => (false, true), + 5 | 7 => (true, false), + _ => (true, true), + }; + // Scale relative to where the handle is now (incremental, so merged steps compose). + let now = corner(st.handle); + if time_axis && (now[0] - st.anchor[0]).abs() > 1e-6 { + let k = (cur[0] - st.anchor[0]) / (now[0] - st.anchor[0]); + if k > 0.01 { + params["timeScale"] = json!(k); + params["timeAnchor"] = json!(st.anchor[0]); + } + } + if value_axis && value_ok && (now[1] - st.anchor[1]).abs() > 1e-9 { + params["valueScale"] = json!((cur[1] - st.anchor[1]) / (now[1] - st.anchor[1])); + params["valueAnchor"] = json!(st.anchor[1]); + } + } + let mut dims: Vec = keys.iter().map(|k| k.dim).collect(); + dims.sort_unstable(); + dims.dedup(); + params["dims"] = json!(dims); + if params.as_object().is_some_and(|m| m.len() > 2) { + actions.push(("keys.transform".into(), params)); + } + ctx.data_mut(|d| d.insert_temp(box_id(), st)); + } + if resp.drag_stopped() { + ctx.data_mut(|d| { + d.remove::(box_id()); + d.insert_temp(drag_id, false); + }); + actions.push(("__endMerge".into(), json!({}))); + } +} + +/// Graph Editor ▸ Snap: a dragged key time (comp s) snaps to the current time or another key's +/// time within 6 points. +pub(crate) fn snap_time(app: &EffectcraftApp, tm: TMap, t: f64, others: &[f64]) -> f64 { + if !app.ui.timeline.graph_snap { + return t; + } + let cti = app.session.time().seconds(); + let x = tm.x(t); + std::iter::once(cti) + .chain(others.iter().copied()) + .map(|c| (c, (tm.x(c) - x).abs())) + .filter(|(_, d)| *d < 6.0) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(c, _)| c) + .unwrap_or(t) +} + +// ---------------------------------------------------------------- timeline Alt-drag scaling + +#[derive(Clone, Copy, Debug)] +struct AltScale { + /// The fixed end of the group (comp s). + anchor: f64, + /// The dragged end is the group's last key (else its first). + end_is_max: bool, +} + +fn alt_id() -> egui::Id { + egui::Id::new("timeline-alt-scale") +} + +/// Selected keys' comp-time range. +fn sel_range(app: &EffectcraftApp, comp: &Comp) -> Option<(f64, f64)> { + let ts: Vec = app.session.state.selected_keys.iter().filter_map(|k| comp.layer(k.layer).map(|l| l.comp_time(k.time).seconds())).collect(); + if ts.len() < 2 { + return None; + } + let lo = ts.iter().copied().fold(f64::MAX, f64::min); + let hi = ts.iter().copied().fold(f64::MIN, f64::max); + (hi - lo > 1e-9).then_some((lo, hi)) +} + +/// Alt-drag on the first or last key of a selected group starts scaling the group in time +/// about the other end. Returns true when it took the drag. +pub(crate) fn alt_scale_begin(app: &EffectcraftApp, ctx: &egui::Context, comp: &Comp, key_ct: f64) -> bool { + let Some((lo, hi)) = sel_range(app, comp) else { return false }; + let st = if (key_ct - hi).abs() < 1e-6 { + AltScale { anchor: lo, end_is_max: true } + } else if (key_ct - lo).abs() < 1e-6 { + AltScale { anchor: hi, end_is_max: false } + } else { + return false; + }; + ctx.data_mut(|d| d.insert_temp(alt_id(), st)); + true +} + +/// While an Alt-drag scale is active: scale the group so its dragged end follows the pointer. +pub(crate) fn alt_scale_update(app: &EffectcraftApp, ctx: &egui::Context, comp: &Comp, tm: TMap, actions: &mut Actions) { + let Some(st) = ctx.data(|d| d.get_temp::(alt_id())) else { return }; + if !ctx.input(|i| i.pointer.primary_down()) { + ctx.data_mut(|d| d.remove::(alt_id())); + actions.push(("__endMerge".into(), json!({}))); + return; + } + let (Some((lo, hi)), Some(px)) = (sel_range(app, comp), ctx.input(|i| i.pointer.latest_pos())) else { return }; + let fr = comp.frame_rate; + let target = fr.snap_nearest(effectcraft_engine::time::Tick::from_seconds_f64(tm.t(px.x).max(0.0))).seconds(); + let end = if st.end_is_max { hi } else { lo }; + let span = end - st.anchor; + let want = target - st.anchor; + if span.abs() < 1e-9 || want.abs() < 1e-9 || want.signum() != span.signum() { + return; + } + let k = want / span; + if (k - 1.0).abs() > 1e-6 { + actions.push(("keys.transform".into(), json!({"timeScale": k, "timeAnchor": st.anchor, "merge": "key-alt-scale"}))); + } +} + +/// An Alt-drag group scale is in progress (the plain key move stands aside). +pub(crate) fn alt_scale_active(ctx: &egui::Context) -> bool { + ctx.data(|d| d.get_temp::(alt_id())).is_some() +} + +/// Graph Editor ▸ Show Reference Graph: the other graph type, normalized to the plot, faint. +pub(crate) fn reference_graph(p: &egui::Painter, plot: Rect, curves: &[(Color32, Vec)]) { + for (c, pts) in curves { + p.add(egui::Shape::line(pts.clone(), Stroke::new(1.0, c.gamma_multiply(0.35)))); + } + let _ = plot; +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 56d05445..2c2f6919 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -8,6 +8,7 @@ pub mod effects_presets; pub mod forms; pub mod fx_widgets; pub mod graph; +pub mod graph_tools; pub mod info; pub mod key_dialogs; pub mod layer_panel; @@ -23,7 +24,9 @@ pub mod text_panels; pub mod timeline; pub mod tracker; pub mod viewer; +pub mod viewer_overlays; pub mod viewer_text; +pub mod viewer_tools; pub mod waveform; use effectcraft_engine::Session; diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 4d0e0bb1..31d7d4de 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -311,9 +311,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let dresp = ui.interact(dr, egui::Id::new("bpc"), Sense::click()); p.text(dr.center(), Align2::CENTER_CENTER, depth, Tokens::ui(11.5), if dresp.hovered() { t.text } else { t.text_dim }); app.auto.add("project.bitDepth", dr, depth); + // Click cycles 8/16/32 bpc; Alt-click opens Project Settings (as in After Effects). if dresp.clicked() { - actions.push(("file.cycleBitDepth".into(), json!({}))); + if ui.input(|i| i.modifiers.alt) { + actions.push(("file.projectSettings".into(), json!({}))); + } else { + actions.push(("file.cycleBitDepth".into(), json!({}))); + } } + dresp.on_hover_text("Project color depth: click to cycle 8/16/32 bpc, Alt-click for Project Settings"); let tr = Rect::from_min_size(pos2(foot.max.x - 30.0, foot.min.y + 3.0), vec2(22.0, 22.0)); if widgets::icon_button(ui, tr, Icon::Trash, false, &t, egui::Id::new("ptrash")).on_hover_text("Delete selected project items").clicked() { let sel = app.session.state.project_selection.clone(); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index ab85e8eb..78f62491 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -953,8 +953,40 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { { let xs = tm.x(layer.start_time.seconds()); let xe = tm.x(layer.comp_time(d).seconds()); - let ghost = Rect::from_min_max(pos2(xs, r.min.y + 9.0), pos2(xe, r.max.y - 9.0)); + let ghost = Rect::from_min_max(pos2(xs.min(xe), r.min.y + 9.0), pos2(xs.max(xe), r.max.y - 9.0)); gp.rect_filled(ghost, 1.0, lc.gamma_multiply(0.18)); + // Dragging the source bar outside the in/out span slips the source. + for (side, gr) in [ + ("l", Rect::from_min_max(ghost.min, pos2(bar.min.x, ghost.max.y))), + ("r", Rect::from_min_max(pos2(bar.max.x, ghost.min.y), ghost.max)), + ] { + if gr.width() < 2.0 { + continue; + } + let gresp = ui.interact(gr, egui::Id::new(("ghost", side, layer.id.0)), Sense::drag()).on_hover_text("Drag to slip the source"); + app.auto.add(&format!("timeline.layer.{}.source{}", layer.id.0, side), gr, "Slip"); + if gresp.hovered() || gresp.dragged() { + ctx.set_cursor_icon(egui::CursorIcon::ResizeHorizontal); + } + let acc_id = egui::Id::new(("slip-acc", layer.id.0)); + if gresp.dragged() { + let mut acc: f32 = ctx.data(|d| d.get_temp(acc_id).unwrap_or(0.0)); + acc += gresp.drag_delta().x; + let frames = ((acc as f64 / pps) / fd).trunc() as i64; + if frames != 0 { + acc -= (frames as f64 * fd * pps) as f32; + actions.push(( + "layer.slip".into(), + json!({"layers": [layer.id.0], "frames": frames, "merge": format!("slip-{}", layer.id.0)}), + )); + } + ctx.data_mut(|d| d.insert_temp(acc_id, acc)); + } + if gresp.drag_stopped() { + ctx.data_mut(|d| d.remove::(acc_id)); + ui_actions.push(UiAct::EndMerge); + } + } } app.auto.add(&format!("timeline.layer.{}.bar", layer.id.0), bar, &layer.name); // Interactions: move body, trim edges. @@ -1320,7 +1352,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } }); - if kresp.dragged() { + // Alt-drag the first or last key of a selected group: scale the group in time. + if kresp.drag_started() + && ui.input(|i| i.modifiers.alt) + && let Some(c) = app.session.active_comp().cloned() + { + super::graph_tools::alt_scale_begin(app, &ctx, &c, ct.seconds()); + } + let alt_scaling = super::graph_tools::alt_scale_active(&ctx); + if kresp.dragged() && !alt_scaling { let acc_id = egui::Id::new("key-drag-acc"); let mut acc: f32 = ctx.data(|d| d.get_temp(acc_id).unwrap_or(0.0)); acc += kresp.drag_delta().x; @@ -1364,6 +1404,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } } + // Alt-drag key group scaling (follows the pointer until release). + if let Some(c) = app.session.active_comp().cloned() { + super::graph_tools::alt_scale_update(app, &ctx, &c, tm, &mut actions); + } // Graph editor. if graph_on { super::graph::show(app, ui, &gp, &comp, &ectx, tm, Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max), &mut actions); diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 65dd6516..e0f1888a 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -5,15 +5,14 @@ //! camera/light wireframes. use effectcraft_engine::geom::{Mat3, vec2 as gv2}; -use effectcraft_engine::project::{GroupKind, Layer, LayerId}; +use effectcraft_engine::project::{Layer, LayerId}; use effectcraft_engine::render::EvalCtx; use effectcraft_engine::render::three_d::{self, CameraState, View3D}; use effectcraft_engine::time::Tick; use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; use serde_json::json; -use crate::icons::Icon; -use crate::state::{Resolution, Tool}; +use crate::state::Tool; use crate::theme::Tokens; use crate::{EffectcraftApp, widgets}; @@ -62,6 +61,8 @@ enum Gesture { Move { layers: Vec<(LayerId, [f64; 3], [f64; 3], [f64; 3])>, start: [f64; 2], + /// The grabbed layer's feature nearest the pointer (comp px at the start): it snaps. + snap_src: Option<[f64; 2]>, }, Camera { tool: Tool, @@ -96,10 +97,14 @@ enum Gesture { Marquee { start: Pos2, }, - /// Dragging selected mask vertices (comp-space point of the previous frame, comp → layer). + /// Dragging selected mask / shape path vertices: press point, the grabbed vertex (comp px, + /// it snaps), the comp-space move applied so far and comp → path space. Vertices { - last: [f64; 2], + start: [f64; 2], + src: [f64; 2], + applied: [f64; 2], inv: Mat3, + layer: LayerId, }, /// Dragging a vertex's Bezier tangent (both sides symmetric unless Alt breaks them). Tangent { @@ -131,6 +136,9 @@ enum Gesture { tracker: u64, drag: super::tracker::Drag, }, + /// Motion paths, free transform, Mask Feather, guides and the region of interest + /// (`viewer_overlays`). + Overlay(ov::Drag), } /// Pen tool path in progress: (layer, mask uid). @@ -138,19 +146,8 @@ fn pen_id() -> egui::Id { egui::Id::new("viewer-pen") } -/// Mask vertex / tangent hit targets drawn this frame. -#[derive(Clone, Copy)] -struct VertexHit { - layer: LayerId, - mask: u64, - index: usize, - pos: Pos2, - /// None = the vertex itself, Some(out?) = a tangent handle. - tangent: Option, - /// Layer-space vertex position and layer → comp matrix. - vertex: [f64; 2], - l2c: Mat3, -} +use super::viewer_overlays::{self as ov, VertexHit}; +use super::viewer_tools as vt; const HANDLE: f32 = 7.0; @@ -356,6 +353,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // keeps the overlays and the interaction. let area = aux_views(app, ui, &comp, cid, full, pasteboard); p.rect_filled(area, 0.0, pasteboard); + // View ▸ Show Rulers takes a strip on the top and left. + let outer = area; + let area = vt::inset(app, area); let (cw, ch) = (comp.width as f32, comp.height as f32); let fit = ((area.width() - 40.0) / cw).min((area.height() - 40.0) / ch).max(0.01); @@ -391,6 +391,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { _ => app.viewer_tex = Some((ctx.load_texture("viewer-frame", (*img).clone(), egui::TextureOptions::LINEAR), key)), } app.viewer_image = Some(img); + vt::set_texture_roi(&ctx, app.session.state.region_of_interest); } } if app.ui.viewer.transparency_grid { @@ -399,11 +400,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let bg = comp.background; painter.rect_filled(comp_rect, 0.0, Color32::from_rgb((bg[0] * 255.0) as u8, (bg[1] * 255.0) as u8, (bg[2] * 255.0) as u8)); } - if let Some((tex, k)) = &app.viewer_tex - && k.comp == cid.0 - { - painter.image(tex.id(), comp_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); - } + 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, expr: snap_expr.as_deref() }; + // The frame (or snapshot) through Show Channel and exposure; ROI frames cover the region. + vt::draw_frame(app, &ctx, &painter, comp_rect, cid, &ectx); painter.rect_stroke(comp_rect, 0.0, Stroke::new(1.0, Color32::from_black_alpha(160)), StrokeKind::Outside); app.auto.add("viewer.comp", comp_rect, &comp_name); app.auto.add("viewer.area", area, "Composition viewer"); @@ -452,6 +453,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } + vt::proportional_grid(app, &painter, comp_rect); + // Comp guides (View ▸ Show Guides) and the region of interest. if app.ui.viewer.guides { let [r, gg, b] = hex_rgb(&app.session.prefs.grids.guide_color).unwrap_or([0x3c, 0xc8, 0xf0]); @@ -469,110 +472,42 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if let Some([rx, ry, rw, rh]) = app.session.state.region_of_interest { let r = Rect::from_min_size(comp_rect.min + vec2(rx as f32 * zoom, ry as f32 * zoom), vec2(rw as f32 * zoom, rh as f32 * zoom)); painter.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::WHITE), StrokeKind::Middle); + for c in [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()] { + painter.rect_filled(Rect::from_center_size(c, vec2(5.0, 5.0)), 0.0, Color32::WHITE); + } + app.auto.add("viewer.regionOfInterest", r, "Region of interest"); } // Overlays + interaction. - 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, expr: snap_expr.as_deref() }; let selected = app.session.state.selected_layers.clone(); let mut handle_hits: Vec<(LayerId, usize, Pos2)> = Vec::new(); let mut vertex_hits: Vec = Vec::new(); + let mut key_hits: Vec = Vec::new(); + let mut paths: Vec = Vec::new(); let sel_vertices = app.session.state.selected_vertices.clone(); - // Pen path in progress ends when the tool changes or its mask is gone. - if app.ui.tool != Tool::Pen || ui.input(|i| i.key_pressed(egui::Key::Escape) || i.key_pressed(egui::Key::Enter)) { + // Pen path in progress ends when the tool changes or its mask is gone; Esc / Enter also end + // free transform. + let finish = ui.input(|i| i.key_pressed(egui::Key::Escape) || i.key_pressed(egui::Key::Enter)) && !ctx.egui_wants_keyboard_input(); + if app.ui.tool != Tool::Pen || finish { ui.data_mut(|d| d.remove::<(LayerId, u64)>(pen_id())); } + if finish || app.ui.tool != Tool::Selection { + ov::end_free_transform(&ctx); + } if app.ui.viewer.show_layer_controls { // Settings ▸ General ▸ Path Point and Handle Size; Appearance ▸ Use Label Color for // Layer Handles and Paths. let hs = app.session.prefs.general.path_point_size as f32 + 2.0; - let vs = app.session.prefs.general.path_point_size as f32 + 1.0; let label_handles = app.session.prefs.appearance.use_label_color_for_handles; for l in comp.layers.iter().filter(|l| selected.contains(&l.id) && l.is_active_at(time)) { let col = if label_handles { t.label(l.label) } else { t.accent }; - // Motion path of animated position. - if let Some(pos) = l.transform().and_then(|tr| tr.get("position")) - && pos.keys.len() > 1 - && l.parent.is_none() - { - let lt0 = pos.keys.first().map(|k| k.time).unwrap_or(Tick::ZERO); - let lt1 = pos.keys.last().map(|k| k.time).unwrap_or(Tick::ZERO); - let fd = comp.frame_duration(); - let mut pts = vec![]; - let mut lt = lt0; - while lt <= lt1 { - let v = pos.value_at(lt).as_vec2(); - pts.push(map.to_screen(v)); - lt += fd; - } - painter.add(egui::Shape::line(pts.clone(), Stroke::new(1.0, col.gamma_multiply(0.8)))); - for q in pts.iter().step_by(1) { - painter.circle_filled(*q, 1.2, col.gamma_multiply(0.8)); - } - for k in &pos.keys { - let v = k.value.as_vec2(); - let s = map.to_screen(v); - painter.rect_filled(Rect::from_center_size(s, vec2(6.0, 6.0)), 0.0, col); - } - } - // Masks. - if app.ui.viewer.show_masks - && let Some(masks) = l.masks() - { - let (m, _) = l2c(&ectx, l); - for g in masks.groups() { - let GroupKind::Mask { color, locked, .. } = g.kind else { continue }; - if locked && app.session.state.hide_locked_masks { - continue; - } - let Some(path) = g.get("path").map(|pr| ectx.value(l, pr)) else { continue }; - let Some(sp) = path.as_path() else { continue }; - let k = effectcraft_engine::render::kurbo_path(sp); - let mut pts: Vec = vec![]; - kurbo::flatten(k.iter(), 0.5, |el| match el { - kurbo::PathEl::MoveTo(q) | kurbo::PathEl::LineTo(q) => { - let c = m.apply(gv2(q.x, q.y)); - pts.push(map.to_screen([c.x, c.y])); - } - _ => {} - }); - if sp.closed && !pts.is_empty() { - pts.push(pts[0]); - } - let mc = Color32::from_rgb(color[0], color[1], color[2]); - painter.add(egui::Shape::line(pts, Stroke::new(1.0, mc))); - let scr = |p: [f64; 2]| { - let c = m.apply(gv2(p[0], p[1])); - map.to_screen([c.x, c.y]) - }; - for (i, v) in sp.vertices.iter().enumerate() { - let s = scr(*v); - let selected = sel_vertices.iter().any(|x| x.layer == l.id && x.mask == g.uid && x.index == i); - // Selected vertices: filled with their tangent handles; others hollow. - if selected { - for (out, tg) in [(false, sp.in_tangents.get(i)), (true, sp.out_tangents.get(i))] { - let Some(tg) = tg.filter(|t| t[0] != 0.0 || t[1] != 0.0) else { continue }; - let h = scr([v[0] + tg[0], v[1] + tg[1]]); - painter.line_segment([s, h], Stroke::new(1.0, mc)); - painter.circle_filled(h, 3.0, mc); - vertex_hits.push(VertexHit { layer: l.id, mask: g.uid, index: i, pos: h, tangent: Some(out), vertex: *v, l2c: m }); - } - painter.rect_filled(Rect::from_center_size(s, vec2(vs, vs)), 0.0, mc); - } else { - painter.rect_filled(Rect::from_center_size(s, vec2(vs, vs)), 0.0, Color32::from_black_alpha(160)); - painter.rect_stroke(Rect::from_center_size(s, vec2(vs, vs)), 0.0, Stroke::new(1.0, mc), StrokeKind::Inside); - } - app.auto.add(&format!("viewer.mask.{}.vertex.{i}", g.uid), Rect::from_center_size(s, vec2(8.0, 8.0)), &g.name); - vertex_hits.push(VertexHit { layer: l.id, mask: g.uid, index: i, pos: s, tangent: None, vertex: *v, l2c: m }); - } - // Pen rubber band from the last vertex to the pointer. - if ui.data(|d| d.get_temp::<(LayerId, u64)>(pen_id())) == Some((l.id, g.uid)) - && let (Some(last), Some(hp)) = (sp.vertices.last(), ui.input(|i| i.pointer.hover_pos())) - { - painter.line_segment([scr(*last), hp], Stroke::new(1.0, mc.gamma_multiply(0.6))); - } - } + // Motion path of animated position (keyframes and spatial tangents are draggable). + ov::motion_path(app, &painter, &map, &ectx, l, col, &mut key_hits); + // Masks and shape paths. + if app.ui.viewer.show_masks { + let pen = ui.data(|d| d.get_temp::<(LayerId, u64)>(pen_id())); + let hover = ui.input(|i| i.pointer.hover_pos()); + ov::draw_paths(app, &painter, &map, &ectx, l, col, &sel_vertices, pen, hover, &mut vertex_hits, &mut paths); } let Some((m, q, _)) = layer_quad(&ectx, l) else { // Cameras, lights, empty layers: just the anchor. @@ -635,6 +570,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { track_hits = super::tracker::draw_overlay(app, &painter, &map, &m, layer, tu, time); } + // Free transform box of a path (double-click a path with the Selection tool). + let ft = ov::draw_free_transform(app, &ctx, &painter, &map, &paths); + // Cameras and lights (wireframes) and the 3D view name. if comp.has_3d() { draw_rigs(app, &painter, &map, &ectx, &selected); @@ -677,7 +615,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { Tool::Orbit => egui::CursorIcon::Alias, Tool::PanCamera => egui::CursorIcon::Move, Tool::Dolly => egui::CursorIcon::ResizeVertical, - t if t.is_shape() || t == Tool::Pen => egui::CursorIcon::Crosshair, + t if t.is_shape() || t.is_pen() => egui::CursorIcon::Crosshair, + _ if app.ui.viewer.roi_draw => egui::CursorIcon::Crosshair, t if t.puppet_kind().is_some() => { if pin_hits.iter().any(|h| h.pos.distance(hp) < 8.0) { egui::CursorIcon::Move @@ -686,8 +625,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } _ => { - if super::tracker::hit_at(&track_hits, hp).is_some() { + if super::tracker::hit_at(&track_hits, hp).is_some() || key_hits.iter().any(|h| h.pos.distance(hp) < 6.0) { egui::CursorIcon::Move + } else if let Some((_, vertical)) = vt::guide_at(app, &comp, &map, hp) { + if vertical { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::ResizeVertical } } else if handle_hits.iter().any(|(_, _, h)| h.distance(hp) < HANDLE) { egui::CursorIcon::ResizeNwSe } else { @@ -711,6 +652,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } else { app.pointer_comp = None; } + app.ui.viewer.interacting = resp.dragged(); let mods = ui.input(|i| i.modifiers); let space_pan = ui.input(|i| i.key_down(egui::Key::Space)) && !ctx.egui_wants_keyboard_input(); // Pen tool: each press places a vertex (dragging pulls Bezier tangents); pressing on the @@ -721,7 +663,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { && ui.input(|i| i.pointer.primary_pressed()) && let Some(pos) = ui.input(|i| i.pointer.press_origin()) { - pen_press(app, ui, &ectx, &map, &vertex_hits, pos); + pen_press(app, ui, &ectx, &map, &paths, pos, mods); } // A pen click (press + release without a drag) ends its tangent gesture on release. if ui.input(|i| i.pointer.primary_released()) @@ -744,6 +686,26 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let tool = if space_pan || resp.dragged_by(egui::PointerButton::Middle) { Tool::Hand } else { app.ui.tool }; let g = match tool { Tool::Hand => Some(Gesture::Pan { start_pan: app.ui.viewer.pan }), + // Region of Interest armed: the drag draws it. + _ if app.ui.viewer.roi_draw => Some(Gesture::Overlay(ov::Drag::Roi { start: map.to_comp(press) })), + Tool::Selection if ft.as_ref().and_then(|f| ov::begin_ft_drag(f, press, &map)).is_some() => { + ft.as_ref().and_then(|f| ov::begin_ft_drag(f, press, &map)).map(Gesture::Overlay) + } + Tool::Selection if key_hits.iter().any(|h| h.pos.distance(press) < 6.0) => { + ov::begin_key_drag(app, &key_hits, press, map.to_comp(press), mods.shift).map(Gesture::Overlay) + } + Tool::Selection if vt::guide_at(app, &comp, &map, press).is_some() => { + vt::guide_at(app, &comp, &map, press).map(|(index, vertical)| Gesture::Overlay(ov::Drag::Guide { index, vertical })) + } + // Alt-drag a vertex with the Selection tool (or drag one with Convert Vertex): pull + // new tangents out of it. + Tool::Selection | Tool::PenConvert if (mods.alt || tool == Tool::PenConvert) && vertex_at(press, false).is_some() => { + vertex_at(press, false).map(|h| { + let inv = h.l2c.inverse().unwrap_or(Mat3::IDENTITY); + Gesture::Pen { layer: h.layer, mask: h.mask, index: h.index, vertex: h.vertex, inv, press } + }) + } + Tool::MaskFeather => ov::begin_feather(app, &ectx, &paths, &map, press).map(Gesture::Overlay), Tool::Rotate => pick(app, &ectx, cpt, mods.shift).map(|l| { let layer = comp.layer(l).cloned(); let center = layer @@ -797,7 +759,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if !selected { let _ = app.session.execute("mask.selectVertices", json!({"vertices": [me], "add": mods.shift})); } - Gesture::Vertices { last: cpt, inv: h.l2c.inverse().unwrap_or(Mat3::IDENTITY) } + let src = h.l2c.apply(gv2(h.vertex[0], h.vertex[1])); + Gesture::Vertices { + start: map.to_comp(press), + src: [src.x, src.y], + applied: [0.0; 2], + inv: h.l2c.inverse().unwrap_or(Mat3::IDENTITY), + layer: h.layer, + } }), Tool::Selection => { if let Some((lid, hi, _)) = handle_hits.iter().find(|(_, _, h)| h.distance(pos) < HANDLE + 2.0).cloned() { @@ -825,10 +794,22 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { Some((l.id, p0, ax, ay)) }) .collect(); - let _ = l; - (!layers.is_empty()).then_some(Gesture::Move { layers, start: cpt }) + // Snap the grabbed layer's feature nearest the pointer (AE's snap handle). + let snap_src = comp.layer(l).map(|layer| effectcraft_engine::viewer::layer_features(&ectx, layer)).and_then(|f| { + let c = map.to_comp(press); + f.into_iter().min_by(|a, b| { + let da = (a[0] - c[0]).powi(2) + (a[1] - c[1]).powi(2); + let db = (b[0] - c[0]).powi(2) + (b[1] - c[1]).powi(2); + da.total_cmp(&db) + }) + }); + (!layers.is_empty()).then_some(Gesture::Move { layers, start: map.to_comp(press), snap_src }) } else { - let _ = app.session.execute("edit.deselectAll", json!({})); + // With path vertices on screen the marquee selects vertices (layers stay + // selected); otherwise it starts from a clean selection. + if !vertex_hits.iter().any(|h| h.tangent.is_none()) { + let _ = app.session.execute("edit.deselectAll", json!({})); + } Some(Gesture::Marquee { start: pos }) } } @@ -847,11 +828,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let d = resp.total_drag_delta().unwrap_or_default(); app.ui.viewer.pan = [start_pan[0] + d.x, start_pan[1] + d.y]; } - Gesture::Move { layers, start } => { + Gesture::Move { layers, start, snap_src } => { let mut d = [cpt[0] - start[0], cpt[1] - start[1]]; if mods.shift { if d[0].abs() > d[1].abs() { d[1] = 0.0 } else { d[0] = 0.0 } } + if let Some(src) = snap_src { + let ids: Vec = layers.iter().map(|x| x.0).collect(); + let c = vt::snap(app, &ctx, &ectx, &map, &ids, &[[src[0] + d[0], src[1] + d[1]]], mods); + d = [d[0] + c[0], d[1] + c[1]]; + } for (lid, p0, ax, ay) in layers { let v = json!([p0[0] + ax[0] * d[0] + ay[0] * d[1], p0[1] + ax[1] * d[0] + ay[1] * d[1], p0[2] + ax[2] * d[0] + ay[2] * d[1]]); let _ = app.session.execute("prop.set", json!({"layer": lid.0, "path": "transform/position", "value": v, "merge": merge})); @@ -940,13 +926,33 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let r = Rect::from_two_pos(a, pos); painter.rect_stroke(r, 0.0, Stroke::new(1.0, t.accent), StrokeKind::Middle); } - Gesture::Vertices { last, inv } => { - let d = inv.apply_vec(gv2(cpt[0] - last[0], cpt[1] - last[1])); + 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]]; + 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])); if d.x != 0.0 || d.y != 0.0 { let _ = app.session.execute("mask.moveVertices", json!({"delta": [d.x, d.y], "merge": merge})); } - ui.data_mut(|dd| dd.insert_temp(gid, Gesture::Vertices { last: cpt, inv })); + ui.data_mut(|dd| dd.insert_temp(gid, Gesture::Vertices { start, src, applied: total, inv, layer })); } + Gesture::Overlay(d) => match d { + ov::Drag::Guide { index, vertical } => { + let pos_v = if vertical { cpt[0] } else { cpt[1] }.round(); + let _ = app.session.execute("view.moveGuide", json!({"index": index, "position": pos_v, "merge": merge})); + } + ov::Drag::Roi { start } => { + let r = Rect::from_two_pos(map.to_screen(start), pos); + painter.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::WHITE), StrokeKind::Middle); + } + d => { + let mut snapper = |a: &EffectcraftApp, p: [f64; 2], l: LayerId| vt::snap(a, &ctx, &ectx, &map, &[l], &[p], mods); + if let Some(nd) = ov::update(app, &d, cpt, pos, mods, &merge, zoom, &mut snapper) { + ui.data_mut(|dd| dd.insert_temp(gid, Gesture::Overlay(nd))); + } + } + }, Gesture::Tangent { layer, mask, index, out, vertex, inv } => { let lp = inv.apply(gv2(cpt[0], cpt[1])); let t = [lp.x - vertex[0], lp.y - vertex[1]]; @@ -993,6 +999,30 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let end = map.to_comp(pos); create_shape(app, tool, start, end, mods.shift); } + Gesture::Overlay(ov::Drag::Roi { start }) => { + let end = map.to_comp(pos); + let (x0, y0) = (start[0].min(end[0]).max(0.0), start[1].min(end[1]).max(0.0)); + let (x1, y1) = (start[0].max(end[0]).min(comp.width as f64), start[1].max(end[1]).min(comp.height as f64)); + if x1 - x0 >= 2.0 && y1 - y0 >= 2.0 { + let rect = [x0.round(), y0.round(), (x1 - x0).round(), (y1 - y0).round()]; + let _ = app.session.execute("view.setRegionOfInterest", json!({"rect": rect})); + } + app.ui.viewer.roi_draw = false; + } + // A guide dragged off the image (back to the rulers) is removed. + Gesture::Overlay(ov::Drag::Guide { index, .. }) if !area.contains(pos) || !comp_rect.expand(40.0).contains(pos) => { + let _ = app.session.execute("view.removeGuide", json!({"index": index})); + } + Gesture::Marquee { start } if vertex_hits.iter().any(|h| h.tangent.is_none() && Rect::from_two_pos(start, pos).contains(h.pos)) => { + // Marquee over path vertices selects them. + let r = Rect::from_two_pos(start, pos); + let v: Vec = vertex_hits + .iter() + .filter(|h| h.tangent.is_none() && r.contains(h.pos)) + .map(|h| json!({"layer": h.layer.0, "mask": h.mask, "index": h.index})) + .collect(); + let _ = app.session.execute("mask.selectVertices", json!({"vertices": v, "add": mods.shift})); + } Gesture::Marquee { start } => { let a = map.to_comp(start); let b = map.to_comp(pos); @@ -1031,7 +1061,31 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.ui.viewer.pan = [pan.x, pan.y]; } Tool::Type | Tool::TypeVertical => {} - Tool::Pen => {} + Tool::Pen | Tool::MaskFeather => {} + Tool::PenAdd => { + if let Some((l, m, seg, tt)) = ov::segment_at(&paths, &map, pos, 6.0) { + let _ = app.session.execute("mask.insertVertex", json!({"layer": l.0, "mask": m, "segment": seg, "t": tt})); + } + } + Tool::PenDelete => { + if let Some(h) = vertex_at(pos, false) { + let _ = app.session.execute("mask.deleteVertices", json!({"vertices": [{"layer": h.layer.0, "mask": h.mask, "index": h.index}]})); + } + } + Tool::PenConvert => { + if let Some(h) = vertex_at(pos, false) { + let _ = app.session.execute("mask.convertVertex", json!({"layer": h.layer.0, "mask": h.mask, "index": h.index})); + } + } + Tool::Selection if mods.alt && vertex_at(pos, false).is_some() => { + if let Some(h) = vertex_at(pos, false) { + let _ = app.session.execute("mask.convertVertex", json!({"layer": h.layer.0, "mask": h.mask, "index": h.index})); + } + } + Tool::Selection if ft.is_some() && ft.as_ref().and_then(|f| ov::begin_ft_drag(f, pos, &map)).is_none() => { + // Clicking away from the free-transform box ends it. + ov::end_free_transform(&ctx); + } t if t.puppet_kind().is_some() => { if pin_hits.iter().all(|h| h.pos.distance(pos) >= 8.0) && let Some(l) = pick(app, &ectx, cpt, false).and_then(|l| comp.layer(l)) @@ -1059,7 +1113,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } if resp.double_clicked() && !text_owns - && app.ui.tool != Tool::Pen + && app.ui.tool == Tool::Selection + && let Some(pos) = resp.interact_pointer_pos() + && let Some((l, m)) = vertex_at(pos, false).map(|h| (h.layer, h.mask)).or_else(|| ov::segment_at(&paths, &map, pos, 6.0).map(|s| (s.0, s.1))) + { + // Double-click a path: free transform its points (Layer ▸ Mask and Shape Path ▸ Free + // Transform Points). + ov::begin_free_transform(&ctx, l, m); + } else if resp.double_clicked() + && !text_owns + && !app.ui.tool.is_pen() && let Some(pos) = resp.interact_pointer_pos() { let cpt = map.to_comp(pos); @@ -1079,7 +1142,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { painter.text(r.center(), Align2::CENTER_CENTER, "Caching frames…", Tokens::ui(11.5), t.cache_green); } - bottom_bar(app, ui, bar, zoom, fit, time, &comp); + vt::draw_snap(&ctx, &painter, &map, &ectx); + vt::rulers(app, ui, &map, outer, area); + vt::bottom_bar(app, ui, bar, zoom, fit, time, &comp); } /// Parent-space position change per comp pixel of drag (x and y) for a layer: 2D layers map @@ -1175,49 +1240,94 @@ fn draw_rigs(app: &mut EffectcraftApp, painter: &egui::Painter, map: &ViewerMap, } } -/// Pen tool press at `pos`: start a mask on the selected layer, add a vertex, or close the path. -fn pen_press(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, map: &ViewerMap, hits: &[VertexHit], pos: Pos2) { +/// Pen tool press at `pos`: continue the path in progress (pressing its first vertex closes it), +/// add a vertex on a visible path's segment, or start a new path: a mask on the selected +/// footage/solid layer, a shape path (Shape n group with Fill and Stroke) on the selected shape +/// layer, or a new shape layer when nothing is selected. The placed point snaps. +fn pen_press(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, map: &ViewerMap, paths: &[ov::PathInfo], pos: Pos2, mods: egui::Modifiers) { + use effectcraft_engine::project::LayerSource; let gid = egui::Id::new("viewer-gesture"); let current: Option<(LayerId, u64)> = ui.data(|d| d.get_temp(pen_id())); - let target = current.map(|c| c.0).or_else(|| { - app.session.state.selected_layers.iter().copied().find(|id| ectx.comp.layer(*id).is_some_and(|l| l.masks().is_some() && !l.switches.locked)) - }); - let Some(lid) = target else { - app.ui.status = "Select a layer to draw a mask with the Pen tool".into(); - return; - }; - let Some(layer) = ectx.comp.layer(lid) else { return }; - let l2c = l2c(ectx, layer).0; - let Some(inv) = l2c.inverse() else { return }; - let c = map.to_comp(pos); - let lp = inv.apply(gv2(c[0], c[1])); - let lp = [lp.x, lp.y]; - let still_there = current.is_some_and(|(l, m)| layer.masks().is_some_and(|ms| ms.find_group(m).is_some()) && l == lid); - if let (true, Some((_, mask))) = (still_there, current) { - let count = hits.iter().filter(|h| h.layer == lid && h.mask == mask && h.tangent.is_none()).count(); - let first = hits.iter().find(|h| h.layer == lid && h.mask == mask && h.index == 0 && h.tangent.is_none()); - if count >= 2 && first.is_some_and(|f| f.pos.distance(pos) < 8.0) { - let _ = app.session.execute("mask.setClosed", json!({"layer": lid.0, "mask": mask, "closed": true})); + let c0 = map.to_comp(pos); + let corr = vt::snap(app, ui.ctx(), ectx, map, &[], &[c0], mods); + let c = [c0[0] + corr[0], c0[1] + corr[1]]; + if let Some((lid, uid)) = current + && let Some(p) = ov::path_of(ectx, lid, uid) + && let Some(inv) = p.m.inverse() + { + let n = p.sp.vertices.len(); + let first = p.sp.vertices.first().map(|v| { + let q = p.m.apply(gv2(v[0], v[1])); + map.to_screen([q.x, q.y]) + }); + if n >= 2 && first.is_some_and(|f| f.distance(pos) < 8.0) { + let _ = app.session.execute("mask.setClosed", json!({"layer": lid.0, "mask": uid, "closed": true})); ui.data_mut(|d| { d.remove::<(LayerId, u64)>(pen_id()); d.remove::(gid); }); return; } - if let Ok(v) = app.session.execute("mask.addVertex", json!({"layer": lid.0, "mask": mask, "point": lp})) { + let lp = inv.apply(gv2(c[0], c[1])); + let lp = [lp.x, lp.y]; + if let Ok(v) = app.session.execute("mask.addVertex", json!({"layer": lid.0, "mask": uid, "point": lp})) { let index = v.as_u64().unwrap_or(0) as usize; - ui.data_mut(|d| d.insert_temp(gid, Gesture::Pen { layer: lid, mask, index, vertex: lp, inv, press: pos })); + ui.data_mut(|d| d.insert_temp(gid, Gesture::Pen { layer: lid, mask: uid, index, vertex: lp, inv, press: pos })); } return; } - if let Ok(v) = app.session.execute("mask.new", json!({"layer": lid.0, "vertices": [lp]})) - && let Some(mask) = v["mask"].as_u64() - { - ui.data_mut(|d| { - d.insert_temp(pen_id(), (lid, mask)); - d.insert_temp(gid, Gesture::Pen { layer: lid, mask, index: 0, vertex: lp, inv, press: pos }); - }); + // On a visible path's segment the Pen adds a vertex there. + if let Some((l, m, seg, t)) = ov::segment_at(paths, map, pos, 5.0) { + let _ = app.session.execute("mask.insertVertex", json!({"layer": l.0, "mask": m, "segment": seg, "t": t})); + return; } + let target = app + .session + .state + .selected_layers + .iter() + .copied() + .find(|id| ectx.comp.layer(*id).is_some_and(|l| !l.switches.locked && (l.masks().is_some() || matches!(l.source, LayerSource::Shape)))); + let layer = target.and_then(|id| ectx.comp.layer(id)); + let (lid, uid, inv) = match layer { + Some(l) if !matches!(l.source, LayerSource::Shape) => { + let Some(inv) = l2c(ectx, l).0.inverse() else { return }; + let lp = inv.apply(gv2(c[0], c[1])); + let Ok(v) = app.session.execute("mask.new", json!({"layer": l.id.0, "vertices": [[lp.x, lp.y]]})) else { return }; + let Some(mask) = v["mask"].as_u64() else { return }; + (l.id, mask, inv) + } + Some(l) => { + let Some(inv) = l2c(ectx, l).0.inverse() else { return }; + let lp = inv.apply(gv2(c[0], c[1])); + let fill = app.ui.fill_color; + let stroke = app.ui.stroke_color; + let r = app.session.execute( + "shape.newPath", + json!({"layer": l.id.0, "vertices": [[lp.x, lp.y]], "space": "layer", "fill": [fill[0], fill[1], fill[2]], "stroke": [stroke[0], stroke[1], stroke[2]], "strokeWidth": app.ui.stroke_width}), + ); + let Some(path) = r.ok().and_then(|v| v["path"].as_u64()) else { return }; + (l.id, path, inv) + } + None => { + // A new shape layer at the comp centre (unrotated: comp → layer is a translation). + let fill = app.ui.fill_color; + let stroke = app.ui.stroke_color; + let r = app.session.execute( + "shape.newPath", + json!({"vertices": [c], "space": "comp", "fill": [fill[0], fill[1], fill[2]], "stroke": [stroke[0], stroke[1], stroke[2]], "strokeWidth": app.ui.stroke_width}), + ); + let Some(v) = r.ok() else { return }; + let (Some(l), Some(path)) = (v["layer"].as_u64(), v["path"].as_u64()) else { return }; + let inv = Mat3::translate(gv2(-(ectx.comp.width as f64) / 2.0, -(ectx.comp.height as f64) / 2.0)); + (LayerId(l), path, inv) + } + }; + let lp = inv.apply(gv2(c[0], c[1])); + ui.data_mut(|d| { + d.insert_temp(pen_id(), (lid, uid)); + d.insert_temp(gid, Gesture::Pen { layer: lid, mask: uid, index: 0, vertex: [lp.x, lp.y], inv, press: pos }); + }); } /// Topmost layer under a comp point (selects it; shift toggles). Returns the hit layer. @@ -1293,136 +1403,6 @@ fn create_shape(app: &mut EffectcraftApp, tool: Tool, a: [f64; 2], b: [f64; 2], ); } -fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, zoom: f32, fit: f32, time: Tick, comp: &effectcraft_engine::project::Comp) { - let t = app.tokens; - let p = ui.painter().clone(); - p.rect_filled(bar, 0.0, t.panel_bg); - p.line_segment([bar.left_top(), bar.right_top()], Stroke::new(1.0, t.separator)); - let cy = bar.center().y; - let mut x = bar.min.x + 8.0; - // Magnification. - let ppp = ui.ctx().pixels_per_point(); - let mag = if app.ui.viewer.zoom.is_none() { format!("Fit ({:.0}%)", fit * ppp * 100.0) } else { format!("{:.1}%", zoom * ppp * 100.0).replace(".0%", "%") }; - let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(92.0, 20.0)); - if widgets::dropdown(ui, r, &mag, &t, egui::Id::new("vw-mag")).clicked() { - widgets::open_popup(ui, egui::Id::new("vw-mag-pop")); - } - app.auto.add("viewer.magnification", r, "Magnification"); - let opts: Vec = ["Fit", "Fit up to 100%", "-", "1.5%", "3.1%", "6.25%", "12.5%", "25%", "33.3%", "50%", "100%", "200%", "400%", "800%", "1600%"] - .iter() - .map(|s| s.to_string()) - .collect(); - if let Some(i) = widgets::popup_menu(ui, egui::Id::new("vw-mag-pop"), r.left_top() - vec2(0.0, 380.0), &opts, None) { - let o = &opts[i]; - app.ui.viewer.pan = [0.0, 0.0]; - app.ui.viewer.zoom = match o.as_str() { - "Fit" => None, - "Fit up to 100%" => Some(fit.min(1.0 / ppp)), - s => s.trim_end_matches('%').parse::().ok().map(|v| v / 100.0 / ppp), - }; - } - x = r.max.x + 6.0; - // Toggles. - let tog = |ui: &mut egui::Ui, auto: &mut crate::automation::Registry, x: &mut f32, icon: Icon, on: bool, id: &str, tip: &str| -> bool { - let r = Rect::from_min_size(pos2(*x, cy - 11.0), vec2(22.0, 22.0)); - let resp = widgets::icon_button(ui, r, icon, on, &t, egui::Id::new(id)).on_hover_text(tip); - auto.add(&format!("viewer.{id}"), r, tip); - *x += 24.0; - resp.clicked() - }; - if tog(ui, &mut app.auto, &mut x, Icon::Grid, app.ui.viewer.grid || app.ui.viewer.safe_margins, "grid", "Choose grid and guide options") { - if app.ui.viewer.safe_margins { - app.ui.viewer.safe_margins = false; - app.ui.viewer.grid = !app.ui.viewer.grid; - } else { - app.ui.viewer.safe_margins = true; - } - } - if tog(ui, &mut app.auto, &mut x, Icon::MaskVis, app.ui.viewer.show_masks, "masks", "Toggle Mask and Shape Path Visibility") { - app.ui.viewer.show_masks = !app.ui.viewer.show_masks; - } - // Timecode. - let tc = crate::panels::timecode(&app.session, comp, time); - let g = p.layout_no_wrap(tc, Tokens::mono(12.0), t.timecode); - let tr = Rect::from_min_size(pos2(x + 4.0, cy - 9.0), g.size() + vec2(8.0, 4.0)); - p.galley(pos2(tr.min.x + 4.0, tr.min.y + 2.0), g, t.timecode); - app.auto.add("viewer.timecode", tr, "Current time"); - x = tr.max.x + 6.0; - if tog(ui, &mut app.auto, &mut x, Icon::Snapshot, false, "snapshot", "Take Snapshot") { - app.ui.status = "Snapshot taken".into(); - } - let _ = tog(ui, &mut app.auto, &mut x, Icon::ShowSnapshot, false, "showSnapshot", "Show Snapshot"); - let _ = tog(ui, &mut app.auto, &mut x, Icon::Channel, app.ui.viewer.channel != "RGB", "channel", "Show Channel and Color Management Settings"); - // Resolution. - let r = Rect::from_min_size(pos2(x + 2.0, cy - 10.0), vec2(78.0, 20.0)); - let rl = if app.ui.viewer.res == Resolution::Auto { - let s = app.viewer_scale(zoom, ppp); - format!( - "Auto ({})", - if s >= 1.0 { - "Full" - } else if s >= 0.5 { - "Half" - } else if s >= 0.33 { - "Third" - } else { - "Quarter" - } - ) - } else { - app.ui.viewer.res.label().to_string() - }; - if widgets::dropdown(ui, r, &rl, &t, egui::Id::new("vw-res")).clicked() { - widgets::open_popup(ui, egui::Id::new("vw-res-pop")); - } - app.auto.add("viewer.resolution", r, "Resolution/Down Sample Factor"); - let ro: Vec = Resolution::ALL.iter().map(|r| r.label().to_string()).collect(); - if let Some(i) = - widgets::popup_menu(ui, egui::Id::new("vw-res-pop"), r.left_top() - vec2(0.0, 140.0), &ro, Resolution::ALL.iter().position(|x| *x == app.ui.viewer.res)) - { - app.ui.viewer.res = Resolution::ALL[i]; - } - x = r.max.x + 6.0; - let has_roi = app.session.state.region_of_interest.is_some(); - if tog(ui, &mut app.auto, &mut x, Icon::Region, has_roi, "roi", "Region of Interest") { - // Toggle a centred region of interest (Composition ▸ Crop Comp to Region of Interest). - let rect = if has_roi { - serde_json::Value::Null - } else { - json!([comp.width as f64 / 4.0, comp.height as f64 / 4.0, comp.width as f64 / 2.0, comp.height as f64 / 2.0]) - }; - let _ = app.session.execute("view.setRegionOfInterest", json!({"rect": rect})); - } - if tog(ui, &mut app.auto, &mut x, Icon::Checker, app.ui.viewer.transparency_grid, "transparency", "Toggle Transparency Grid") { - app.ui.viewer.transparency_grid = !app.ui.viewer.transparency_grid; - } - if tog(ui, &mut app.auto, &mut x, Icon::Sparkle, app.ui.viewer.fast_preview, "fastPreviews", "Fast Previews") { - app.ui.viewer.fast_preview = !app.ui.viewer.fast_preview; - } - // 3D renderer + view. - if comp.has_3d() { - let r = Rect::from_min_size(pos2(x + 2.0, cy - 10.0), vec2(96.0, 20.0)); - let _ = widgets::dropdown(ui, r, "Classic 3D", &t, egui::Id::new("vw-3d")); - app.auto.add("viewer.renderer3d", r, "3D Renderer"); - x = r.max.x + 4.0; - let cid = app.session.active_comp_id(); - let cur = cid.and_then(|c| app.session.state.views3d.get(&c)).map(|v| v.current).unwrap_or_default(); - let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(118.0, 20.0)); - if widgets::dropdown(ui, r, cur.label(), &t, egui::Id::new("vw-cam")).clicked() { - widgets::open_popup(ui, egui::Id::new("vw-cam-pop")); - } - app.auto.add("viewer.view3d", r, "3D View"); - let opts: Vec = View3D::ALL.iter().map(|v| v.label().to_string()).collect(); - let at = r.left_top() - vec2(0.0, 22.0 * opts.len() as f32 + 12.0); - if let Some(i) = widgets::popup_menu(ui, egui::Id::new("vw-cam-pop"), at, &opts, View3D::ALL.iter().position(|v| *v == cur)) { - let _ = app.session.execute("view.set3DView", json!({"view": View3D::ALL[i].id()})); - } - } - // Right: render time. - let ms = app.frames.last_ms.lock().map(|v| *v).unwrap_or(0.0); - p.text(pos2(bar.max.x - 10.0, cy), Align2::RIGHT_CENTER, format!("{ms:.0} ms"), Tokens::ui(11.0), t.text_faint); -} - fn empty_state(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; let p = ui.painter(); diff --git a/crates/ui-egui/src/panels/viewer_overlays.rs b/crates/ui-egui/src/panels/viewer_overlays.rs new file mode 100644 index 00000000..08cc32b5 --- /dev/null +++ b/crates/ui-egui/src/panels/viewer_overlays.rs @@ -0,0 +1,528 @@ +//! Composition viewer overlays and their drags: mask and shape paths (vertices, tangents, +//! segments for Add Vertex), position motion paths with keyframe dots and spatial Bezier +//! handles, the path free-transform box and the Mask Feather tool. +//! +//! Every edit is an engine command (`mask.*`, `path.freeTransform`, `keys.set`, +//! `keys.setSpatialTangents`, `prop.set`). + +use effectcraft_engine::geom::{Mat3, vec2 as gv2}; +use effectcraft_engine::keyframe::ShapePath; +use effectcraft_engine::project::{GroupKind, Layer, LayerId}; +use effectcraft_engine::render::EvalCtx; +use effectcraft_engine::time::Tick; +use effectcraft_engine::{KeyRef, VertexRef}; +use egui::{Color32, Pos2, Rect, Stroke, StrokeKind, vec2}; +use serde_json::json; + +use super::viewer::ViewerMap; +use crate::EffectcraftApp; + +/// A vertex or tangent handle drawn this frame (masks and shape paths alike; `mask` is the path +/// group's uid). +#[derive(Clone, Copy)] +pub(crate) struct VertexHit { + pub layer: LayerId, + pub mask: u64, + pub index: usize, + pub pos: Pos2, + /// None = the vertex itself, Some(out?) = a tangent handle. + pub tangent: Option, + /// Path-space vertex position and path → comp matrix. + pub vertex: [f64; 2], + pub l2c: Mat3, +} + +/// A path drawn this frame (for segment hits and the free-transform box). +#[derive(Clone)] +pub(crate) struct PathInfo { + pub layer: LayerId, + pub uid: u64, + /// Path space → comp. + pub m: Mat3, + pub sp: ShapePath, + pub is_mask: bool, +} + +fn cubic(p0: [f64; 2], p1: [f64; 2], p2: [f64; 2], p3: [f64; 2], t: f64) -> [f64; 2] { + let u = 1.0 - t; + let f = |k: usize| u * u * u * p0[k] + 3.0 * u * u * t * p1[k] + 3.0 * u * t * t * p2[k] + t * t * t * p3[k]; + [f(0), f(1)] +} + +/// Control points of segment `i` (vertex i → next) in path space. +pub(crate) fn segment(sp: &ShapePath, i: usize) -> Option<[[f64; 2]; 4]> { + let n = sp.vertices.len(); + let j = if i + 1 < n { + i + 1 + } else if sp.closed && n > 1 { + 0 + } else { + return None; + }; + let (a, d) = (sp.vertices[i], sp.vertices[j]); + let to = sp.out_tangents.get(i).copied().unwrap_or_default(); + let ti = sp.in_tangents.get(j).copied().unwrap_or_default(); + Some([a, [a[0] + to[0], a[1] + to[1]], [d[0] + ti[0], d[1] + ti[1]], d]) +} + +/// The path segment under a screen point: (layer, path uid, segment, t). +pub(crate) fn segment_at(paths: &[PathInfo], map: &ViewerMap, pos: Pos2, tol: f32) -> Option<(LayerId, u64, usize, f64)> { + let mut best: Option<(f32, (LayerId, u64, usize, f64))> = None; + for p in paths { + for i in 0..p.sp.vertices.len() { + let Some(c) = segment(&p.sp, i) else { continue }; + for k in 1..48 { + let t = k as f64 / 48.0; + let q = cubic(c[0], c[1], c[2], c[3], t); + let cq = p.m.apply(gv2(q[0], q[1])); + let d = map.to_screen([cq.x, cq.y]).distance(pos); + if d < tol && best.as_ref().is_none_or(|b| d < b.0) { + best = Some((d, (p.layer, p.uid, i, t))); + } + } + } + } + best.map(|b| b.1) +} + +/// A mask or shape path of a layer by group uid, evaluated at the context time. +pub(crate) fn path_of(ectx: &EvalCtx, layer: LayerId, uid: u64) -> Option { + let l = ectx.comp.layer(layer)?; + let g = l.props.find_group(uid)?; + let sp = g.get("path").map(|pr| ectx.value(l, pr)).and_then(|v| v.as_path().cloned())?; + let (m, _) = super::viewer::l2c(ectx, l); + let is_mask = matches!(g.kind, GroupKind::Mask { .. }); + let m = if is_mask { m } else { m * effectcraft_engine::render::shapes::item_matrix(ectx, l, uid).unwrap_or(Mat3::IDENTITY) }; + Some(PathInfo { layer, uid, m, sp, is_mask }) +} + +fn flatten(sp: &ShapePath, m: &Mat3, map: &ViewerMap) -> Vec { + let k = effectcraft_engine::render::kurbo_path(sp); + let mut pts: Vec = vec![]; + kurbo::flatten(k.iter(), 0.5, |el| match el { + kurbo::PathEl::MoveTo(q) | kurbo::PathEl::LineTo(q) => { + let c = m.apply(gv2(q.x, q.y)); + pts.push(map.to_screen([c.x, c.y])); + } + _ => {} + }); + if sp.closed && !pts.is_empty() { + pts.push(pts[0]); + } + pts +} + +/// Draw the masks and (on shape layers) shape paths of a selected layer with their vertices; +/// selected vertices show their tangent handles. Collects vertex hits and paths. +#[allow(clippy::too_many_arguments)] +pub(crate) fn draw_paths( + app: &mut EffectcraftApp, + painter: &egui::Painter, + map: &ViewerMap, + ectx: &EvalCtx, + l: &Layer, + label_col: Color32, + sel: &[VertexRef], + pen: Option<(LayerId, u64)>, + hover: Option, + hits: &mut Vec, + paths: &mut Vec, +) { + let vs = app.session.prefs.general.path_point_size as f32 + 1.0; + let (m, _) = super::viewer::l2c(ectx, l); + let mut list: Vec<(u64, String, ShapePath, Mat3, Color32, bool)> = vec![]; + if let Some(masks) = l.masks() { + for g in masks.groups() { + let GroupKind::Mask { color, locked, .. } = g.kind else { continue }; + if locked && app.session.state.hide_locked_masks { + continue; + } + let Some(sp) = g.get("path").map(|pr| ectx.value(l, pr)).and_then(|v| v.as_path().cloned()) else { continue }; + list.push((g.uid, g.name.clone(), sp, m, Color32::from_rgb(color[0], color[1], color[2]), true)); + } + } + for (uid, sp) in effectcraft_engine::viewer::shape_paths(ectx, l) { + let im = effectcraft_engine::render::shapes::item_matrix(ectx, l, uid).unwrap_or(Mat3::IDENTITY); + let name = l.props.find_group(uid).map(|g| g.name.clone()).unwrap_or_default(); + list.push((uid, name, sp, m * im, label_col, false)); + } + for (uid, name, sp, pm, mc, is_mask) in list { + painter.add(egui::Shape::line(flatten(&sp, &pm, map), Stroke::new(1.0, mc))); + let scr = |p: [f64; 2]| { + let c = pm.apply(gv2(p[0], p[1])); + map.to_screen([c.x, c.y]) + }; + for (i, v) in sp.vertices.iter().enumerate() { + let s = scr(*v); + let selected = sel.iter().any(|x| x.layer == l.id && x.mask == uid && x.index == i); + if selected { + for (out, tg) in [(false, sp.in_tangents.get(i)), (true, sp.out_tangents.get(i))] { + let Some(tg) = tg.filter(|t| t[0] != 0.0 || t[1] != 0.0) else { continue }; + let h = scr([v[0] + tg[0], v[1] + tg[1]]); + painter.line_segment([s, h], Stroke::new(1.0, mc)); + painter.circle_filled(h, 3.0, mc); + hits.push(VertexHit { layer: l.id, mask: uid, index: i, pos: h, tangent: Some(out), vertex: *v, l2c: pm }); + } + painter.rect_filled(Rect::from_center_size(s, vec2(vs, vs)), 0.0, mc); + } else { + painter.rect_filled(Rect::from_center_size(s, vec2(vs, vs)), 0.0, Color32::from_black_alpha(160)); + painter.rect_stroke(Rect::from_center_size(s, vec2(vs, vs)), 0.0, Stroke::new(1.0, mc), StrokeKind::Inside); + } + let kind = if is_mask { "mask" } else { "shapePath" }; + app.auto.add(&format!("viewer.{kind}.{uid}.vertex.{i}"), Rect::from_center_size(s, vec2(8.0, 8.0)), &name); + hits.push(VertexHit { layer: l.id, mask: uid, index: i, pos: s, tangent: None, vertex: *v, l2c: pm }); + } + // Pen rubber band from the last vertex to the pointer. + if pen == Some((l.id, uid)) + && let (Some(last), Some(hp)) = (sp.vertices.last(), hover) + { + painter.line_segment([scr(*last), hp], Stroke::new(1.0, mc.gamma_multiply(0.6))); + } + paths.push(PathInfo { layer: l.id, uid, m: pm, sp, is_mask }); + } +} + +// ---------------------------------------------------------------- motion paths + +/// A keyframe dot or spatial tangent handle on a motion path. +#[derive(Clone, Copy)] +pub(crate) struct KeyHit { + pub layer: LayerId, + pub prop: u64, + /// Layer time of the key. + pub time: Tick, + pub pos: Pos2, + /// None = the key, Some(out?) = a tangent handle. + pub tangent: Option, + pub value: [f64; 3], + /// Parent space → comp. + pub p2c: Mat3, +} + +/// Parent space → comp matrix of a 2D layer at the context time. +pub(crate) fn parent_to_comp(ectx: &EvalCtx, l: &Layer) -> Mat3 { + match l.parent.and_then(|p| ectx.comp.layer(p)) { + Some(p) => { + let w = ectx.world_matrix(p).0; + Mat3([[w[0][0], w[0][1], w[0][3]], [w[1][0], w[1][1], w[1][3]], [0.0, 0.0, 1.0]]) + } + None => Mat3::IDENTITY, + } +} + +/// Draw the position motion path of a selected 2D layer: frame dots along the path, keyframe +/// squares (filled when selected) and spatial Bezier tangent handles. Works through parents. +pub(crate) fn motion_path(app: &mut EffectcraftApp, painter: &egui::Painter, map: &ViewerMap, ectx: &EvalCtx, l: &Layer, col: Color32, hits: &mut Vec) { + let Some(pos) = l.transform().and_then(|tr| tr.get("position")) else { return }; + if pos.keys.len() < 2 || l.is_3d() { + return; + } + let p2c = parent_to_comp(ectx, l); + let to_scr = |v: [f64; 3]| { + let c = p2c.apply(gv2(v[0], v[1])); + map.to_screen([c.x, c.y]) + }; + let (lt0, lt1) = (pos.keys[0].time, pos.keys[pos.keys.len() - 1].time); + let fd = ectx.comp.frame_duration(); + let mut pts = vec![]; + let mut lt = lt0; + while lt <= lt1 && pts.len() < 20_000 { + pts.push(to_scr(pos.value_at(lt).as_vec3())); + lt += fd; + } + pts.push(to_scr(pos.value_at(lt1).as_vec3())); + let faint = col.gamma_multiply(0.8); + painter.add(egui::Shape::line(pts.clone(), Stroke::new(1.0, faint))); + for q in &pts { + painter.circle_filled(*q, 1.2, faint); + } + let sel = &app.session.state.selected_keys; + for (i, k) in pos.keys.iter().enumerate() { + let v = k.value.as_vec3(); + let s = to_scr(v); + let (tin, tout) = 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, 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); + } 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); + } + 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 }); + } +} + +// ---------------------------------------------------------------- free transform + +fn ft_id() -> egui::Id { + egui::Id::new("viewer-free-transform") +} + +/// Enter Free Transform on a path (double-click a path with the Selection tool). +pub(crate) fn begin_free_transform(ctx: &egui::Context, layer: LayerId, uid: u64) { + ctx.data_mut(|d| d.insert_temp(ft_id(), (layer, uid))); +} + +pub(crate) fn end_free_transform(ctx: &egui::Context) { + ctx.data_mut(|d| d.remove::<(LayerId, u64)>(ft_id())); +} + +pub(crate) fn free_transform_target(ctx: &egui::Context) -> Option<(LayerId, u64)> { + ctx.data(|d| d.get_temp(ft_id())) +} + +/// The free-transform box: path-space bounds of the transformed vertices, its handles on screen +/// (corners 0–3, edge midpoints 4–7) and the path → comp matrix. +pub(crate) struct FtBox { + pub layer: LayerId, + pub uid: u64, + pub b: [f64; 4], + pub m: Mat3, + pub handles: [Pos2; 8], + pub quad: [Pos2; 4], +} + +impl FtBox { + /// Path-space position of handle `i`. + pub fn handle_local(&self, i: usize) -> [f64; 2] { + let [x0, y0, x1, y1] = self.b; + let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0); + [[x0, y0], [x1, y0], [x1, y1], [x0, y1], [cx, y0], [x1, cy], [cx, y1], [x0, cy]][i] + } + pub fn center(&self) -> [f64; 2] { + [(self.b[0] + self.b[2]) / 2.0, (self.b[1] + self.b[3]) / 2.0] + } +} + +/// Draw the free-transform box of the target path (if any) and return it for hit tests. +pub(crate) fn draw_free_transform( + app: &mut EffectcraftApp, + ctx: &egui::Context, + painter: &egui::Painter, + map: &ViewerMap, + paths: &[PathInfo], +) -> Option { + let (layer, uid) = free_transform_target(ctx)?; + let Some(p) = paths.iter().find(|p| p.layer == layer && p.uid == uid) else { + end_free_transform(ctx); + return None; + }; + let sel: Vec = app.session.state.selected_vertices.iter().filter(|v| v.layer == layer && v.mask == uid).map(|v| v.index).collect(); + let idx: Vec = if sel.len() >= 2 { sel } else { (0..p.sp.vertices.len()).collect() }; + let (mut lo, mut hi) = ([f64::MAX; 2], [f64::MIN; 2]); + for &i in &idx { + let Some(v) = p.sp.vertices.get(i) else { continue }; + lo = [lo[0].min(v[0]), lo[1].min(v[1])]; + hi = [hi[0].max(v[0]), hi[1].max(v[1])]; + } + if lo[0] > hi[0] { + return None; + } + let b = [lo[0], lo[1], hi[0].max(lo[0] + 1e-3), hi[1].max(lo[1] + 1e-3)]; + let mut f = FtBox { layer, uid, b, m: p.m, handles: [Pos2::ZERO; 8], quad: [Pos2::ZERO; 4] }; + for i in 0..8 { + let q = f.handle_local(i); + let c = p.m.apply(gv2(q[0], q[1])); + f.handles[i] = map.to_screen([c.x, c.y]); + } + f.quad = [f.handles[0], f.handles[1], f.handles[2], f.handles[3]]; + let col = app.tokens.accent; + painter.add(egui::Shape::closed_line(f.quad.to_vec(), Stroke::new(1.0, col))); + for (i, h) in f.handles.iter().enumerate() { + let r = Rect::from_center_size(*h, vec2(7.0, 7.0)); + painter.rect_filled(r, 0.0, Color32::WHITE); + painter.rect_stroke(r, 0.0, Stroke::new(1.0, col), StrokeKind::Outside); + app.auto.add(&format!("viewer.freeTransform.handle.{i}"), r, "Free transform handle"); + } + let c = f.center(); + let cc = p.m.apply(gv2(c[0], c[1])); + let cs = map.to_screen([cc.x, cc.y]); + painter.circle_stroke(cs, 4.0, Stroke::new(1.0, col)); + Some(f) +} + +fn point_in_quad(p: Pos2, q: &[Pos2; 4]) -> bool { + let mut sign = 0.0f32; + for i in 0..4 { + let (a, b) = (q[i], q[(i + 1) % 4]); + let c = (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x); + if c.abs() < 1e-6 { + continue; + } + if sign == 0.0 { + sign = c.signum(); + } else if c.signum() != sign { + return false; + } + } + true +} + +// ---------------------------------------------------------------- drags + +/// Overlay drags owned by this module. +#[derive(Clone, Debug)] +pub(crate) enum Drag { + /// A motion-path keyframe: its value moves with the pointer (parent space). + Key { layer: LayerId, prop: u64, time: Tick, start: [f64; 3], press: [f64; 2], inv: Mat3 }, + /// A spatial tangent handle of a motion-path key. + KeyTangent { layer: LayerId, prop: u64, time: Tick, out: bool, key: [f64; 3], inv: Mat3 }, + /// Free transform: 0 move, 1 scale (anchor, axes), 2 rotate about `anchor`. + Ft { layer: LayerId, path: u64, mode: u8, anchor: [f64; 2], last: [f64; 2], inv: Mat3, axes: [bool; 2] }, + /// Mask Feather tool: drag away from the path to feather the mask. + Feather { layer: LayerId, prop: u64, start: [f64; 2], press: Pos2 }, + /// A guide dragged in the viewer. + Guide { index: usize, vertical: bool }, + /// Drawing the region of interest. + Roi { start: [f64; 2] }, +} + +/// Start a motion-path drag at `press` (keys before tangents), selecting the key. +pub(crate) fn begin_key_drag(app: &mut EffectcraftApp, hits: &[KeyHit], press: Pos2, cpt: [f64; 2], shift: bool) -> Option { + let h = hits + .iter() + .rev() + .filter(|h| h.tangent.is_some()) + .find(|h| h.pos.distance(press) < 6.0) + .or_else(|| hits.iter().rev().find(|h| h.tangent.is_none() && h.pos.distance(press) < 6.0))?; + let inv = h.p2c.inverse().unwrap_or(Mat3::IDENTITY); + match h.tangent { + Some(out) => Some(Drag::KeyTangent { layer: h.layer, prop: h.prop, time: h.time, out, key: h.value, inv }), + None => { + let k = KeyRef { layer: h.layer, prop: h.prop, time: h.time }; + if !app.session.state.selected_keys.contains(&k) || shift { + let _ = app.session.execute("keys.select", json!({"keys": [{"layer": h.layer.0, "prop": h.prop, "time": h.time.seconds()}], "add": shift})); + } + Some(Drag::Key { layer: h.layer, prop: h.prop, time: h.time, start: h.value, press: cpt, inv }) + } + } +} + +/// Start a free-transform drag on the box: a handle scales about the opposite one (corners both +/// axes, edges one), inside moves, just outside a corner rotates about the centre. +pub(crate) fn begin_ft_drag(f: &FtBox, press: Pos2, map: &ViewerMap) -> Option { + let inv = f.m.inverse()?; + let c = map.to_comp(press); + let lp = inv.apply(gv2(c[0], c[1])); + let lp = [lp.x, lp.y]; + if let Some(i) = (0..8).find(|i| f.handles[*i].distance(press) < 7.0) { + let opp = [2, 3, 0, 1, 6, 7, 4, 5][i]; + let axes = match i { + 4 | 6 => [false, true], + 5 | 7 => [true, false], + _ => [true, true], + }; + return Some(Drag::Ft { layer: f.layer, path: f.uid, mode: 1, anchor: f.handle_local(opp), last: lp, inv, axes }); + } + if point_in_quad(press, &f.quad) { + return Some(Drag::Ft { layer: f.layer, path: f.uid, mode: 0, anchor: f.center(), last: lp, inv, axes: [true, true] }); + } + if f.quad.iter().any(|q| q.distance(press) < 24.0) { + return Some(Drag::Ft { layer: f.layer, path: f.uid, mode: 2, anchor: f.center(), last: lp, inv, axes: [true, true] }); + } + None +} + +/// Mask Feather tool: start on a mask path. +pub(crate) fn begin_feather(app: &EffectcraftApp, ectx: &EvalCtx, paths: &[PathInfo], map: &ViewerMap, press: Pos2) -> Option { + let (layer, uid, _, _) = segment_at(&paths.iter().filter(|p| p.is_mask).cloned().collect::>(), map, press, 10.0)?; + let l = ectx.comp.layer(layer)?; + let pr = l.masks()?.find_group(uid)?.get("feather")?; + let _ = app; + Some(Drag::Feather { layer, prop: pr.uid, start: ectx.value(l, pr).as_vec2(), press }) +} + +/// Advance an overlay drag to the pointer (`cpt` comp, `pos` screen). `snap` corrects a comp +/// point for snapping. Returns the replacement drag state. +#[allow(clippy::too_many_arguments)] +pub(crate) fn update( + app: &mut EffectcraftApp, + d: &Drag, + cpt: [f64; 2], + pos: Pos2, + mods: egui::Modifiers, + merge: &str, + zoom: f32, + snap: &mut dyn FnMut(&EffectcraftApp, [f64; 2], LayerId) -> [f64; 2], +) -> Option { + match d.clone() { + Drag::Key { layer, prop, time, start, press, inv } => { + // Snap the key's on-screen position like a layer drag. + let start_c = inv.inverse().map(|m| m.apply(gv2(start[0], start[1]))).map(|v| [v.x, v.y]).unwrap_or([start[0], start[1]]); + let mut target = [start_c[0] + cpt[0] - press[0], start_c[1] + cpt[1] - press[1]]; + let s = snap(app, target, layer); + target = [target[0] + s[0], target[1] + s[1]]; + let p = inv.apply(gv2(target[0], target[1])); + let v = json!([p.x, p.y, start[2]]); + let r = app.session.execute("keys.set", json!({"layer": layer.0, "prop": prop, "time": time.seconds(), "value": v, "merge": merge})); + if let Err(e) = r { + app.ui.status = e.to_string(); + } + None + } + Drag::KeyTangent { layer, prop, time, out, key, inv } => { + let p = inv.apply(gv2(cpt[0], cpt[1])); + let tg = json!([p.x - key[0], p.y - key[1], 0.0]); + let mut q = json!({"layer": layer.0, "prop": prop, "time": time.seconds(), "break": mods.alt, "merge": merge}); + q[if out { "out" } else { "in" }] = tg; + let _ = app.session.execute("keys.setSpatialTangents", q); + None + } + Drag::Ft { layer, path, mode, anchor, last, inv, axes } => { + let p = inv.apply(gv2(cpt[0], cpt[1])); + let cur = [p.x, p.y]; + let mut q = json!({"layer": layer.0, "mask": path, "anchor": anchor, "merge": merge}); + match mode { + 0 => q["offset"] = json!([cur[0] - last[0], cur[1] - last[1]]), + 1 => { + let f = |k: usize| { + let a = last[k] - anchor[k]; + if axes[k] && a.abs() > 1e-6 { ((cur[k] - anchor[k]) / a).clamp(-50.0, 50.0) } else { 1.0 } + }; + let (mut fx, mut fy) = (f(0), f(1)); + if mods.shift && axes[0] && axes[1] { + let k = (fx + fy) / 2.0; + (fx, fy) = (k, k); + } + if fx.abs() < 1e-3 || fy.abs() < 1e-3 { + return None; + } + q["scale"] = json!([fx * 100.0, fy * 100.0]); + } + _ => { + let a0 = (last[1] - anchor[1]).atan2(last[0] - anchor[0]); + let a1 = (cur[1] - anchor[1]).atan2(cur[0] - anchor[0]); + q["rotation"] = json!((a1 - a0).to_degrees()); + } + } + if cur != last { + let _ = app.session.execute("path.freeTransform", q); + } + Some(Drag::Ft { layer, path, mode, anchor, last: cur, inv, axes }) + } + Drag::Feather { layer, prop, start, press } => { + let amt = (pos.distance(press) / zoom.max(1e-3)) as f64; + let v = json!([(start[0] + amt).max(0.0), (start[1] + amt).max(0.0)]); + let _ = app.session.execute("prop.set", json!({"layer": layer.0, "prop": prop, "value": v, "merge": merge})); + None + } + Drag::Guide { index, .. } => { + let _ = index; + None + } + Drag::Roi { .. } => None, + } +} diff --git a/crates/ui-egui/src/panels/viewer_text.rs b/crates/ui-egui/src/panels/viewer_text.rs index 65f9da04..e97f5eda 100644 --- a/crates/ui-egui/src/panels/viewer_text.rs +++ b/crates/ui-egui/src/panels/viewer_text.rs @@ -303,7 +303,7 @@ pub fn hook( exec(app, "text.setSelection", json!({"anchor": r.start, "caret": r.end})); owns = true; } - _ if !matches!(tool, Tool::Hand | Tool::Zoom | Tool::Pen) && !type_tool => { + _ if !matches!(tool, Tool::Hand | Tool::Zoom) && !tool.is_pen() && !type_tool => { if let Some(e) = text_layer_at(app, ectx, map, p) { exec(app, "text.edit", json!({"layer": e.layer.id.0})); ui.memory_mut(|m| m.request_focus(focus_id())); diff --git a/crates/ui-egui/src/panels/viewer_tools.rs b/crates/ui-egui/src/panels/viewer_tools.rs new file mode 100644 index 00000000..79688e19 --- /dev/null +++ b/crates/ui-egui/src/panels/viewer_tools.rs @@ -0,0 +1,689 @@ +//! Composition viewer tools: snapping (targets and the highlighted feedback), rulers with guides +//! dragged out of them, the displayed frame (Show Channel, exposure, snapshots, region of +//! interest, Wireframe Fast Previews) and the bottom control bar in After Effects' order. +//! +//! Edits go through engine commands (`view.*`, `comp.*`); the viewer calls in from small hooks. + +use effectcraft_engine::commands::viewer_cmds::FastPreviews; +use effectcraft_engine::project::{Comp, ItemId, LayerId}; +use effectcraft_engine::render::EvalCtx; +use effectcraft_engine::time::Tick; +use effectcraft_engine::viewer::{self as vw, Channel, Snap, SnapKind, SnapSource}; +use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; +use serde_json::json; + +use super::viewer::ViewerMap; +use crate::icons::Icon; +use crate::state::Resolution; +use crate::theme::Tokens; +use crate::{EffectcraftApp, widgets}; + +// ---------------------------------------------------------------- snapping + +/// Snap distance in screen points. +pub(crate) const SNAP_PX: f64 = 8.0; +const SNAP_COLOR: Color32 = Color32::from_rgb(0xff, 0xd2, 0x3c); + +fn snap_id() -> egui::Id { + egui::Id::new("viewer-snap") +} + +/// Layer snapping is on: the Snapping checkbox (View ▸ Snapping), inverted while Cmd/Ctrl is held. +pub(crate) fn snapping_on(app: &EffectcraftApp, mods: egui::Modifiers) -> bool { + app.session.state.snapping != mods.command +} + +/// Snap dragged feature points (`sources`, comp pixels) to the comp's targets: other layers' +/// edges, centres, anchor points and mask/shape vertices and the comp edges and centre (when +/// snapping is on), guides (View ▸ Snap to Guides) and the grid (View ▸ Snap to Grid). Returns +/// the comp-space correction (zero when nothing is near) and records the feedback for this frame. +pub(crate) fn snap( + app: &EffectcraftApp, + ctx: &egui::Context, + ectx: &EvalCtx, + map: &ViewerMap, + exclude: &[LayerId], + sources: &[[f64; 2]], + mods: egui::Modifiers, +) -> [f64; 2] { + let layers = snapping_on(app, mods); + let v = &app.ui.viewer; + let guides = v.snap_guides && v.guides; + let grid = v.snap_grid; + if sources.is_empty() || !(layers || guides || grid) { + return [0.0; 2]; + } + let opts = vw::SnapOptions { guides, grid, grid_spacing: app.session.prefs.grids.grid_spacing }; + let mut targets = vw::targets(ectx, exclude, opts); + if !layers { + targets.retain(|t| matches!(t.source, SnapSource::Guide | SnapSource::Grid)); + } + match vw::snap(sources, &targets, SNAP_PX / map.zoom.max(1e-3) as f64) { + Some(s) => { + let d = s.delta; + ctx.data_mut(|dd| dd.insert_temp(snap_id(), s)); + d + } + None => [0.0; 2], + } +} + +/// Draw (and clear) this frame's snap feedback: the target layer's box highlighted, the guide or +/// grid line, and a cross where the feature landed. +pub(crate) fn draw_snap(ctx: &egui::Context, painter: &egui::Painter, map: &ViewerMap, ectx: &EvalCtx) { + let Some(s) = ctx.data(|d| d.get_temp::(snap_id())) else { return }; + ctx.data_mut(|d| d.remove::(snap_id())); + let stroke = Stroke::new(1.5, SNAP_COLOR); + let area = map.area; + for h in &s.hits { + if let SnapSource::Layer(l) | SnapSource::Vertex(l) = h.source + && let Some(layer) = ectx.comp.layer(l) + && let Some(b) = effectcraft_engine::render::content_bounds(ectx, layer) + { + let (m, _) = ectx.layer_to_comp(layer); + let q: Vec = [[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]]] + .iter() + .map(|p| { + let c = m.apply(effectcraft_engine::geom::vec2(p[0], p[1])); + map.to_screen([c.x, c.y]) + }) + .collect(); + painter.add(egui::Shape::closed_line(q, stroke)); + } + match h.kind { + SnapKind::Point => { + let c = map.to_screen(h.pos); + painter.rect_stroke(Rect::from_center_size(c, vec2(9.0, 9.0)), 0.0, stroke, StrokeKind::Middle); + } + SnapKind::VLine => { + let x = map.to_screen(h.pos).x; + painter.line_segment([pos2(x, area.min.y), pos2(x, area.max.y)], Stroke::new(1.0, SNAP_COLOR)); + } + SnapKind::HLine => { + let y = map.to_screen(h.pos).y; + painter.line_segment([pos2(area.min.x, y), pos2(area.max.x, y)], Stroke::new(1.0, SNAP_COLOR)); + } + } + } + let c = map.to_screen(s.at); + painter.line_segment([c - vec2(8.0, 0.0), c + vec2(8.0, 0.0)], stroke); + painter.line_segment([c - vec2(0.0, 8.0), c + vec2(0.0, 8.0)], stroke); +} + +// ---------------------------------------------------------------- rulers and guides + +/// Ruler thickness in points. +pub(crate) const RULER: f32 = 16.0; + +/// The viewer area left for the image once the rulers (View ▸ Show Rulers) take their strips. +pub(crate) fn inset(app: &EffectcraftApp, area: Rect) -> Rect { + if app.ui.viewer.rulers { Rect::from_min_max(area.min + vec2(RULER, RULER), area.max) } else { area } +} + +fn nice(raw: f64) -> f64 { + let mag = 10f64.powf(raw.max(1e-9).log10().floor()); + let n = raw / mag; + mag * if n <= 1.0 { + 1.0 + } else if n <= 2.0 { + 2.0 + } else if n <= 5.0 { + 5.0 + } else { + 10.0 + } +} + +/// Draw the rulers (ticks relative to the zero point, live pointer markers) and handle their +/// gestures: drag from a ruler to add a guide, drag the corner to move the zero point +/// (double-click resets it). +pub(crate) fn rulers(app: &mut EffectcraftApp, ui: &mut egui::Ui, map: &ViewerMap, outer: Rect, inner: Rect) { + if !app.ui.viewer.rulers { + return; + } + let t = app.tokens; + let p = ui.painter().clone(); + let top = Rect::from_min_max(pos2(inner.min.x, outer.min.y), pos2(inner.max.x, inner.min.y)); + let left = Rect::from_min_max(pos2(outer.min.x, inner.min.y), pos2(inner.min.x, inner.max.y)); + let corner = Rect::from_min_max(outer.min, inner.min); + let bg = t.panel_bg; + for r in [top, left, corner] { + p.rect_filled(r, 0.0, bg); + } + p.line_segment([top.left_bottom(), top.right_bottom()], Stroke::new(1.0, t.separator)); + p.line_segment([left.right_top(), left.right_bottom()], Stroke::new(1.0, t.separator)); + let o = app.ui.viewer.ruler_origin; + let zoom = map.zoom as f64; + let step = nice(64.0 / zoom.max(1e-6)); + let minor = step / if (step / nice(step / 2.0) - 2.0).abs() < 1e-9 { 2.0 } else { 5.0 }; + let tick = Stroke::new(1.0, t.text_faint); + let font = Tokens::ui(9.0); + let label = |v: f64| if step >= 1.0 { format!("{}", v.round() as i64) } else { format!("{v:.1}") }; + // Horizontal ruler (x). + let pt = p.with_clip_rect(top); + let (c0, c1) = (map.to_comp(top.left_top())[0], map.to_comp(top.right_top())[0]); + let mut k = ((c0 - o[0]) / minor).floor() as i64; + while (o[0] + k as f64 * minor) <= c1 { + let cx = o[0] + k as f64 * minor; + let x = map.to_screen([cx, 0.0]).x; + let major = ((k as f64 * minor) / step).round() * step - k as f64 * minor; + let is_major = major.abs() < minor * 0.01; + pt.line_segment([pos2(x, top.max.y - if is_major { RULER - 2.0 } else { 4.0 }), pos2(x, top.max.y)], tick); + if is_major { + pt.text(pos2(x + 2.0, top.min.y + 1.0), Align2::LEFT_TOP, label(cx - o[0]), font.clone(), t.text_dim); + } + k += 1; + } + // Vertical ruler (y). + let pl = p.with_clip_rect(left); + let (r0, r1) = (map.to_comp(left.left_top())[1], map.to_comp(left.left_bottom())[1]); + let mut k = ((r0 - o[1]) / minor).floor() as i64; + while (o[1] + k as f64 * minor) <= r1 { + let cy = o[1] + k as f64 * minor; + let y = map.to_screen([0.0, cy]).y; + let major = ((k as f64 * minor) / step).round() * step - k as f64 * minor; + let is_major = major.abs() < minor * 0.01; + pl.line_segment([pos2(left.max.x - if is_major { RULER - 2.0 } else { 4.0 }, y), pos2(left.max.x, y)], tick); + if is_major { + let g = pl.layout_no_wrap(label(cy - o[1]), font.clone(), t.text_dim); + pl.add(egui::epaint::TextShape::new(pos2(left.min.x + 1.0, y + g.size().x + 2.0), g, t.text_dim).with_angle(-std::f32::consts::FRAC_PI_2)); + } + k += 1; + } + // Live pointer markers. + if let Some(hp) = ui.ctx().pointer_hover_pos() + && inner.contains(hp) + { + let m = Stroke::new(1.0, t.accent); + pt.line_segment([pos2(hp.x, top.min.y), pos2(hp.x, top.max.y)], m); + pl.line_segment([pos2(left.min.x, hp.y), pos2(left.max.x, hp.y)], m); + } + // Zero point: drag the corner, double-click to reset. + p.line_segment([corner.center() - vec2(4.0, 0.0), corner.center() + vec2(4.0, 0.0)], tick); + p.line_segment([corner.center() - vec2(0.0, 4.0), corner.center() + vec2(0.0, 4.0)], tick); + let cr = + ui.interact(corner, egui::Id::new("vw-ruler-origin"), Sense::click_and_drag()).on_hover_text("Ruler zero point (drag to move, double-click to reset)"); + app.auto.add("viewer.ruler.origin", corner, "Ruler zero point"); + let fg = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("vw-ruler-fg"))); + if cr.dragged() + && let Some(hp) = cr.interact_pointer_pos() + { + fg.line_segment([pos2(inner.min.x, hp.y), pos2(inner.max.x, hp.y)], Stroke::new(1.0, t.accent)); + fg.line_segment([pos2(hp.x, inner.min.y), pos2(hp.x, inner.max.y)], Stroke::new(1.0, t.accent)); + } + if cr.drag_stopped() + && let Some(hp) = cr.interact_pointer_pos() + { + let c = map.to_comp(hp); + app.ui.viewer.ruler_origin = [c[0].round(), c[1].round()]; + } + if cr.double_clicked() { + app.ui.viewer.ruler_origin = [0.0, 0.0]; + } + // Guides dragged out of the rulers: the top ruler makes horizontal guides, the left one + // vertical guides. + for (r, vertical, id) in [(top, false, "top"), (left, true, "left")] { + let resp = ui.interact(r, egui::Id::new(("vw-ruler", id)), Sense::drag()).on_hover_text("Drag to create a guide"); + app.auto.add(&format!("viewer.ruler.{id}"), r, if vertical { "Vertical ruler" } else { "Horizontal ruler" }); + if resp.hovered() { + ui.ctx().set_cursor_icon(if vertical { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::ResizeVertical }); + } + if resp.dragged() + && let Some(hp) = resp.interact_pointer_pos() + { + let s = Stroke::new(1.0, guide_color(app)); + if vertical { + fg.line_segment([pos2(hp.x, inner.min.y), pos2(hp.x, inner.max.y)], s); + } else { + fg.line_segment([pos2(inner.min.x, hp.y), pos2(inner.max.x, hp.y)], s); + } + } + if resp.drag_stopped() + && let Some(hp) = resp.interact_pointer_pos() + && inner.contains(hp) + { + let c = map.to_comp(hp); + let pos = if vertical { c[0] } else { c[1] }.round(); + let r = app.session.execute("view.addGuide", json!({"orientation": if vertical { "vertical" } else { "horizontal" }, "position": pos})); + if let Err(e) = r { + app.ui.status = e.to_string(); + } + app.ui.viewer.guides = true; + } + } +} + +pub(crate) fn guide_color(app: &EffectcraftApp) -> Color32 { + let [r, g, b] = super::viewer::hex_rgb(&app.session.prefs.grids.guide_color).unwrap_or([0x3c, 0xc8, 0xf0]); + Color32::from_rgb(r, g, b) +} + +/// The guide under a screen point (visible, unlocked guides only): (index, vertical). +pub(crate) fn guide_at(app: &EffectcraftApp, comp: &Comp, map: &ViewerMap, pos: Pos2) -> Option<(usize, bool)> { + if !app.ui.viewer.guides || app.ui.viewer.lock_guides { + return None; + } + comp.guides.iter().enumerate().find_map(|(i, g)| { + let s = map.to_screen([g.position, g.position]); + let d = if g.vertical { (s.x - pos.x).abs() } else { (s.y - pos.y).abs() }; + (d < 4.0).then_some((i, g.vertical)) + }) +} + +/// Proportional grid (Grid and guide options ▸ Proportional Grid): Settings ▸ Grids & Guides +/// horizontal × vertical cells over the comp. +pub(crate) fn proportional_grid(app: &EffectcraftApp, painter: &egui::Painter, comp_rect: Rect) { + if !app.ui.viewer.proportional_grid { + return; + } + let g = &app.session.prefs.grids; + let [r, gg, b] = super::viewer::hex_rgb(&g.grid_color).unwrap_or([120, 160, 255]); + let s = Stroke::new(1.0, Color32::from_rgba_unmultiplied(r, gg, b, 110)); + let (nx, ny) = (g.proportional_horizontal.max(1), g.proportional_vertical.max(1)); + for i in 1..nx { + let x = comp_rect.min.x + comp_rect.width() * i as f32 / nx as f32; + painter.line_segment([pos2(x, comp_rect.min.y), pos2(x, comp_rect.max.y)], s); + } + for i in 1..ny { + let y = comp_rect.min.y + comp_rect.height() * i as f32 / ny as f32; + painter.line_segment([pos2(comp_rect.min.x, y), pos2(comp_rect.max.x, y)], s); + } +} + +// ---------------------------------------------------------------- the displayed frame + +/// Remember the region of interest the displayed texture was rendered with. +pub(crate) fn set_texture_roi(ctx: &egui::Context, roi: Option<[f64; 4]>) { + ctx.data_mut(|d| d.insert_temp(egui::Id::new("viewer-tex-roi"), roi)); +} + +fn texture_roi(ctx: &egui::Context) -> Option<[f64; 4]> { + ctx.data(|d| d.get_temp::>(egui::Id::new("viewer-tex-roi"))).flatten() +} + +/// Show Snapshot: the stored snapshot is on screen (button or F5 held). +pub(crate) fn showing_snapshot(app: &EffectcraftApp, ctx: &egui::Context) -> bool { + let f5 = ctx.input(|i| i.key_down(egui::Key::F5)) && !ctx.egui_wants_keyboard_input(); + app.session.snapshot.is_some() && (app.session.state.viewer.show_snapshot || f5) +} + +fn transformed(img: &egui::ColorImage, ch: Channel, colorized: bool, stops: f32) -> egui::ColorImage { + let mut px: Vec<[u8; 4]> = img.pixels.iter().map(|c| c.to_array()).collect(); + vw::display_transform(&mut px, ch, colorized, stops); + egui::ColorImage::new(img.size, px.into_iter().map(|a| Color32::from_rgba_premultiplied(a[0], a[1], a[2], a[3])).collect()) +} + +/// Draw the frame into `comp_rect`: the rendered frame (covering the region of interest when one +/// is set), or the snapshot while Show Snapshot is on / F5 is held, through Show Channel and the +/// exposure. Fast Previews ▸ Wireframe draws layer outlines instead. +pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter: &egui::Painter, comp_rect: Rect, cid: ItemId, ectx: &EvalCtx) { + let opts = app.session.state.viewer.clone(); + let snap = showing_snapshot(app, ctx); + if opts.fast_previews == FastPreviews::Wireframe && !snap { + let zoom = comp_rect.width() / ectx.comp.width.max(1) as f32; + let map = ViewerMap { origin: comp_rect.min, zoom, comp: [ectx.comp.width as f32, ectx.comp.height as f32], area: comp_rect }; + for l in ectx.comp.layers.iter().filter(|l| l.is_active_at(ectx.time) && l.has_video()) { + let Some(b) = effectcraft_engine::render::content_bounds(ectx, l) else { continue }; + let (m, _) = super::viewer::l2c(ectx, l); + let q: Vec = [[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]]] + .iter() + .map(|p| { + let c = m.apply(effectcraft_engine::geom::vec2(p[0], p[1])); + map.to_screen([c.x, c.y]) + }) + .collect(); + painter.add(egui::Shape::closed_line(q, Stroke::new(1.0, app.tokens.label(l.label)))); + } + return; + } + let plain = opts.channel == Channel::Rgb && opts.exposure == 0.0; + let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); + if snap { + let Some(s) = app.session.snapshot.clone() else { return }; + let key = { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + (std::sync::Arc::as_ptr(&s.image) as usize, opts.channel.id(), opts.colorized, opts.exposure.to_bits()).hash(&mut h); + h.finish() + }; + let id = egui::Id::new("viewer-snapshot-tex"); + let tex = match ctx.data(|d| d.get_temp::<(u64, egui::TextureHandle)>(id)) { + Some((k, tex)) if k == key => tex, + _ => { + let img = crate::frames::to_color_image(&s.image); + let img = if plain { img } else { transformed(&img, opts.channel, opts.colorized, opts.exposure) }; + let tex = ctx.load_texture("viewer-snapshot", img, egui::TextureOptions::LINEAR); + ctx.data_mut(|d| d.insert_temp(id, (key, tex.clone()))); + tex + } + }; + painter.image(tex.id(), comp_rect, uv, Color32::WHITE); + painter.text(comp_rect.left_top() + vec2(6.0, 6.0), Align2::LEFT_TOP, "Snapshot", Tokens::ui(11.0), SNAP_COLOR); + return; + } + let Some((tex, k)) = app.viewer_tex.clone() else { return }; + if k.comp != cid.0 { + return; + } + let zoom = comp_rect.width() as f64 / ectx.comp.width.max(1) as f64; + let rect = match texture_roi(ctx) { + Some([x, y, w, h]) => Rect::from_min_size(comp_rect.min + vec2((x * zoom) as f32, (y * zoom) as f32), vec2((w * zoom) as f32, (h * zoom) as f32)), + None => comp_rect, + }; + if plain { + painter.image(tex.id(), rect, uv, Color32::WHITE); + return; + } + let Some(src) = app.viewer_image.clone() else { return }; + let key = { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + (std::sync::Arc::as_ptr(&src) as usize, opts.channel.id(), opts.colorized, opts.exposure.to_bits()).hash(&mut h); + h.finish() + }; + let id = egui::Id::new("viewer-display-tex"); + let tex = match ctx.data(|d| d.get_temp::<(u64, egui::TextureHandle)>(id)) { + Some((k, tex)) if k == key => tex, + _ => { + let tex = ctx.load_texture("viewer-display", transformed(&src, opts.channel, opts.colorized, opts.exposure), egui::TextureOptions::LINEAR); + ctx.data_mut(|d| d.insert_temp(id, (key, tex.clone()))); + tex + } + }; + painter.image(tex.id(), rect, uv, Color32::WHITE); +} + +// ---------------------------------------------------------------- bottom bar + +/// A popup list above `anchor` (selected entries highlighted); entries register +/// `viewer..` automation ids. +fn popup(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: &str, anchor: Rect, items: &[(String, bool)], auto: &str) -> Option { + let pid = egui::Id::new(id); + let open: bool = ui.data(|d| d.get_temp(pid.with("open")).unwrap_or(false)); + if !open { + return None; + } + let h: f32 = items.iter().map(|(l, _)| if l == "-" { 9.0 } else { 21.0 }).sum::() + 14.0; + let pos = anchor.left_top() - vec2(0.0, h + 4.0); + let mut chosen = None; + let area = egui::Area::new(pid.with("area")).order(egui::Order::Foreground).fixed_pos(pos).show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.set_min_width(170.0); + for (i, (label, on)) in items.iter().enumerate() { + if label == "-" { + ui.separator(); + continue; + } + let r = ui.selectable_label(*on, label.as_str()); + app.auto.add(&format!("viewer.{auto}.{i}"), r.rect, label); + if r.clicked() { + chosen = Some(i); + } + } + }); + }); + let outside = ui.input(|i| i.pointer.any_pressed()) + && !area.response.contains_pointer() + && !anchor.contains(ui.input(|i| i.pointer.interact_pos()).unwrap_or_default()); + if chosen.is_some() || outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) { + ui.data_mut(|d| d.insert_temp(pid.with("open"), false)); + } + chosen +} + +fn toggle_popup(ui: &egui::Ui, id: &str) { + let pid = egui::Id::new(id).with("open"); + let open: bool = ui.data(|d| d.get_temp(pid).unwrap_or(false)); + ui.data_mut(|d| d.insert_temp(pid, !open)); +} + +/// Magnification as After Effects shows it: "136.1 %", "100 %". +pub(crate) fn magnification_label(pct: f32) -> String { + if (pct - pct.round()).abs() < 0.05 { format!("{:.0} %", pct.round()) } else { format!("{pct:.1} %") } +} + +/// The resolution popup's label: the chosen factor, or the factor Auto resolves to in brackets. +pub(crate) fn resolution_label(res: Resolution, scale: f64) -> String { + match res { + Resolution::Auto => { + let r = if scale >= 0.99 { + "Full" + } else if scale >= 0.49 { + "Half" + } else if scale >= 0.33 { + "Third" + } else { + "Quarter" + }; + format!("({r})") + } + Resolution::Custom(n) => format!("Custom ({n})"), + r => r.label().to_string(), + } +} + +/// The Composition panel's bottom bar, in After Effects 2026 order: magnification, resolution, +/// transparency grid, mask and shape path visibility, region of interest, grid and guide +/// options, show channel, reset exposure, exposure, take / show snapshot, Fast Previews, the 3D +/// renderer and view, and the current time. +pub(crate) fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, zoom: f32, fit: f32, time: Tick, comp: &Comp) { + let t = app.tokens; + let p = ui.painter().clone(); + p.rect_filled(bar, 0.0, t.panel_bg); + p.line_segment([bar.left_top(), bar.right_top()], Stroke::new(1.0, t.separator)); + let cy = bar.center().y; + let mut x = bar.min.x + 8.0; + let ppp = ui.ctx().pixels_per_point(); + + // Magnification. + let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(80.0, 20.0)); + if widgets::dropdown(ui, r, &magnification_label(zoom * ppp * 100.0), &t, egui::Id::new("vw-mag")).on_hover_text("Magnification ratio popup").clicked() { + toggle_popup(ui, "vw-mag-pop"); + } + app.auto.add("viewer.magnification", r, "Magnification"); + const MAGS: [&str; 15] = ["Fit", "Fit up to 100%", "-", "1.5%", "3.1%", "6.25%", "12.5%", "25%", "33.3%", "50%", "100%", "200%", "400%", "800%", "1600%"]; + let cur = (zoom * ppp * 100.0 * 10.0).round() / 10.0; + let items: Vec<(String, bool)> = MAGS + .iter() + .map(|m| { + let on = match *m { + "Fit" => app.ui.viewer.zoom.is_none(), + s => s.trim_end_matches('%').parse::().is_ok_and(|v| app.ui.viewer.zoom.is_some() && (v - cur).abs() < 0.06), + }; + (m.to_string(), on) + }) + .collect(); + if let Some(i) = popup(app, ui, "vw-mag-pop", r, &items, "magnification") { + app.ui.viewer.pan = [0.0, 0.0]; + app.ui.viewer.zoom = match MAGS[i] { + "Fit" => None, + "Fit up to 100%" => Some(fit.min(1.0 / ppp)), + s => s.trim_end_matches('%').parse::().ok().map(|v| v / 100.0 / ppp), + }; + } + x = r.max.x + 4.0; + + // Resolution / down-sample factor. + let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(84.0, 20.0)); + let scale = app.viewer_scale(zoom, ppp); + if widgets::dropdown(ui, r, &resolution_label(app.ui.viewer.res, scale), &t, egui::Id::new("vw-res")) + .on_hover_text("Resolution/Down Sample Factor Popup") + .clicked() + { + toggle_popup(ui, "vw-res-pop"); + } + app.auto.add("viewer.resolution", r, "Resolution/Down Sample Factor"); + let res = app.ui.viewer.res; + let items: Vec<(String, bool)> = vec![ + ("Full".into(), res == Resolution::Full), + ("Half".into(), res == Resolution::Half), + ("Third".into(), res == Resolution::Third), + ("Quarter".into(), res == Resolution::Quarter), + ("Custom...".into(), matches!(res, Resolution::Custom(_))), + ("-".into(), false), + ("Auto".into(), res == Resolution::Auto), + ]; + if let Some(i) = popup(app, ui, "vw-res-pop", r, &items, "resolutionItem") { + let ctx = ui.ctx().clone(); + let id = ["view.res.full", "view.res.half", "view.res.third", "view.res.quarter", "view.res.custom", "", "view.res.auto"][i]; + if let Err(e) = crate::menus::invoke(app, &ctx, id, json!({})) { + app.ui.status = e; + } + } + x = r.max.x + 6.0; + + let tog = |ui: &mut egui::Ui, auto: &mut crate::automation::Registry, x: &mut f32, icon: Icon, on: bool, id: &str, tip: &str| -> (bool, Rect) { + let r = Rect::from_min_size(pos2(*x, cy - 11.0), vec2(22.0, 22.0)); + let resp = widgets::icon_button(ui, r, icon, on, &t, egui::Id::new(("vw-btn", id))).on_hover_text(tip); + auto.add(&format!("viewer.{id}"), r, tip); + *x += 24.0; + (resp.clicked(), r) + }; + if tog(ui, &mut app.auto, &mut x, Icon::Checker, app.ui.viewer.transparency_grid, "transparency", "Toggle Transparency Grid").0 { + app.ui.viewer.transparency_grid = !app.ui.viewer.transparency_grid; + } + if tog(ui, &mut app.auto, &mut x, Icon::MaskVis, app.ui.viewer.show_masks, "masks", "Toggle Mask and Shape Path Visibility").0 { + app.ui.viewer.show_masks = !app.ui.viewer.show_masks; + } + // Region of interest: with none, arm drawing (the next drag draws it); with one, clear it. + let has_roi = app.session.state.region_of_interest.is_some(); + if tog(ui, &mut app.auto, &mut x, Icon::Region, has_roi || app.ui.viewer.roi_draw, "roi", "Region of Interest").0 { + if has_roi { + let _ = app.session.execute("view.setRegionOfInterest", json!({"rect": null})); + app.ui.viewer.roi_draw = false; + } else { + app.ui.viewer.roi_draw = !app.ui.viewer.roi_draw; + } + } + // Grid and guide options. + let v = &app.ui.viewer; + let any = v.grid || v.safe_margins || v.proportional_grid || (v.guides && !comp.guides.is_empty()) || v.rulers; + let (clicked, gr) = tog(ui, &mut app.auto, &mut x, Icon::Grid, any, "grid", "Choose grid and guide options"); + if clicked { + toggle_popup(ui, "vw-grid-pop"); + } + let v = &app.ui.viewer; + let items = vec![ + ("Title/Action Safe".to_string(), v.safe_margins), + ("Proportional Grid".to_string(), v.proportional_grid), + ("Grid".to_string(), v.grid), + ("Guides".to_string(), v.guides), + ("Rulers".to_string(), v.rulers), + ]; + if let Some(i) = popup(app, ui, "vw-grid-pop", gr, &items, "gridItem") { + let v = &mut app.ui.viewer; + match i { + 0 => v.safe_margins = !v.safe_margins, + 1 => v.proportional_grid = !v.proportional_grid, + 2 => v.grid = !v.grid, + 3 => v.guides = !v.guides, + _ => v.rulers = !v.rulers, + } + } + // Show Channel and Color Management Settings. + let vo = app.session.state.viewer.clone(); + let (clicked, cr) = tog(ui, &mut app.auto, &mut x, Icon::Channel, vo.channel != Channel::Rgb, "channel", "Show Channel and Color Management Settings"); + if clicked { + toggle_popup(ui, "vw-channel-pop"); + } + let mut items: Vec<(String, bool)> = Channel::ALL.iter().map(|c| (c.label().to_string(), vo.channel == *c)).collect(); + items.push(("-".into(), false)); + items.push(("Colorize".into(), vo.colorized)); + if let Some(i) = popup(app, ui, "vw-channel-pop", cr, &items, "channelItem") { + let params = + if i < Channel::ALL.len() { json!({"channel": Channel::ALL[i].id()}) } else { json!({"channel": vo.channel.id(), "colorized": !vo.colorized}) }; + let _ = app.session.execute("view.channel", params); + } + // Exposure. + let exposed = vo.exposure != 0.0; + if tog(ui, &mut app.auto, &mut x, Icon::Exposure, exposed, "resetExposure", "Reset Exposure").0 { + let _ = app.session.execute("view.resetExposure", json!({})); + } + let er = Rect::from_min_size(pos2(x, cy - 10.0), vec2(42.0, 20.0)); + let mut ev = vo.exposure as f64; + let resp = ui.put( + er, + egui::DragValue::new(&mut ev) + .speed(0.05) + .range(-40.0..=40.0) + .fixed_decimals(1) + .custom_formatter(|v, _| if v == 0.0 { "0.0".into() } else { format!("{v:+.1}") }), + ); + let resp = resp.on_hover_text("Adjust Exposure (stops)"); + app.auto.add("viewer.exposure", er, "Adjust Exposure"); + if resp.changed() { + let _ = app.session.execute("view.exposure", json!({"stops": ev})); + } + x = er.max.x + 4.0; + // Snapshots. + if tog(ui, &mut app.auto, &mut x, Icon::Snapshot, false, "snapshot", "Take Snapshot").0 { + let scale = app.viewer_scale(zoom, ppp); + match app.session.execute("view.takeSnapshot", json!({"scale": scale.clamp(0.05, 1.0)})) { + Ok(_) => app.ui.status = "Snapshot taken".into(), + Err(e) => app.ui.status = e.to_string(), + } + } + let showing = showing_snapshot(app, ui.ctx()); + let (_, sr) = tog(ui, &mut app.auto, &mut x, Icon::ShowSnapshot, showing, "showSnapshot", "Show Snapshot (hold)"); + // Show Snapshot works while the button is held, like F5. + let held = ui.input(|i| i.pointer.primary_down()) && ui.input(|i| i.pointer.press_origin()).is_some_and(|o| sr.contains(o)); + if app.session.snapshot.is_some() && held != app.session.state.viewer.show_snapshot { + let _ = app.session.execute("view.showSnapshot", json!({"value": held})); + } + // Fast Previews. + let fp = vo.fast_previews; + let (clicked, fr) = tog(ui, &mut app.auto, &mut x, Icon::Sparkle, fp != FastPreviews::Off || app.ui.viewer.fast_preview, "fastPreviews", "Fast Previews"); + if clicked { + toggle_popup(ui, "vw-fast-pop"); + } + let items: Vec<(String, bool)> = FastPreviews::ALL.iter().map(|m| (m.label().to_string(), fp == *m)).collect(); + if let Some(i) = popup(app, ui, "vw-fast-pop", fr, &items, "fastPreviewsItem") { + let _ = app.session.execute("view.fastPreviewMode", json!({"mode": FastPreviews::ALL[i].id()})); + } + x += 4.0; + // 3D renderer + view. + if comp.has_3d() { + let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(96.0, 20.0)); + let _ = widgets::dropdown(ui, r, "Classic 3D", &t, egui::Id::new("vw-3d")); + app.auto.add("viewer.renderer3d", r, "3D Renderer"); + x = r.max.x + 4.0; + let cid = app.session.active_comp_id(); + let cur = cid.and_then(|c| app.session.state.views3d.get(&c)).map(|v| v.current).unwrap_or_default(); + let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(118.0, 20.0)); + if widgets::dropdown(ui, r, cur.label(), &t, egui::Id::new("vw-cam")).clicked() { + toggle_popup(ui, "vw-cam-pop"); + } + app.auto.add("viewer.view3d", r, "3D View"); + use effectcraft_engine::render::three_d::View3D; + let items: Vec<(String, bool)> = View3D::ALL.iter().map(|v| (v.label().to_string(), *v == cur)).collect(); + if let Some(i) = popup(app, ui, "vw-cam-pop", r, &items, "view3dItem") { + let _ = app.session.execute("view.set3DView", json!({"view": View3D::ALL[i].id()})); + } + x = r.max.x + 6.0; + } + // Current time. + let tc = crate::panels::timecode(&app.session, comp, time); + let g = p.layout_no_wrap(tc, Tokens::mono(12.0), t.timecode); + let tr = Rect::from_min_size(pos2(x + 4.0, cy - 9.0), g.size() + vec2(8.0, 4.0)); + p.galley(pos2(tr.min.x + 4.0, tr.min.y + 2.0), g, t.timecode); + app.auto.add("viewer.timecode", tr, "Current time"); + // Right: render time. + let ms = app.frames.last_ms.lock().map(|v| *v).unwrap_or(0.0); + p.text(pos2(bar.max.x - 10.0, cy), Align2::RIGHT_CENTER, format!("{ms:.0} ms"), Tokens::ui(11.0), t.text_faint); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bar_labels_match_after_effects() { + assert_eq!(magnification_label(136.08), "136.1 %"); + assert_eq!(magnification_label(100.0), "100 %"); + assert_eq!(magnification_label(49.97), "50 %"); + assert_eq!(resolution_label(Resolution::Auto, 1.0), "(Full)"); + assert_eq!(resolution_label(Resolution::Auto, 0.5), "(Half)"); + assert_eq!(resolution_label(Resolution::Quarter, 0.25), "Quarter"); + assert_eq!(resolution_label(Resolution::Custom(5), 0.2), "Custom (5)"); + } +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 515b6d9f..43ca11cb 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -24,6 +24,10 @@ pub enum Tool { Polygon, Star, Pen, + PenAdd, + PenDelete, + PenConvert, + MaskFeather, Type, TypeVertical, Brush, @@ -49,7 +53,7 @@ impl Tool { &[Tool::Rotate], &[Tool::PanBehind], &[Tool::Rectangle, Tool::RoundedRect, Tool::Ellipse, Tool::Polygon, Tool::Star], - &[Tool::Pen], + &[Tool::Pen, Tool::PenAdd, Tool::PenDelete, Tool::PenConvert, Tool::MaskFeather], &[Tool::Type, Tool::TypeVertical], &[Tool::Brush], &[Tool::Clone], @@ -57,7 +61,7 @@ impl Tool { &[Tool::RotoBrush], &[Tool::Puppet, Tool::PuppetStarch, Tool::PuppetBend, Tool::PuppetAdvanced, Tool::PuppetOverlap], ]; - pub const ALL: [Tool; 25] = [ + pub const ALL: [Tool; 29] = [ Tool::Selection, Tool::Hand, Tool::Zoom, @@ -72,6 +76,10 @@ impl Tool { Tool::Polygon, Tool::Star, Tool::Pen, + Tool::PenAdd, + Tool::PenDelete, + Tool::PenConvert, + Tool::MaskFeather, Tool::Type, Tool::TypeVertical, Tool::Brush, @@ -100,6 +108,10 @@ impl Tool { Tool::Polygon => "Polygon Tool", Tool::Star => "Star Tool", Tool::Pen => "Pen Tool", + Tool::PenAdd => "Add Vertex Tool", + Tool::PenDelete => "Delete Vertex Tool", + Tool::PenConvert => "Convert Vertex Tool", + Tool::MaskFeather => "Mask Feather Tool", Tool::Type => "Horizontal Type Tool", Tool::TypeVertical => "Vertical Type Tool", Tool::Brush => "Brush Tool", @@ -124,7 +136,7 @@ impl Tool { Tool::Rotate => Some("W"), Tool::PanBehind => Some("Y"), Tool::Rectangle | Tool::RoundedRect | Tool::Ellipse | Tool::Polygon | Tool::Star => Some("Q"), - Tool::Pen => Some("G"), + Tool::Pen | Tool::PenAdd | Tool::PenDelete | Tool::PenConvert | Tool::MaskFeather => Some("G"), Tool::Type | Tool::TypeVertical => Some("Cmd+T"), Tool::Brush | Tool::Clone | Tool::Eraser => Some("Cmd+B"), Tool::RotoBrush => Some("Alt+W"), @@ -147,6 +159,10 @@ impl Tool { Tool::Polygon => Icon::Polygon, Tool::Star => Icon::Star, Tool::Pen => Icon::Pen, + Tool::PenAdd => Icon::PenAdd, + Tool::PenDelete => Icon::PenDelete, + Tool::PenConvert => Icon::PenConvert, + Tool::MaskFeather => Icon::MaskFeather, Tool::Type => Icon::Type, Tool::TypeVertical => Icon::TypeVertical, Tool::Brush => Icon::Brush, @@ -180,6 +196,10 @@ impl Tool { _ => return None, }) } + /// The Pen slot's tools (Pen, Add / Delete / Convert Vertex, Mask Feather). + pub fn is_pen(self) -> bool { + matches!(self, Tool::Pen | Tool::PenAdd | Tool::PenDelete | Tool::PenConvert | Tool::MaskFeather) + } pub fn is_shape(self) -> bool { matches!(self, Tool::Rectangle | Tool::RoundedRect | Tool::Ellipse | Tool::Polygon | Tool::Star) } @@ -194,6 +214,8 @@ pub enum Resolution { Half, Third, Quarter, + /// View ▸ Resolution ▸ Custom: render every n-th pixel (2..=40). + Custom(u8), } impl Resolution { @@ -205,6 +227,7 @@ impl Resolution { Resolution::Half => "Half", Resolution::Third => "Third", Resolution::Quarter => "Quarter", + Resolution::Custom(_) => "Custom", } } /// Render scale for a viewer magnification (and display pixel density). @@ -226,6 +249,7 @@ impl Resolution { Resolution::Half => 0.5, Resolution::Third => 1.0 / 3.0, Resolution::Quarter => 0.25, + Resolution::Custom(n) => 1.0 / n.max(1) as f64, } } } @@ -251,6 +275,15 @@ pub struct ViewerState { pub snap_guides: bool, pub lock_guides: bool, pub snap_grid: bool, + /// Grid and guide options ▸ Proportional Grid. + pub proportional_grid: bool, + /// Rulers zero point (comp pixels), moved by dragging the ruler corner. + pub ruler_origin: [f64; 2], + /// Region of Interest button armed: the next drag in the viewer draws the region. + pub roi_draw: bool, + /// The pointer is dragging in the viewer this frame (Adaptive Resolution). + #[serde(skip)] + pub interacting: bool, /// View ▸ Panel Background Color (`None` = theme default). pub pasteboard: Option<[u8; 3]>, pub custom_pasteboard: [u8; 3], @@ -274,6 +307,10 @@ impl Default for ViewerState { snap_guides: false, lock_guides: false, snap_grid: false, + proportional_grid: false, + ruler_origin: [0.0, 0.0], + roi_draw: false, + interacting: false, pasteboard: None, custom_pasteboard: [0x80, 0x80, 0x80], } @@ -311,6 +348,15 @@ pub struct TimelineState { /// Manual graph value range (when auto-zoom is off). #[serde(default)] pub graph_range: Option<(f64, f64)>, + /// Graph Editor ▸ Snap: key drags snap to the current time, other keys and frames. + #[serde(default = "yes")] + pub graph_snap: bool, + /// Graph Editor ▸ Show Reference Graph (the other graph type, faint, behind). + #[serde(default)] + pub graph_reference: bool, + /// Graph Editor ▸ Show Transform Box When Multiple Keys Are Selected. + #[serde(default = "yes")] + pub graph_transform_box: bool, /// Properties whose inline expression editor is collapsed. #[serde(default)] pub expr_closed: BTreeSet, @@ -341,6 +387,9 @@ impl Default for TimelineState { graph_show_selected: true, graph_auto_zoom: true, graph_range: None, + graph_snap: true, + graph_reference: false, + graph_transform_box: true, expr_closed: BTreeSet::new(), } } diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs new file mode 100644 index 00000000..db739954 --- /dev/null +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -0,0 +1,297 @@ +//! Headless checks for the Composition viewer's interaction parity: bottom bar order, region of +//! interest drawing, rulers and guides, snapping, the shape Pen and motion-path key drags +//! (egui_kittest, UI logic only). + +use effectcraft_engine::Session; +use effectcraft_engine::project::LayerId; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::state::Tool; +use egui::{Event, Pos2, Rect, pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +fn app() -> EffectcraftApp { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "View", "width": 640, "height": 360, "duration": 4})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080", "width": 640, "height": 360})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Box", "color": "#e04020", "width": 80, "height": 80})).unwrap(); + s.execute("edit.deselectAll", json!({})).unwrap(); + EffectcraftApp::new(s) +} + +fn harness() -> Harness<'static, EffectcraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| app()); + h.run_steps(3); + 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 click(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) { + h.input_mut().events.push(Event::PointerMoved(p)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); +} + +fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) { + 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..=8 { + h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i as f32 / 8.0))); + h.step(); + } + h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); +} + +/// Comp pixel → screen point. +fn screen(h: &Harness<'_, EffectcraftApp>, p: [f32; 2]) -> Pos2 { + let c = rect(h, "viewer.comp"); + let z = c.width() / 640.0; + c.min + vec2(p[0] * z, p[1] * z) +} + +#[test] +fn bottom_bar_in_after_effects_order() { + let h = harness(); + let order = [ + "viewer.magnification", + "viewer.resolution", + "viewer.transparency", + "viewer.masks", + "viewer.roi", + "viewer.grid", + "viewer.channel", + "viewer.resetExposure", + "viewer.exposure", + "viewer.snapshot", + "viewer.showSnapshot", + "viewer.fastPreviews", + "viewer.timecode", + ]; + let xs: Vec = order.iter().map(|id| rect(&h, id).min.x).collect(); + assert!(xs.windows(2).all(|w| w[0] < w[1]), "{xs:?}"); + let mag = &h.state().auto.find("viewer.magnification").unwrap().label; + assert_eq!(mag, "Magnification"); +} + +#[test] +fn region_of_interest_is_drawn_in_the_viewer() { + let mut h = harness(); + let at = rect(&h, "viewer.roi").center(); + click(&mut h, at); + assert!(h.state().ui.viewer.roi_draw); + let (a, b) = (screen(&h, [100.0, 50.0]), screen(&h, [300.0, 250.0])); + drag(&mut h, a, b); + let r = h.state().session.state.region_of_interest.expect("roi"); + assert!((r[0] - 100.0).abs() <= 2.0 && (r[1] - 50.0).abs() <= 2.0 && (r[2] - 200.0).abs() <= 3.0 && (r[3] - 200.0).abs() <= 3.0, "{r:?}"); + assert!(!h.state().ui.viewer.roi_draw); + h.run_steps(3); + assert!(h.state().auto.find("viewer.regionOfInterest").is_some()); + // The button clears it again. + let at = rect(&h, "viewer.roi").center(); + click(&mut h, at); + assert!(h.state().session.state.region_of_interest.is_none()); +} + +#[test] +fn rulers_make_guides_and_guides_move() { + let mut h = harness(); + h.state_mut().ui.viewer.rulers = true; + h.run_steps(3); + let top = rect(&h, "viewer.ruler.top"); + let to = screen(&h, [0.0, 120.0]); + drag(&mut h, pos2(to.x + 200.0, top.center().y), pos2(to.x + 200.0, to.y)); + let g = h.state().session.active_comp().unwrap().guides.clone(); + assert_eq!(g.len(), 1); + assert!(!g[0].vertical && (g[0].position - 120.0).abs() <= 2.0, "{g:?}"); + // Drag the guide down with the Selection tool. + let from = pos2(screen(&h, [500.0, 0.0]).x, screen(&h, [0.0, g[0].position as f32]).y); + let to2 = pos2(from.x, screen(&h, [0.0, 200.0]).y); + drag(&mut h, from, to2); + let g = h.state().session.active_comp().unwrap().guides.clone(); + assert!((g[0].position - 200.0).abs() <= 2.0, "{g:?}"); +} + +#[test] +fn layer_drag_snaps_to_comp_centre_and_ctrl_disables() { + let mut h = harness(); + let box_id = h.state().session.active_comp().unwrap().layers[0].id; + h.state_mut().session.execute("prop.set", json!({"layer": box_id.0, "path": "transform/position", "value": [100, 100, 0]})).unwrap(); + h.run_steps(2); + // Grab the box at its centre and drop it 3 px from the comp centre: it snaps there. + let from = screen(&h, [100.0, 100.0]); + let to = screen(&h, [323.0, 182.0]); + drag(&mut h, from, to); + let pos = |h: &Harness<'_, EffectcraftApp>| { + let l = h.state().session.active_comp().unwrap().layer(box_id).unwrap().clone(); + l.props.prop("transform/position").unwrap().value.as_vec3() + }; + let p = pos(&h); + assert!((p[0] - 320.0).abs() < 0.01 && (p[1] - 180.0).abs() < 0.01, "{p:?}"); + // Snapping off: the same drag lands where the pointer is. + h.state_mut().session.execute("view.snapping", json!({"value": false})).unwrap(); + h.state_mut().session.execute("prop.set", json!({"layer": box_id.0, "path": "transform/position", "value": [100, 100, 0]})).unwrap(); + h.run_steps(2); + drag(&mut h, from, to); + let p = pos(&h); + assert!((p[0] - 320.0).abs() > 1.0, "{p:?}"); +} + +#[test] +fn shape_pen_draws_a_closed_shape_layer() { + let mut h = harness(); + h.state_mut().ui.tool = Tool::Pen; + h.run_steps(2); + let n0 = h.state().session.active_comp().unwrap().layers.len(); + for p in [[200.0, 100.0], [400.0, 120.0], [300.0, 260.0], [200.0, 100.0]] { + let at = screen(&h, p); + click(&mut h, at); + } + let comp = h.state().session.active_comp().unwrap().clone(); + assert_eq!(comp.layers.len(), n0 + 1); + let l = &comp.layers[0]; + assert!(matches!(l.source, effectcraft_engine::project::LayerSource::Shape)); + let g = l.props.sub("contents").unwrap().groups().next().unwrap(); + assert_eq!(g.name, "Shape 1"); + let path = g.sub("contents").unwrap().groups().next().unwrap(); + let sp = path.get("path").unwrap().value.as_path().unwrap().clone(); + assert_eq!(sp.vertices.len(), 3); + assert!(sp.closed); + assert!((sp.vertices[0][0] - (200.0 - 320.0)).abs() < 1.5, "{:?}", sp.vertices); + // G cycles the Pen slot: Add Vertex, Delete Vertex, Convert Vertex, Mask Feather, Pen. + let ctx = h.ctx.clone(); + let mut seen = vec![]; + for _ in 0..5 { + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "tool.pen", json!({})).unwrap(); + seen.push(h.state().ui.tool); + } + assert_eq!(seen, vec![Tool::PenAdd, Tool::PenDelete, Tool::PenConvert, Tool::MaskFeather, Tool::Pen]); + // Add Vertex on the first segment. + h.state_mut().ui.tool = Tool::PenAdd; + h.run_steps(2); + let at = screen(&h, [300.0, 110.0]); + click(&mut h, at); + let comp = h.state().session.active_comp().unwrap().clone(); + let sp = comp.layers[0] + .props + .sub("contents") + .unwrap() + .groups() + .next() + .unwrap() + .sub("contents") + .unwrap() + .groups() + .next() + .unwrap() + .get("path") + .unwrap() + .value + .as_path() + .unwrap() + .clone(); + assert_eq!(sp.vertices.len(), 4); +} + +#[test] +fn motion_path_key_drag_edits_that_key() { + let mut h = harness(); + let box_id: LayerId = h.state().session.active_comp().unwrap().layers[0].id; + let s = &mut h.state_mut().session; + s.execute("layer.select", json!({"layers": [box_id.0]})).unwrap(); + s.execute("prop.addKey", json!({"layer": box_id.0, "path": "transform/position", "time": 0.0, "value": [100, 100, 0]})).unwrap(); + s.execute("prop.addKey", json!({"layer": box_id.0, "path": "transform/position", "time": 2.0, "value": [500, 100, 0]})).unwrap(); + s.execute("view.snapping", json!({"value": false})).unwrap(); + s.set_time(effectcraft_engine::time::Tick::from_seconds_f64(1.0)); + h.run_steps(3); + assert!(h.state().auto.find(&format!("viewer.motionPath.{}.1", box_id.0)).is_some()); + let from = screen(&h, [500.0, 100.0]); + let to2 = screen(&h, [500.0, 300.0]); + drag(&mut h, from, to2); + let l = h.state().session.active_comp().unwrap().layer(box_id).unwrap().clone(); + let k = &l.props.prop("transform/position").unwrap().keys; + let v1 = k[1].value.as_vec3(); + assert!((v1[1] - 300.0).abs() < 2.0 && (v1[0] - 500.0).abs() < 2.0, "{v1:?}"); + assert_eq!(k[0].value.as_vec3(), [100.0, 100.0, 0.0]); +} + +#[test] +fn graph_editor_transform_box_scales_selected_keys_in_time() { + let mut h = harness(); + let box_id: LayerId = h.state().session.active_comp().unwrap().layers[0].id; + let s = &mut h.state_mut().session; + s.execute("layer.select", json!({"layers": [box_id.0]})).unwrap(); + for (t, v) in [(0.0, 0.0), (1.0, 50.0), (2.0, 100.0)] { + s.execute("prop.addKey", json!({"layer": box_id.0, "path": "transform/opacity", "time": t, "value": v})).unwrap(); + } + let uid = s.active_comp().unwrap().layer(box_id).unwrap().props.prop("transform/opacity").unwrap().uid; + s.execute("prop.select", json!({"layer": box_id.0, "prop": uid})).unwrap(); + let keys: Vec<_> = [0.0, 1.0, 2.0].iter().map(|t| json!({"layer": box_id.0, "prop": uid, "time": t})).collect(); + s.execute("keys.select", json!({"keys": keys})).unwrap(); + h.state_mut().ui.timeline.graph_editor = true; + h.run_steps(4); + for id in ["timeline.graph.snap", "timeline.graph.reference", "timeline.graph.transformBox", "timeline.graph.transformBox.5"] { + assert!(h.state().auto.find(id).is_some(), "missing {id}"); + } + // Drag the right edge handle to the right: the keys spread out in time, the first stays. + let r = rect(&h, "timeline.graph.transformBox.5"); + let k0 = rect(&h, &format!("timeline.graph.key.{uid}.0.0")).center(); + let k2 = rect(&h, &format!("timeline.graph.key.{uid}.0.2")).center(); + let to = r.center() + vec2((k2.x - k0.x) * 0.5, 0.0); + drag(&mut h, r.center(), to); + let p = h.state().session.active_comp().unwrap().layer(box_id).unwrap().props.prop("transform/opacity").unwrap().clone(); + let ts: Vec = p.keys.iter().map(|k| k.time.seconds()).collect(); + assert_eq!(ts[0], 0.0); + assert!(ts[2] > 2.5 && ts[2] < 3.5, "{ts:?}"); + assert_eq!(p.keys.iter().map(|k| k.value.as_f64()).collect::>(), vec![0.0, 50.0, 100.0]); + let undo = h.state().session.history.undo.iter().filter(|(l, _)| l == "Transform Keyframes").count(); + assert_eq!(undo, 1, "one undo step per drag"); +} + +#[test] +fn timeline_alt_drag_scales_a_key_group_in_time() { + let mut h = harness(); + let box_id: LayerId = h.state().session.active_comp().unwrap().layers[0].id; + let s = &mut h.state_mut().session; + s.execute("layer.select", json!({"layers": [box_id.0]})).unwrap(); + for (t, v) in [(0.0, 0.0), (1.0, 50.0), (2.0, 100.0)] { + s.execute("prop.addKey", json!({"layer": box_id.0, "path": "transform/opacity", "time": t, "value": v})).unwrap(); + } + let uid = s.active_comp().unwrap().layer(box_id).unwrap().props.prop("transform/opacity").unwrap().uid; + s.execute("keys.selectAll", json!({"layers": [box_id.0]})).unwrap(); + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "timeline.reveal.opacity", json!({})).unwrap(); + h.run_steps(4); + let mut ks: Vec = + h.state().auto.query(&format!("timeline.key.{uid}.")).iter().map(|e| pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0)).collect(); + ks.sort_by(|a, b| a.x.total_cmp(&b.x)); + assert_eq!(ks.len(), 3, "{ks:?}"); + let (k0, k2) = (ks[0], ks[2]); + // Alt-drag the last key to where 3 s would be: the group scales by 1.5 about the first key. + let to = pos2(k0.x + (k2.x - k0.x) * 1.5, k2.y); + let alt = egui::Modifiers { alt: true, ..Default::default() }; + h.input_mut().events.push(Event::PointerMoved(k2)); + h.step(); + h.input_mut().events.push(Event::ModifiersChanged(alt)); + h.input_mut().events.push(Event::PointerButton { pos: k2, button: egui::PointerButton::Primary, pressed: true, modifiers: alt }); + h.step(); + for i in 1..=8 { + h.input_mut().events.push(Event::PointerMoved(k2 + (to - k2) * (i as f32 / 8.0))); + h.step(); + } + h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: alt }); + h.run_steps(2); + let p = h.state().session.active_comp().unwrap().layer(box_id).unwrap().props.prop("transform/opacity").unwrap().clone(); + let ts: Vec = p.keys.iter().map(|k| k.time.seconds()).collect(); + assert_eq!(ts.len(), 3); + assert_eq!(ts[0], 0.0); + assert!((ts[2] - 3.0).abs() < 0.1 && (ts[1] - 1.5).abs() < 0.1, "{ts:?}"); +} diff --git a/docs/architecture.md b/docs/architecture.md index b7886581..dc95f35b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -103,6 +103,30 @@ groups into `Params` keys (`effects::flatten_params`). Commands: `paint.*`, `pup Half, Third and Quarter resolution render proportionally fewer pixels end to end. +**Colour and bit depth** (`crates/render/src/color.rs`, `crates/color/src/space.rs`). Pixels are +`f32`, but 8 and 16 bpc projects clamp and quantise each layer after its source and masks and +after every effect, and the comp after every layer (16 bpc uses 0..32768), so over-range values +(Add, Screen, Exposure…) only survive in 32 bpc; blend modes without HDR support clamp their +inputs in 32 bpc. With a working space (sRGB, Rec. 709, Rec. 2020, Display P3; matrices derived +from the published primaries), footage is converted from its colour profile (stream metadata or +Interpret Footage, else sRGB) and the top-level comp is converted to the sRGB display. Linearize +Working Space runs everything in linear light; Blend Colors Using 1.0 Gamma linearises only for +blending. Layer cache keys include these settings. + +**Frame blending**: footage and precomp layers whose source time falls between source frames +(rate conform, stretch, remap) blend the two neighbouring frames, by cross-fade (Frame Mix) or by +hierarchical block-matching optical flow and a bidirectional warp (Pixel Motion, +`raster::flow`). + +**Collapse Transformations**: a collapsed precomp layer without masks, effects or styles draws +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. On text and shape layers the switch is +Continuously Rasterize: the source is rasterised at its on-screen scale. Quality: Draft samples +nearest-neighbour, Wireframe draws the layer bounds. Slip edit (`layer.slip`, Alt+PageUp/Down, +dragging the source bar in the timeline) moves the source under fixed in/out points. + **Motion tracking** (Animation ▸ Track Motion / Stabilize Motion, Window ▸ Tracker): a tracker is a `Tracker` group under the layer's Motion Trackers group, with Track Point groups (Feature Center, Feature Size, Search Offset, Search Size, Confidence, Attach Point, Attach Point Offset) diff --git a/docs/control-protocol.md b/docs/control-protocol.md index f7c8b964..449a1898 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -81,7 +81,21 @@ Element ids are stable, for example `tools.Selection`, `panel.Timeline`, `panel. `header.workspace.Animation`, `project.item.`, `viewer.comp`, `viewer.handle..`, `timeline.layer..bar`, `timeline.layer..twirl`, `timeline.prop..stopwatch`, `timeline.key..`, `timeline.cti`, `effectControls.prop..value` and -`effects.item.`. Use `ui.elements` to see what is on screen. +`effects.item.`. The Composition viewer adds `viewer.magnification`, `viewer.resolution`, +`viewer.roi`, `viewer.grid`, `viewer.channel`, `viewer.exposure`, `viewer.snapshot`, +`viewer.showSnapshot`, `viewer.fastPreviews` (their popup entries are `viewer.

Item.`), +`viewer.ruler.top|left|origin`, `viewer.mask..vertex.`, `viewer.shapePath..vertex.`, +`viewer.motionPath..[.in|.out]`, `viewer.freeTransform.handle.` and +`viewer.regionOfInterest`; the Graph Editor adds `timeline.graph.transformBox[.]`, +`timeline.graph.snap` and `timeline.graph.reference`. Use `ui.elements` to see what is on screen. + +Viewer state that agents drive headless too: `view.snapping`, `view.channel {channel, colorized?}`, +`view.exposure {stops | delta}`, `view.resetExposure`, `view.takeSnapshot`, `view.showSnapshot`, +`view.fastPreviewMode {mode}`, `view.setRegionOfInterest {rect}`, `view.addGuide`, +`view.moveGuide`, `view.removeGuide`; editing: `shape.newPath` (Pen on shape layers), +`mask.insertVertex`, `mask.convertVertex`, `mask.deleteVertices`, `path.freeTransform` (masks and +shape paths by uid), `keys.setSpatialTangents` (motion-path handles) and `keys.transform` (Graph +Editor transform box, timeline Alt-drag scaling). The MCP server's bridge mode (`effectcraft-cli mcp --bridge 9877`) is a thin client of this protocol; see [agents.md](agents.md). diff --git a/docs/parity.md b/docs/parity.md index 13afaa2a..971b0345 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -63,17 +63,17 @@ rendering, on-canvas text editing) are where the conservative figure is most lik | Audio | 85% | 0.5 | audio to keyframes | | Import | 78% | 4.5 | PSD, SVG as shapes, Lottie | | Automation | 76% | 3.3 | a JavaScript scripting object model | -| Shapes | 68% | 3.9 | pen tool for shape paths, vertex editing, taper and wave strokes | +| Shapes | 68% | 3.9 | taper and wave strokes (pen tool for shape paths and vertex editing landed in M6.5) | | Compositions | 67% | 7.4 | marker dialog, flowchart, Essential Graphics | -| Animation | 65% | 10.4 | motion-path handles in the viewer, graph editor transform box, puppet, Wiggler/Smoother/Motion Sketch | +| Animation | 65% | 10.4 | puppet, Wiggler/Smoother/Motion Sketch (motion-path handles and the graph editor transform box landed in M5.8) | | Text | ≈ 85% | 1.5 | vertical type is basic (upright characters, no tate-chu-yoko), no OpenType feature panel, no text on 3D bevels; per-character styles, paragraph settings, on-canvas editing and the `sourceText` style API landed (M9.9–M9.10) | | Web | 60% | 2.5 | threads, browser storage, audio | | 3D | 58% | 20.5 | Advanced 3D (models, PBR, image-based light), 3D camera tracker, multi-view layouts | | Effects | 56% | 14.6 | Effect Controls widgets (angle dial, point crosshair, eyedropper, curves), GPU effects, 41 missing effects | -| Interface | 55% | 10.4 | drag-to-dock and floating panels, viewer rulers/snapping/channels/snapshots, preferences, native macOS menus | +| Interface | 55% | 10.4 | drag-to-dock and floating panels, preferences, native macOS menus (viewer rulers/snapping/channels/snapshots landed in M0.13) | | Project | 53% | 8.1 | 8/16/32-bit pipeline and colour management, auto-save, proxies, folder moves | -| Masks & roto | 44% | 13.0 | mask tracking, Roto Brush, free transform and RotoBezier | -| Preview | 43% | 8.1 | GPU compositor, disk cache, region of interest, snapshots and exposure | +| Masks & roto | 44% | 13.0 | mask tracking, Roto Brush, variable-width mask feather points | +| Preview | 43% | 8.1 | GPU compositor, disk cache (region of interest, snapshots, exposure and Fast Previews landed in M0.13) | | Tracking | 0% | 13.0 | point tracker, Warp Stabilizer, mask and face tracking | | Paint | 0% | 4.0 | Brush, Clone Stamp, Eraser | @@ -89,10 +89,10 @@ Mocha and Cineware are third-party and not counted. 1. ~~On-canvas text editing and per-character styles~~ (landed: M9.9–M9.10). 2. Effect Controls widgets: angle dial, point crosshair, eyedropper, curves and levels editors. -3. Viewer basics: snapping, rulers, channel view, snapshots, exposure, a drawable region of interest. +3. ~~Viewer basics: snapping, rulers, channel view, snapshots, exposure, a drawable region of interest.~~ (M0.13) 4. A GPU (wgpu) compositor, then GPU effects. -5. Pen tool for shape paths, shape vertex editing, the Layer viewer, free transform and RotoBezier. -6. Motion-path handles in the viewer; graph editor transform box and snapping. +5. ~~Pen tool for shape paths, shape vertex editing, free transform~~ (M6.5); the Layer viewer. +6. ~~Motion-path handles in the viewer; graph editor transform box and snapping.~~ (M5.8) 7. Point tracking and stabilization (in progress). 8. Frame blending, collapse transformations, slip edit. 9. A real 8/16/32-bit pipeline with linear blending and colour management. diff --git a/packaging/icons.sh b/packaging/icons.sh new file mode 100755 index 00000000..afeb13f8 --- /dev/null +++ b/packaging/icons.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Regenerate every app icon file from assets/app-icon/effectcraft.svg (the canonical vector). +# +# Needs: resvg (brew install resvg / cargo install resvg). On macOS, iconutil also writes the +# .icns. The outputs are committed, so builds and packaging never need these tools. +# +# packaging/icons.sh +# +# effectcraft.svg and effectcraft-small.svg (the lighter 1024 px trace) come from craftrules +# (assets/app-icons/effectcraft/, which also keeps the source drawing) and are copied in by hand. +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DIR="$ROOT/assets/app-icon" +SVG="$DIR/effectcraft.svg" +ID="ai.storyteller.effectcraft" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +command -v resvg >/dev/null || { echo "error: resvg not found (brew install resvg)" >&2; exit 1; } + +render() { resvg -w "$2" -h "$2" "$1" "$3" ||' \ + -e 's|[[:space:]]*$||' "$SVG" >"$MAC" +grep -q 'viewBox="0 0 1024 1024"' "$MAC" || { echo "error: unexpected header in $SVG" >&2; exit 1; } + +render "$SVG" 1024 "$DIR/effectcraft-1024.png" +# Runtime window/Dock icon on macOS (apps/effectcraft/src/main.rs embeds it). +render "$MAC" 512 "$DIR/effectcraft-macos-512.png" + +# Linux hicolor theme (the 256 px one is also the runtime icon on Windows and Linux). +for s in 16 24 32 48 64 128 256 512; do + mkdir -p "$DIR/hicolor/${s}x${s}/apps" + render "$SVG" "$s" "$DIR/hicolor/${s}x${s}/apps/$ID.png" +done +mkdir -p "$DIR/hicolor/scalable/apps" +cp "$DIR/effectcraft-small.svg" "$DIR/hicolor/scalable/apps/$ID.svg" + +# Windows .ico. +ICO_PNGS=() +for s in 16 20 24 32 40 48 64 128 256; do + render "$SVG" "$s" "$TMP/ico-$s.png" + ICO_PNGS+=("$TMP/ico-$s.png") +done +(cd "$ROOT" && cargo run -q -p xtask -- ico "$DIR/effectcraft.ico" "${ICO_PNGS[@]}") + +# macOS .icns. +if command -v iconutil >/dev/null; then + SET="$TMP/effectcraft.iconset" + mkdir -p "$SET" + for s in 16 32 128 256 512; do + render "$MAC" "$s" "$SET/icon_${s}x${s}.png" + render "$MAC" $((s * 2)) "$SET/icon_${s}x${s}@2x.png" + done + iconutil -c icns -o "$DIR/effectcraft.icns" "$SET" +else + echo "warning: iconutil not found (macOS only); effectcraft.icns not regenerated" >&2 +fi +echo "icons written to $DIR" diff --git a/packaging/linux/ai.storyteller.effectcraft.desktop b/packaging/linux/ai.storyteller.effectcraft.desktop new file mode 100644 index 00000000..f7cf2dce --- /dev/null +++ b/packaging/linux/ai.storyteller.effectcraft.desktop @@ -0,0 +1,13 @@ +[Desktop Entry] +Type=Application +Name=EffectCraft +GenericName=Motion Graphics Editor +Comment=Motion graphics and visual effects compositor +Exec=effectcraft %F +TryExec=effectcraft +Icon=ai.storyteller.effectcraft +Terminal=false +StartupNotify=true +StartupWMClass=ai.storyteller.effectcraft +Categories=AudioVideo;Video;Graphics;2DGraphics; +Keywords=motion;graphics;animation;compositor;effects;keyframe;vfx; diff --git a/xtask/src/ico.rs b/xtask/src/ico.rs new file mode 100644 index 00000000..a8ecd9b7 --- /dev/null +++ b/xtask/src/ico.rs @@ -0,0 +1,104 @@ +//! `cargo xtask ico …`: pack PNG images into a Windows `.ico`. +//! +//! Vista and later read PNG-compressed icon entries, so each PNG is stored as is. The size comes +//! from the PNG's IHDR chunk; 256 px is written as 0, per the ICO format. + +use std::path::Path; + +/// Width and height from a PNG's IHDR chunk. +pub fn png_size(png: &[u8]) -> Result<(u32, u32), String> { + const SIG: &[u8] = b"\x89PNG\r\n\x1a\n"; + if png.len() < 24 || &png[..8] != SIG || &png[12..16] != b"IHDR" { + return Err("not a PNG file".into()); + } + let w = u32::from_be_bytes([png[16], png[17], png[18], png[19]]); + let h = u32::from_be_bytes([png[20], png[21], png[22], png[23]]); + Ok((w, h)) +} + +/// An ICO file holding `pngs`, which must be square and at most 256 px. +pub fn pack(pngs: &[Vec]) -> Result, String> { + if pngs.is_empty() || pngs.len() > u16::MAX as usize { + return Err("need at least one PNG".into()); + } + let mut out = Vec::new(); + out.extend_from_slice(&0u16.to_le_bytes()); // reserved + out.extend_from_slice(&1u16.to_le_bytes()); // type: icon + out.extend_from_slice(&(pngs.len() as u16).to_le_bytes()); + let mut offset = 6 + 16 * pngs.len(); + for png in pngs { + let (w, h) = png_size(png)?; + if w != h || w == 0 || w > 256 { + return Err(format!("icon images must be square and at most 256 px, got {w}x{h}")); + } + let dim = if w == 256 { 0 } else { w as u8 }; + out.extend_from_slice(&[dim, dim, 0, 0]); // width, height, palette size, reserved + out.extend_from_slice(&1u16.to_le_bytes()); // colour planes + out.extend_from_slice(&32u16.to_le_bytes()); // bits per pixel + out.extend_from_slice(&(png.len() as u32).to_le_bytes()); + out.extend_from_slice(&(offset as u32).to_le_bytes()); + offset += png.len(); + } + for png in pngs { + out.extend_from_slice(png); + } + Ok(out) +} + +pub fn run(args: &[&str]) -> Result<(), String> { + let [out, inputs @ ..] = args else { + return Err("usage: cargo xtask ico …".into()); + }; + let pngs = inputs.iter().map(|p| std::fs::read(p).map_err(|e| format!("read {p}: {e}"))).collect::, _>>()?; + let ico = pack(&pngs)?; + std::fs::write(Path::new(out), &ico).map_err(|e| format!("write {out}: {e}"))?; + println!("{out}: {} images, {} bytes", pngs.len(), ico.len()); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fake_png(size: u32) -> Vec { + let mut v = b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDR".to_vec(); + v.extend_from_slice(&size.to_be_bytes()); + v.extend_from_slice(&size.to_be_bytes()); + v.extend_from_slice(&[8, 6, 0, 0, 0, 1, 2, 3, 4]); + v + } + + #[test] + fn packs_directory_and_payloads() { + let a = fake_png(16); + let b = fake_png(256); + let ico = pack(&[a.clone(), b.clone()]).unwrap(); + assert_eq!(&ico[..6], &[0, 0, 1, 0, 2, 0]); + // Entry 0: 16 px at offset 6 + 32. + assert_eq!(&ico[6..8], &[16, 16]); + assert_eq!(u32::from_le_bytes(ico[14..18].try_into().unwrap()), a.len() as u32); + assert_eq!(u32::from_le_bytes(ico[18..22].try_into().unwrap()), 38); + // Entry 1: 256 px stored as 0, right after the first payload. + assert_eq!(&ico[22..24], &[0, 0]); + assert_eq!(u32::from_le_bytes(ico[34..38].try_into().unwrap()), 38 + a.len() as u32); + assert_eq!(&ico[38..38 + a.len()], &a[..]); + assert_eq!(&ico[38 + a.len()..], &b[..]); + } + + #[test] + fn rejects_bad_input() { + assert!(pack(&[]).is_err()); + assert!(pack(&[fake_png(512)]).is_err()); + assert!(pack(&[b"GIF89a".to_vec()]).is_err()); + assert_eq!(png_size(&fake_png(48)).unwrap(), (48, 48)); + } + + #[test] + fn committed_icon_is_valid() { + let path = crate::root().join("assets/app-icon/effectcraft.ico"); + let Ok(ico) = std::fs::read(&path) else { return }; + assert_eq!(&ico[..4], &[0, 0, 1, 0]); + let n = u16::from_le_bytes([ico[4], ico[5]]) as usize; + assert!(n >= 4, "expected several sizes in {}", path.display()); + } +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 323b9ca7..9a04da13 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -1,4 +1,4 @@ -//! Workspace automation: `cargo xtask `. +//! Workspace automation: `cargo xtask `. //! //! - `layers`: enforces the dependency layering of `docs/architecture.md` §1 (downward-only edges, //! listed same-layer edges, no UI/OS crates below L5). @@ -8,8 +8,11 @@ //! `/web/dist` with `wasm-bindgen` (docs/web.md); `--serve` serves it on localhost. //! - `assets`: every asset file (image, icon, font, LUT, audio, video…) has a complete //! `.attribution` sidecar and an entry in `ATTRIBUTION.md` (AGENTS.md §1). +//! - `ico …`: pack PNGs into a Windows `.ico` (used by `packaging/icons.sh`). //! - `ci`: fmt check, clippy -D warnings, tests, layers, assets, wasm. +mod ico; + use std::process::{Command, ExitCode}; use serde_json::Value; @@ -357,7 +360,11 @@ fn main() -> ExitCode { "assets" => assets(), "ci" => ci(), "web" => web(&std::env::args().skip(2).collect::>()), - _ => Err("usage: cargo xtask ".into()), + "ico" => { + let rest: Vec = std::env::args().skip(2).collect(); + ico::run(&rest.iter().map(String::as_str).collect::>()) + } + _ => Err("usage: cargo xtask ".into()), }; match r { Ok(()) => ExitCode::SUCCESS, @@ -367,3 +374,8 @@ fn main() -> ExitCode { } } } + +/// The workspace root (the parent of `xtask/`). +pub fn root() -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).parent().expect("xtask has a parent dir").to_path_buf() +}