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