Merge main into text editing (M9.10)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 10:46:31 -04:00
commit 460e650bfc
104 changed files with 6334 additions and 391 deletions

View File

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

19
Cargo.lock generated
View File

@ -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"

View File

@ -7,6 +7,10 @@
</a>
</p>
<p align="center">
<img alt="EffectCraft app icon: an engraved unicorn on EffectCraft pink (#e0368f)" src="assets/app-icon/effectcraft-1024.png" width="128">
</p>
<h1 align="center">EffectCraft</h1>
<p align="center">

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 558 B

After

Width:  |  Height:  |  Size: 343 KiB

View File

@ -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.

View File

@ -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 }

27
apps/effectcraft/build.rs Normal file
View File

@ -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}");
}
}

View File

@ -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<u16> = 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()
};

View File

@ -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.

42
assets/app-icon/README.md Normal file
View File

@ -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/<n>x<n>/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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 KiB

View File

@ -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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

View File

@ -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.

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 343 KiB

View File

@ -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.

Binary file not shown.

View File

@ -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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

View File

@ -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).

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 682 KiB

View File

@ -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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 733 B

View File

@ -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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

View File

@ -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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

@ -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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

View File

@ -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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

@ -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.

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 343 KiB

View File

@ -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.

View File

@ -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 {

View File

@ -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)]

273
crates/color/src/space.rs Normal file
View File

@ -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<ColorSpace> {
let k: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().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<ColorSpace>,
/// Linear RGB matrix (None = identity primaries).
pub matrix: Option<[[f32; 3]; 3]>,
/// Encode the output with this space's curve last.
pub encode: Option<ColorSpace>,
}
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<Conversion> {
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));
}
}

View File

@ -521,6 +521,10 @@ fn select_label_group(s: &mut Session, _: &Value) -> Result<Value> {
fn purge_caches(s: &mut Session, p: &Value) -> Result<Value> {
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}))

View File

@ -140,6 +140,18 @@ fn project_settings(s: &mut Session, p: &Value) -> Result<Value> {
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<CommandSpec> {
"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
),

View File

@ -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<Value> {
};
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<Value> {
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<CommandSpec> {
"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
),

View File

@ -42,6 +42,7 @@ pub fn specs() -> Vec<CommandSpec> {
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),

View File

@ -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<Value> {
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<Vec<usize>> = 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<Tick>> = 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<Keyframe>, Vec<Keyframe>) = 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<Value> {
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<CommandSpec> {
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
),
]
}

View File

@ -607,6 +607,57 @@ fn timing(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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::<Vec<_>>()}))
}
fn arrange(s: &mut Session, p: &Value) -> Result<Value> {
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<CommandSpec> {
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!(

View File

@ -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<usize> {
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<T>(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut ShapePath) -> Result<T>) -> Result<(Uid, T)> {
let (cid, lid) = layer_p(s, p, label)?;
@ -41,8 +60,7 @@ fn edit_path<T>(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<Value> {
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<Value> {
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<usize> {
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<Value> {
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<Value> {
let (cid, lid) = layer_p(s, p, "mask.remove")?;
let key = p.get("mask").cloned();
@ -263,6 +368,8 @@ pub fn specs() -> Vec<CommandSpec> {
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)),
]

View File

@ -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());

View File

@ -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<Value> {
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 };

View File

@ -53,7 +53,6 @@ pub fn specs() -> Vec<CommandSpec> {
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}"),

View File

@ -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<FastPreviews> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
s.state.viewer.exposure = 0.0;
Ok(json!(0.0))
}
fn take_snapshot(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<u8>)> {
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<CommandSpec> {
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
),
]
}

View File

@ -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<effectcraft_keyframe::TextDoc>,
/// 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<shortcuts::ShortcutTable>,
/// Auto-save bookkeeping.
pub autosave: autosave::AutoSaveState,
/// The viewer snapshot (Take Snapshot / Show Snapshot).
pub snapshot: Option<viewer::Snapshot>,
}
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.

View File

@ -165,6 +165,7 @@ pub fn checked(s: &Session, command: &str, params: &Value) -> Option<bool> {
"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

View File

@ -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());
}

View File

@ -256,6 +256,7 @@ impl Importer for MockImporter {
codec: "mock".into(),
missing: false,
sequence: vec![],
color_profile: None,
})
}
}

View File

@ -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();

View File

@ -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<String> = 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<f64> = 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<_>>(), 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());
}

372
crates/engine/src/viewer.rs Normal file
View File

@ -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<SnapTarget>,
/// 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<Snap> {
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<SnapTarget> {
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<Channel> {
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<Image>,
}

View File

@ -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;

View File

@ -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);

View File

@ -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);

View File

@ -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<effectcraft_project::ColorSpace> {
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,
}
}

View File

@ -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<f32> {
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<ColorSpace>,
/// 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<String>,
/// Colour profile (from the file's metadata, or Interpret Footage). `None` = sRGB.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color_profile: Option<ColorSpace>,
}
fn one() -> u32 {

290
crates/raster/src/flow.rs Normal file
View File

@ -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<f32>,
}
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::<f32>();
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}");
}
}

View File

@ -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};

View File

@ -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));

View File

@ -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);

114
crates/render/src/color.rs Normal file
View File

@ -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<f32>,
/// Working space (`None` = unmanaged).
pub space: Option<ColorSpace>,
/// 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<ColorSpace>) -> Option<Conversion> {
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<Conversion> {
self.linear.then(|| Conversion::linearize(self.curve()))
}
/// Working space → blending space (linear when blending with 1.0 gamma).
pub fn to_blend(&self) -> Option<Conversion> {
self.linear_blend.then(|| Conversion::linearize(self.curve()))
}
/// Blending space → working space.
pub fn from_blend(&self) -> Option<Conversion> {
self.linear_blend.then(|| Conversion::delinearize(self.curve()))
}
/// Working space → sRGB display (top-level comp output).
pub fn output(&self) -> Option<Conversion> {
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];
});
}

View File

@ -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<three_d::CameraState>,
/// 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<Vec<LayerTiming>>>,
/// 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<Mat3>,
/// 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<Collapse3d<'a>>,
}
/// 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<ItemId> {
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: &params, 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<Arc<Image>> {
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<Buf> {
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<Arc<Buf>> {
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<Arc<Buf>> {
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<Buf>, key: Option<u64>) -> Arc<Buf> {
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<Buf>, Option<u64>)> {
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<Buf>, Option<u64>)> {
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<Arc<Buf>> {
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<StyledLayer> {
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<Vec<_>> = 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<Buf>,
/// Pre-style pixels (knockout shape).
content: Arc<Buf>,
/// Styled cache key.
/// Styled cache key (the content key when plain).
key: Option<u64>,
/// Content cache key.
ckey: Option<u64>,
/// 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;

View File

@ -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<Mat3> {
fn walk(ctx: &EvalCtx, layer: &Layer, g: &PropGroup, uid: u64, m: Mat3) -> Option<Mat3> {
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 }
}

View File

@ -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)

View File

@ -50,6 +50,7 @@ fn audio_footage() -> Footage {
codec: String::new(),
missing: false,
sequence: vec![],
color_profile: None,
}
}

View File

@ -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));
}

View File

@ -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<Arc<Image>> {
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<ColorSpace>) -> 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<ColorSpace>, profile: Option<ColorSpace>| {
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);
}

View File

@ -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<Arc<Image>> {
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::<f32>();
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);
}

View File

@ -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));
}

View File

@ -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<Item<'a>> {
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<Item> = 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<Item<'a>> {
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.

View File

@ -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)

View File

@ -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);

View File

@ -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)]);
}
}
}

View File

@ -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 {

View File

@ -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),

View File

@ -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),
],
)
}

View File

@ -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<Pos2>)> = 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<Pos2> = 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<super::graph_tools::SelKey> = 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<f64> = 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()?;

View File

@ -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<BoxDrag> = None;
let mut resp_any: Option<egui::Response> = 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::<BoxDrag>(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<usize> = 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::<BoxDrag>(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<f64> = 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::<AltScale>(alt_id())) else { return };
if !ctx.input(|i| i.pointer.primary_down()) {
ctx.data_mut(|d| d.remove::<AltScale>(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::<AltScale>(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<Pos2>)]) {
for (c, pts) in curves {
p.add(egui::Shape::line(pts.clone(), Stroke::new(1.0, c.gamma_multiply(0.35))));
}
let _ = plot;
}

View File

@ -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;

View File

@ -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();

View File

@ -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::<f32>(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);

View File

@ -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<bool>,
/// 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<VertexHit> = Vec::new();
let mut key_hits: Vec<ov::KeyHit> = Vec::new();
let mut paths: Vec<ov::PathInfo> = 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<Pos2> = 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<LayerId> = 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<serde_json::Value> = 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::<Gesture>(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<String> = ["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::<f32>().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<String> = 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<String> = 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();

View File

@ -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<bool>,
/// 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<PathInfo> {
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<Pos2> {
let k = effectcraft_engine::render::kurbo_path(sp);
let mut pts: Vec<Pos2> = 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<Pos2>,
hits: &mut Vec<VertexHit>,
paths: &mut Vec<PathInfo>,
) {
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<bool>,
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<KeyHit>) {
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<FtBox> {
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<usize> = app.session.state.selected_vertices.iter().filter(|v| v.layer == layer && v.mask == uid).map(|v| v.index).collect();
let idx: Vec<usize> = 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<Drag> {
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<Drag> {
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<Drag> {
let (layer, uid, _, _) = segment_at(&paths.iter().filter(|p| p.is_mask).cloned().collect::<Vec<_>>(), 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<Drag> {
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,
}
}

View File

@ -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()));

View File

@ -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>(snap_id())) else { return };
ctx.data_mut(|d| d.remove::<Snap>(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<Pos2> = [[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::<Option<[f64; 4]>>(egui::Id::new("viewer-tex-roi"))).flatten()
}
/// Show Snapshot: the stored snapshot is on screen (button or F5 held).
pub(crate) fn showing_snapshot(app: &EffectcraftApp, ctx: &egui::Context) -> bool {
let f5 = ctx.input(|i| i.key_down(egui::Key::F5)) && !ctx.egui_wants_keyboard_input();
app.session.snapshot.is_some() && (app.session.state.viewer.show_snapshot || f5)
}
fn transformed(img: &egui::ColorImage, ch: Channel, colorized: bool, stops: f32) -> egui::ColorImage {
let mut px: Vec<[u8; 4]> = img.pixels.iter().map(|c| c.to_array()).collect();
vw::display_transform(&mut px, ch, colorized, stops);
egui::ColorImage::new(img.size, px.into_iter().map(|a| Color32::from_rgba_premultiplied(a[0], a[1], a[2], a[3])).collect())
}
/// Draw the frame into `comp_rect`: the rendered frame (covering the region of interest when one
/// is set), or the snapshot while Show Snapshot is on / F5 is held, through Show Channel and the
/// exposure. Fast Previews ▸ Wireframe draws layer outlines instead.
pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter: &egui::Painter, comp_rect: Rect, cid: ItemId, ectx: &EvalCtx) {
let opts = app.session.state.viewer.clone();
let snap = showing_snapshot(app, ctx);
if opts.fast_previews == FastPreviews::Wireframe && !snap {
let zoom = comp_rect.width() / ectx.comp.width.max(1) as f32;
let map = ViewerMap { origin: comp_rect.min, zoom, comp: [ectx.comp.width as f32, ectx.comp.height as f32], area: comp_rect };
for l in ectx.comp.layers.iter().filter(|l| l.is_active_at(ectx.time) && l.has_video()) {
let Some(b) = effectcraft_engine::render::content_bounds(ectx, l) else { continue };
let (m, _) = super::viewer::l2c(ectx, l);
let q: Vec<Pos2> = [[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]]]
.iter()
.map(|p| {
let c = m.apply(effectcraft_engine::geom::vec2(p[0], p[1]));
map.to_screen([c.x, c.y])
})
.collect();
painter.add(egui::Shape::closed_line(q, Stroke::new(1.0, app.tokens.label(l.label))));
}
return;
}
let plain = opts.channel == Channel::Rgb && opts.exposure == 0.0;
let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0));
if snap {
let Some(s) = app.session.snapshot.clone() else { return };
let key = {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
(std::sync::Arc::as_ptr(&s.image) as usize, opts.channel.id(), opts.colorized, opts.exposure.to_bits()).hash(&mut h);
h.finish()
};
let id = egui::Id::new("viewer-snapshot-tex");
let tex = match ctx.data(|d| d.get_temp::<(u64, egui::TextureHandle)>(id)) {
Some((k, tex)) if k == key => tex,
_ => {
let img = crate::frames::to_color_image(&s.image);
let img = if plain { img } else { transformed(&img, opts.channel, opts.colorized, opts.exposure) };
let tex = ctx.load_texture("viewer-snapshot", img, egui::TextureOptions::LINEAR);
ctx.data_mut(|d| d.insert_temp(id, (key, tex.clone())));
tex
}
};
painter.image(tex.id(), comp_rect, uv, Color32::WHITE);
painter.text(comp_rect.left_top() + vec2(6.0, 6.0), Align2::LEFT_TOP, "Snapshot", Tokens::ui(11.0), SNAP_COLOR);
return;
}
let Some((tex, k)) = app.viewer_tex.clone() else { return };
if k.comp != cid.0 {
return;
}
let zoom = comp_rect.width() as f64 / ectx.comp.width.max(1) as f64;
let rect = match texture_roi(ctx) {
Some([x, y, w, h]) => Rect::from_min_size(comp_rect.min + vec2((x * zoom) as f32, (y * zoom) as f32), vec2((w * zoom) as f32, (h * zoom) as f32)),
None => comp_rect,
};
if plain {
painter.image(tex.id(), rect, uv, Color32::WHITE);
return;
}
let Some(src) = app.viewer_image.clone() else { return };
let key = {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
(std::sync::Arc::as_ptr(&src) as usize, opts.channel.id(), opts.colorized, opts.exposure.to_bits()).hash(&mut h);
h.finish()
};
let id = egui::Id::new("viewer-display-tex");
let tex = match ctx.data(|d| d.get_temp::<(u64, egui::TextureHandle)>(id)) {
Some((k, tex)) if k == key => tex,
_ => {
let tex = ctx.load_texture("viewer-display", transformed(&src, opts.channel, opts.colorized, opts.exposure), egui::TextureOptions::LINEAR);
ctx.data_mut(|d| d.insert_temp(id, (key, tex.clone())));
tex
}
};
painter.image(tex.id(), rect, uv, Color32::WHITE);
}
// ---------------------------------------------------------------- bottom bar
/// A popup list above `anchor` (selected entries highlighted); entries register
/// `viewer.<auto>.<n>` automation ids.
fn popup(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: &str, anchor: Rect, items: &[(String, bool)], auto: &str) -> Option<usize> {
let pid = egui::Id::new(id);
let open: bool = ui.data(|d| d.get_temp(pid.with("open")).unwrap_or(false));
if !open {
return None;
}
let h: f32 = items.iter().map(|(l, _)| if l == "-" { 9.0 } else { 21.0 }).sum::<f32>() + 14.0;
let pos = anchor.left_top() - vec2(0.0, h + 4.0);
let mut chosen = None;
let area = egui::Area::new(pid.with("area")).order(egui::Order::Foreground).fixed_pos(pos).show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_min_width(170.0);
for (i, (label, on)) in items.iter().enumerate() {
if label == "-" {
ui.separator();
continue;
}
let r = ui.selectable_label(*on, label.as_str());
app.auto.add(&format!("viewer.{auto}.{i}"), r.rect, label);
if r.clicked() {
chosen = Some(i);
}
}
});
});
let outside = ui.input(|i| i.pointer.any_pressed())
&& !area.response.contains_pointer()
&& !anchor.contains(ui.input(|i| i.pointer.interact_pos()).unwrap_or_default());
if chosen.is_some() || outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
ui.data_mut(|d| d.insert_temp(pid.with("open"), false));
}
chosen
}
fn toggle_popup(ui: &egui::Ui, id: &str) {
let pid = egui::Id::new(id).with("open");
let open: bool = ui.data(|d| d.get_temp(pid).unwrap_or(false));
ui.data_mut(|d| d.insert_temp(pid, !open));
}
/// Magnification as After Effects shows it: "136.1 %", "100 %".
pub(crate) fn magnification_label(pct: f32) -> String {
if (pct - pct.round()).abs() < 0.05 { format!("{:.0} %", pct.round()) } else { format!("{pct:.1} %") }
}
/// The resolution popup's label: the chosen factor, or the factor Auto resolves to in brackets.
pub(crate) fn resolution_label(res: Resolution, scale: f64) -> String {
match res {
Resolution::Auto => {
let r = if scale >= 0.99 {
"Full"
} else if scale >= 0.49 {
"Half"
} else if scale >= 0.33 {
"Third"
} else {
"Quarter"
};
format!("({r})")
}
Resolution::Custom(n) => format!("Custom ({n})"),
r => r.label().to_string(),
}
}
/// The Composition panel's bottom bar, in After Effects 2026 order: magnification, resolution,
/// transparency grid, mask and shape path visibility, region of interest, grid and guide
/// options, show channel, reset exposure, exposure, take / show snapshot, Fast Previews, the 3D
/// renderer and view, and the current time.
pub(crate) fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, zoom: f32, fit: f32, time: Tick, comp: &Comp) {
let t = app.tokens;
let p = ui.painter().clone();
p.rect_filled(bar, 0.0, t.panel_bg);
p.line_segment([bar.left_top(), bar.right_top()], Stroke::new(1.0, t.separator));
let cy = bar.center().y;
let mut x = bar.min.x + 8.0;
let ppp = ui.ctx().pixels_per_point();
// Magnification.
let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(80.0, 20.0));
if widgets::dropdown(ui, r, &magnification_label(zoom * ppp * 100.0), &t, egui::Id::new("vw-mag")).on_hover_text("Magnification ratio popup").clicked() {
toggle_popup(ui, "vw-mag-pop");
}
app.auto.add("viewer.magnification", r, "Magnification");
const MAGS: [&str; 15] = ["Fit", "Fit up to 100%", "-", "1.5%", "3.1%", "6.25%", "12.5%", "25%", "33.3%", "50%", "100%", "200%", "400%", "800%", "1600%"];
let cur = (zoom * ppp * 100.0 * 10.0).round() / 10.0;
let items: Vec<(String, bool)> = MAGS
.iter()
.map(|m| {
let on = match *m {
"Fit" => app.ui.viewer.zoom.is_none(),
s => s.trim_end_matches('%').parse::<f32>().is_ok_and(|v| app.ui.viewer.zoom.is_some() && (v - cur).abs() < 0.06),
};
(m.to_string(), on)
})
.collect();
if let Some(i) = popup(app, ui, "vw-mag-pop", r, &items, "magnification") {
app.ui.viewer.pan = [0.0, 0.0];
app.ui.viewer.zoom = match MAGS[i] {
"Fit" => None,
"Fit up to 100%" => Some(fit.min(1.0 / ppp)),
s => s.trim_end_matches('%').parse::<f32>().ok().map(|v| v / 100.0 / ppp),
};
}
x = r.max.x + 4.0;
// Resolution / down-sample factor.
let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(84.0, 20.0));
let scale = app.viewer_scale(zoom, ppp);
if widgets::dropdown(ui, r, &resolution_label(app.ui.viewer.res, scale), &t, egui::Id::new("vw-res"))
.on_hover_text("Resolution/Down Sample Factor Popup")
.clicked()
{
toggle_popup(ui, "vw-res-pop");
}
app.auto.add("viewer.resolution", r, "Resolution/Down Sample Factor");
let res = app.ui.viewer.res;
let items: Vec<(String, bool)> = vec![
("Full".into(), res == Resolution::Full),
("Half".into(), res == Resolution::Half),
("Third".into(), res == Resolution::Third),
("Quarter".into(), res == Resolution::Quarter),
("Custom...".into(), matches!(res, Resolution::Custom(_))),
("-".into(), false),
("Auto".into(), res == Resolution::Auto),
];
if let Some(i) = popup(app, ui, "vw-res-pop", r, &items, "resolutionItem") {
let ctx = ui.ctx().clone();
let id = ["view.res.full", "view.res.half", "view.res.third", "view.res.quarter", "view.res.custom", "", "view.res.auto"][i];
if let Err(e) = crate::menus::invoke(app, &ctx, id, json!({})) {
app.ui.status = e;
}
}
x = r.max.x + 6.0;
let tog = |ui: &mut egui::Ui, auto: &mut crate::automation::Registry, x: &mut f32, icon: Icon, on: bool, id: &str, tip: &str| -> (bool, Rect) {
let r = Rect::from_min_size(pos2(*x, cy - 11.0), vec2(22.0, 22.0));
let resp = widgets::icon_button(ui, r, icon, on, &t, egui::Id::new(("vw-btn", id))).on_hover_text(tip);
auto.add(&format!("viewer.{id}"), r, tip);
*x += 24.0;
(resp.clicked(), r)
};
if tog(ui, &mut app.auto, &mut x, Icon::Checker, app.ui.viewer.transparency_grid, "transparency", "Toggle Transparency Grid").0 {
app.ui.viewer.transparency_grid = !app.ui.viewer.transparency_grid;
}
if tog(ui, &mut app.auto, &mut x, Icon::MaskVis, app.ui.viewer.show_masks, "masks", "Toggle Mask and Shape Path Visibility").0 {
app.ui.viewer.show_masks = !app.ui.viewer.show_masks;
}
// Region of interest: with none, arm drawing (the next drag draws it); with one, clear it.
let has_roi = app.session.state.region_of_interest.is_some();
if tog(ui, &mut app.auto, &mut x, Icon::Region, has_roi || app.ui.viewer.roi_draw, "roi", "Region of Interest").0 {
if has_roi {
let _ = app.session.execute("view.setRegionOfInterest", json!({"rect": null}));
app.ui.viewer.roi_draw = false;
} else {
app.ui.viewer.roi_draw = !app.ui.viewer.roi_draw;
}
}
// Grid and guide options.
let v = &app.ui.viewer;
let any = v.grid || v.safe_margins || v.proportional_grid || (v.guides && !comp.guides.is_empty()) || v.rulers;
let (clicked, gr) = tog(ui, &mut app.auto, &mut x, Icon::Grid, any, "grid", "Choose grid and guide options");
if clicked {
toggle_popup(ui, "vw-grid-pop");
}
let v = &app.ui.viewer;
let items = vec![
("Title/Action Safe".to_string(), v.safe_margins),
("Proportional Grid".to_string(), v.proportional_grid),
("Grid".to_string(), v.grid),
("Guides".to_string(), v.guides),
("Rulers".to_string(), v.rulers),
];
if let Some(i) = popup(app, ui, "vw-grid-pop", gr, &items, "gridItem") {
let v = &mut app.ui.viewer;
match i {
0 => v.safe_margins = !v.safe_margins,
1 => v.proportional_grid = !v.proportional_grid,
2 => v.grid = !v.grid,
3 => v.guides = !v.guides,
_ => v.rulers = !v.rulers,
}
}
// Show Channel and Color Management Settings.
let vo = app.session.state.viewer.clone();
let (clicked, cr) = tog(ui, &mut app.auto, &mut x, Icon::Channel, vo.channel != Channel::Rgb, "channel", "Show Channel and Color Management Settings");
if clicked {
toggle_popup(ui, "vw-channel-pop");
}
let mut items: Vec<(String, bool)> = Channel::ALL.iter().map(|c| (c.label().to_string(), vo.channel == *c)).collect();
items.push(("-".into(), false));
items.push(("Colorize".into(), vo.colorized));
if let Some(i) = popup(app, ui, "vw-channel-pop", cr, &items, "channelItem") {
let params =
if i < Channel::ALL.len() { json!({"channel": Channel::ALL[i].id()}) } else { json!({"channel": vo.channel.id(), "colorized": !vo.colorized}) };
let _ = app.session.execute("view.channel", params);
}
// Exposure.
let exposed = vo.exposure != 0.0;
if tog(ui, &mut app.auto, &mut x, Icon::Exposure, exposed, "resetExposure", "Reset Exposure").0 {
let _ = app.session.execute("view.resetExposure", json!({}));
}
let er = Rect::from_min_size(pos2(x, cy - 10.0), vec2(42.0, 20.0));
let mut ev = vo.exposure as f64;
let resp = ui.put(
er,
egui::DragValue::new(&mut ev)
.speed(0.05)
.range(-40.0..=40.0)
.fixed_decimals(1)
.custom_formatter(|v, _| if v == 0.0 { "0.0".into() } else { format!("{v:+.1}") }),
);
let resp = resp.on_hover_text("Adjust Exposure (stops)");
app.auto.add("viewer.exposure", er, "Adjust Exposure");
if resp.changed() {
let _ = app.session.execute("view.exposure", json!({"stops": ev}));
}
x = er.max.x + 4.0;
// Snapshots.
if tog(ui, &mut app.auto, &mut x, Icon::Snapshot, false, "snapshot", "Take Snapshot").0 {
let scale = app.viewer_scale(zoom, ppp);
match app.session.execute("view.takeSnapshot", json!({"scale": scale.clamp(0.05, 1.0)})) {
Ok(_) => app.ui.status = "Snapshot taken".into(),
Err(e) => app.ui.status = e.to_string(),
}
}
let showing = showing_snapshot(app, ui.ctx());
let (_, sr) = tog(ui, &mut app.auto, &mut x, Icon::ShowSnapshot, showing, "showSnapshot", "Show Snapshot (hold)");
// Show Snapshot works while the button is held, like F5.
let held = ui.input(|i| i.pointer.primary_down()) && ui.input(|i| i.pointer.press_origin()).is_some_and(|o| sr.contains(o));
if app.session.snapshot.is_some() && held != app.session.state.viewer.show_snapshot {
let _ = app.session.execute("view.showSnapshot", json!({"value": held}));
}
// Fast Previews.
let fp = vo.fast_previews;
let (clicked, fr) = tog(ui, &mut app.auto, &mut x, Icon::Sparkle, fp != FastPreviews::Off || app.ui.viewer.fast_preview, "fastPreviews", "Fast Previews");
if clicked {
toggle_popup(ui, "vw-fast-pop");
}
let items: Vec<(String, bool)> = FastPreviews::ALL.iter().map(|m| (m.label().to_string(), fp == *m)).collect();
if let Some(i) = popup(app, ui, "vw-fast-pop", fr, &items, "fastPreviewsItem") {
let _ = app.session.execute("view.fastPreviewMode", json!({"mode": FastPreviews::ALL[i].id()}));
}
x += 4.0;
// 3D renderer + view.
if comp.has_3d() {
let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(96.0, 20.0));
let _ = widgets::dropdown(ui, r, "Classic 3D", &t, egui::Id::new("vw-3d"));
app.auto.add("viewer.renderer3d", r, "3D Renderer");
x = r.max.x + 4.0;
let cid = app.session.active_comp_id();
let cur = cid.and_then(|c| app.session.state.views3d.get(&c)).map(|v| v.current).unwrap_or_default();
let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(118.0, 20.0));
if widgets::dropdown(ui, r, cur.label(), &t, egui::Id::new("vw-cam")).clicked() {
toggle_popup(ui, "vw-cam-pop");
}
app.auto.add("viewer.view3d", r, "3D View");
use effectcraft_engine::render::three_d::View3D;
let items: Vec<(String, bool)> = View3D::ALL.iter().map(|v| (v.label().to_string(), *v == cur)).collect();
if let Some(i) = popup(app, ui, "vw-cam-pop", r, &items, "view3dItem") {
let _ = app.session.execute("view.set3DView", json!({"view": View3D::ALL[i].id()}));
}
x = r.max.x + 6.0;
}
// Current time.
let tc = crate::panels::timecode(&app.session, comp, time);
let g = p.layout_no_wrap(tc, Tokens::mono(12.0), t.timecode);
let tr = Rect::from_min_size(pos2(x + 4.0, cy - 9.0), g.size() + vec2(8.0, 4.0));
p.galley(pos2(tr.min.x + 4.0, tr.min.y + 2.0), g, t.timecode);
app.auto.add("viewer.timecode", tr, "Current time");
// Right: render time.
let ms = app.frames.last_ms.lock().map(|v| *v).unwrap_or(0.0);
p.text(pos2(bar.max.x - 10.0, cy), Align2::RIGHT_CENTER, format!("{ms:.0} ms"), Tokens::ui(11.0), t.text_faint);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bar_labels_match_after_effects() {
assert_eq!(magnification_label(136.08), "136.1 %");
assert_eq!(magnification_label(100.0), "100 %");
assert_eq!(magnification_label(49.97), "50 %");
assert_eq!(resolution_label(Resolution::Auto, 1.0), "(Full)");
assert_eq!(resolution_label(Resolution::Auto, 0.5), "(Half)");
assert_eq!(resolution_label(Resolution::Quarter, 0.25), "Quarter");
assert_eq!(resolution_label(Resolution::Custom(5), 0.2), "Custom (5)");
}
}

View File

@ -24,6 +24,10 @@ pub enum Tool {
Polygon,
Star,
Pen,
PenAdd,
PenDelete,
PenConvert,
MaskFeather,
Type,
TypeVertical,
Brush,
@ -49,7 +53,7 @@ impl Tool {
&[Tool::Rotate],
&[Tool::PanBehind],
&[Tool::Rectangle, Tool::RoundedRect, Tool::Ellipse, Tool::Polygon, Tool::Star],
&[Tool::Pen],
&[Tool::Pen, Tool::PenAdd, Tool::PenDelete, Tool::PenConvert, Tool::MaskFeather],
&[Tool::Type, Tool::TypeVertical],
&[Tool::Brush],
&[Tool::Clone],
@ -57,7 +61,7 @@ impl Tool {
&[Tool::RotoBrush],
&[Tool::Puppet, Tool::PuppetStarch, Tool::PuppetBend, Tool::PuppetAdvanced, Tool::PuppetOverlap],
];
pub const ALL: [Tool; 25] = [
pub const ALL: [Tool; 29] = [
Tool::Selection,
Tool::Hand,
Tool::Zoom,
@ -72,6 +76,10 @@ impl Tool {
Tool::Polygon,
Tool::Star,
Tool::Pen,
Tool::PenAdd,
Tool::PenDelete,
Tool::PenConvert,
Tool::MaskFeather,
Tool::Type,
Tool::TypeVertical,
Tool::Brush,
@ -100,6 +108,10 @@ impl Tool {
Tool::Polygon => "Polygon Tool",
Tool::Star => "Star Tool",
Tool::Pen => "Pen Tool",
Tool::PenAdd => "Add Vertex Tool",
Tool::PenDelete => "Delete Vertex Tool",
Tool::PenConvert => "Convert Vertex Tool",
Tool::MaskFeather => "Mask Feather Tool",
Tool::Type => "Horizontal Type Tool",
Tool::TypeVertical => "Vertical Type Tool",
Tool::Brush => "Brush Tool",
@ -124,7 +136,7 @@ impl Tool {
Tool::Rotate => Some("W"),
Tool::PanBehind => Some("Y"),
Tool::Rectangle | Tool::RoundedRect | Tool::Ellipse | Tool::Polygon | Tool::Star => Some("Q"),
Tool::Pen => Some("G"),
Tool::Pen | Tool::PenAdd | Tool::PenDelete | Tool::PenConvert | Tool::MaskFeather => Some("G"),
Tool::Type | Tool::TypeVertical => Some("Cmd+T"),
Tool::Brush | Tool::Clone | Tool::Eraser => Some("Cmd+B"),
Tool::RotoBrush => Some("Alt+W"),
@ -147,6 +159,10 @@ impl Tool {
Tool::Polygon => Icon::Polygon,
Tool::Star => Icon::Star,
Tool::Pen => Icon::Pen,
Tool::PenAdd => Icon::PenAdd,
Tool::PenDelete => Icon::PenDelete,
Tool::PenConvert => Icon::PenConvert,
Tool::MaskFeather => Icon::MaskFeather,
Tool::Type => Icon::Type,
Tool::TypeVertical => Icon::TypeVertical,
Tool::Brush => Icon::Brush,
@ -180,6 +196,10 @@ impl Tool {
_ => return None,
})
}
/// The Pen slot's tools (Pen, Add / Delete / Convert Vertex, Mask Feather).
pub fn is_pen(self) -> bool {
matches!(self, Tool::Pen | Tool::PenAdd | Tool::PenDelete | Tool::PenConvert | Tool::MaskFeather)
}
pub fn is_shape(self) -> bool {
matches!(self, Tool::Rectangle | Tool::RoundedRect | Tool::Ellipse | Tool::Polygon | Tool::Star)
}
@ -194,6 +214,8 @@ pub enum Resolution {
Half,
Third,
Quarter,
/// View ▸ Resolution ▸ Custom: render every n-th pixel (2..=40).
Custom(u8),
}
impl Resolution {
@ -205,6 +227,7 @@ impl Resolution {
Resolution::Half => "Half",
Resolution::Third => "Third",
Resolution::Quarter => "Quarter",
Resolution::Custom(_) => "Custom",
}
}
/// Render scale for a viewer magnification (and display pixel density).
@ -226,6 +249,7 @@ impl Resolution {
Resolution::Half => 0.5,
Resolution::Third => 1.0 / 3.0,
Resolution::Quarter => 0.25,
Resolution::Custom(n) => 1.0 / n.max(1) as f64,
}
}
}
@ -251,6 +275,15 @@ pub struct ViewerState {
pub snap_guides: bool,
pub lock_guides: bool,
pub snap_grid: bool,
/// Grid and guide options ▸ Proportional Grid.
pub proportional_grid: bool,
/// Rulers zero point (comp pixels), moved by dragging the ruler corner.
pub ruler_origin: [f64; 2],
/// Region of Interest button armed: the next drag in the viewer draws the region.
pub roi_draw: bool,
/// The pointer is dragging in the viewer this frame (Adaptive Resolution).
#[serde(skip)]
pub interacting: bool,
/// View ▸ Panel Background Color (`None` = theme default).
pub pasteboard: Option<[u8; 3]>,
pub custom_pasteboard: [u8; 3],
@ -274,6 +307,10 @@ impl Default for ViewerState {
snap_guides: false,
lock_guides: false,
snap_grid: false,
proportional_grid: false,
ruler_origin: [0.0, 0.0],
roi_draw: false,
interacting: false,
pasteboard: None,
custom_pasteboard: [0x80, 0x80, 0x80],
}
@ -311,6 +348,15 @@ pub struct TimelineState {
/// Manual graph value range (when auto-zoom is off).
#[serde(default)]
pub graph_range: Option<(f64, f64)>,
/// Graph Editor ▸ Snap: key drags snap to the current time, other keys and frames.
#[serde(default = "yes")]
pub graph_snap: bool,
/// Graph Editor ▸ Show Reference Graph (the other graph type, faint, behind).
#[serde(default)]
pub graph_reference: bool,
/// Graph Editor ▸ Show Transform Box When Multiple Keys Are Selected.
#[serde(default = "yes")]
pub graph_transform_box: bool,
/// Properties whose inline expression editor is collapsed.
#[serde(default)]
pub expr_closed: BTreeSet<u64>,
@ -341,6 +387,9 @@ impl Default for TimelineState {
graph_show_selected: true,
graph_auto_zoom: true,
graph_range: None,
graph_snap: true,
graph_reference: false,
graph_transform_box: true,
expr_closed: BTreeSet::new(),
}
}

View File

@ -0,0 +1,297 @@
//! Headless checks for the Composition viewer's interaction parity: bottom bar order, region of
//! interest drawing, rulers and guides, snapping, the shape Pen and motion-path key drags
//! (egui_kittest, UI logic only).
use effectcraft_engine::Session;
use effectcraft_engine::project::LayerId;
use effectcraft_ui_egui::EffectcraftApp;
use effectcraft_ui_egui::state::Tool;
use egui::{Event, Pos2, Rect, pos2, vec2};
use egui_kittest::Harness;
use serde_json::json;
fn app() -> EffectcraftApp {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "View", "width": 640, "height": 360, "duration": 4})).unwrap();
s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080", "width": 640, "height": 360})).unwrap();
s.execute("layer.newSolid", json!({"name": "Box", "color": "#e04020", "width": 80, "height": 80})).unwrap();
s.execute("edit.deselectAll", json!({})).unwrap();
EffectcraftApp::new(s)
}
fn harness() -> Harness<'static, EffectcraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| app());
h.run_steps(3);
h
}
fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect {
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}"));
Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3]))
}
fn click(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) {
h.input_mut().events.push(Event::PointerMoved(p));
h.step();
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
h.step();
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
h.run_steps(2);
}
fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) {
h.input_mut().events.push(Event::PointerMoved(from));
h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
h.step();
for i in 1..=8 {
h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i as f32 / 8.0)));
h.step();
}
h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
h.run_steps(2);
}
/// Comp pixel → screen point.
fn screen(h: &Harness<'_, EffectcraftApp>, p: [f32; 2]) -> Pos2 {
let c = rect(h, "viewer.comp");
let z = c.width() / 640.0;
c.min + vec2(p[0] * z, p[1] * z)
}
#[test]
fn bottom_bar_in_after_effects_order() {
let h = harness();
let order = [
"viewer.magnification",
"viewer.resolution",
"viewer.transparency",
"viewer.masks",
"viewer.roi",
"viewer.grid",
"viewer.channel",
"viewer.resetExposure",
"viewer.exposure",
"viewer.snapshot",
"viewer.showSnapshot",
"viewer.fastPreviews",
"viewer.timecode",
];
let xs: Vec<f32> = order.iter().map(|id| rect(&h, id).min.x).collect();
assert!(xs.windows(2).all(|w| w[0] < w[1]), "{xs:?}");
let mag = &h.state().auto.find("viewer.magnification").unwrap().label;
assert_eq!(mag, "Magnification");
}
#[test]
fn region_of_interest_is_drawn_in_the_viewer() {
let mut h = harness();
let at = rect(&h, "viewer.roi").center();
click(&mut h, at);
assert!(h.state().ui.viewer.roi_draw);
let (a, b) = (screen(&h, [100.0, 50.0]), screen(&h, [300.0, 250.0]));
drag(&mut h, a, b);
let r = h.state().session.state.region_of_interest.expect("roi");
assert!((r[0] - 100.0).abs() <= 2.0 && (r[1] - 50.0).abs() <= 2.0 && (r[2] - 200.0).abs() <= 3.0 && (r[3] - 200.0).abs() <= 3.0, "{r:?}");
assert!(!h.state().ui.viewer.roi_draw);
h.run_steps(3);
assert!(h.state().auto.find("viewer.regionOfInterest").is_some());
// The button clears it again.
let at = rect(&h, "viewer.roi").center();
click(&mut h, at);
assert!(h.state().session.state.region_of_interest.is_none());
}
#[test]
fn rulers_make_guides_and_guides_move() {
let mut h = harness();
h.state_mut().ui.viewer.rulers = true;
h.run_steps(3);
let top = rect(&h, "viewer.ruler.top");
let to = screen(&h, [0.0, 120.0]);
drag(&mut h, pos2(to.x + 200.0, top.center().y), pos2(to.x + 200.0, to.y));
let g = h.state().session.active_comp().unwrap().guides.clone();
assert_eq!(g.len(), 1);
assert!(!g[0].vertical && (g[0].position - 120.0).abs() <= 2.0, "{g:?}");
// Drag the guide down with the Selection tool.
let from = pos2(screen(&h, [500.0, 0.0]).x, screen(&h, [0.0, g[0].position as f32]).y);
let to2 = pos2(from.x, screen(&h, [0.0, 200.0]).y);
drag(&mut h, from, to2);
let g = h.state().session.active_comp().unwrap().guides.clone();
assert!((g[0].position - 200.0).abs() <= 2.0, "{g:?}");
}
#[test]
fn layer_drag_snaps_to_comp_centre_and_ctrl_disables() {
let mut h = harness();
let box_id = h.state().session.active_comp().unwrap().layers[0].id;
h.state_mut().session.execute("prop.set", json!({"layer": box_id.0, "path": "transform/position", "value": [100, 100, 0]})).unwrap();
h.run_steps(2);
// Grab the box at its centre and drop it 3 px from the comp centre: it snaps there.
let from = screen(&h, [100.0, 100.0]);
let to = screen(&h, [323.0, 182.0]);
drag(&mut h, from, to);
let pos = |h: &Harness<'_, EffectcraftApp>| {
let l = h.state().session.active_comp().unwrap().layer(box_id).unwrap().clone();
l.props.prop("transform/position").unwrap().value.as_vec3()
};
let p = pos(&h);
assert!((p[0] - 320.0).abs() < 0.01 && (p[1] - 180.0).abs() < 0.01, "{p:?}");
// Snapping off: the same drag lands where the pointer is.
h.state_mut().session.execute("view.snapping", json!({"value": false})).unwrap();
h.state_mut().session.execute("prop.set", json!({"layer": box_id.0, "path": "transform/position", "value": [100, 100, 0]})).unwrap();
h.run_steps(2);
drag(&mut h, from, to);
let p = pos(&h);
assert!((p[0] - 320.0).abs() > 1.0, "{p:?}");
}
#[test]
fn shape_pen_draws_a_closed_shape_layer() {
let mut h = harness();
h.state_mut().ui.tool = Tool::Pen;
h.run_steps(2);
let n0 = h.state().session.active_comp().unwrap().layers.len();
for p in [[200.0, 100.0], [400.0, 120.0], [300.0, 260.0], [200.0, 100.0]] {
let at = screen(&h, p);
click(&mut h, at);
}
let comp = h.state().session.active_comp().unwrap().clone();
assert_eq!(comp.layers.len(), n0 + 1);
let l = &comp.layers[0];
assert!(matches!(l.source, effectcraft_engine::project::LayerSource::Shape));
let g = l.props.sub("contents").unwrap().groups().next().unwrap();
assert_eq!(g.name, "Shape 1");
let path = g.sub("contents").unwrap().groups().next().unwrap();
let sp = path.get("path").unwrap().value.as_path().unwrap().clone();
assert_eq!(sp.vertices.len(), 3);
assert!(sp.closed);
assert!((sp.vertices[0][0] - (200.0 - 320.0)).abs() < 1.5, "{:?}", sp.vertices);
// G cycles the Pen slot: Add Vertex, Delete Vertex, Convert Vertex, Mask Feather, Pen.
let ctx = h.ctx.clone();
let mut seen = vec![];
for _ in 0..5 {
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "tool.pen", json!({})).unwrap();
seen.push(h.state().ui.tool);
}
assert_eq!(seen, vec![Tool::PenAdd, Tool::PenDelete, Tool::PenConvert, Tool::MaskFeather, Tool::Pen]);
// Add Vertex on the first segment.
h.state_mut().ui.tool = Tool::PenAdd;
h.run_steps(2);
let at = screen(&h, [300.0, 110.0]);
click(&mut h, at);
let comp = h.state().session.active_comp().unwrap().clone();
let sp = comp.layers[0]
.props
.sub("contents")
.unwrap()
.groups()
.next()
.unwrap()
.sub("contents")
.unwrap()
.groups()
.next()
.unwrap()
.get("path")
.unwrap()
.value
.as_path()
.unwrap()
.clone();
assert_eq!(sp.vertices.len(), 4);
}
#[test]
fn motion_path_key_drag_edits_that_key() {
let mut h = harness();
let box_id: LayerId = h.state().session.active_comp().unwrap().layers[0].id;
let s = &mut h.state_mut().session;
s.execute("layer.select", json!({"layers": [box_id.0]})).unwrap();
s.execute("prop.addKey", json!({"layer": box_id.0, "path": "transform/position", "time": 0.0, "value": [100, 100, 0]})).unwrap();
s.execute("prop.addKey", json!({"layer": box_id.0, "path": "transform/position", "time": 2.0, "value": [500, 100, 0]})).unwrap();
s.execute("view.snapping", json!({"value": false})).unwrap();
s.set_time(effectcraft_engine::time::Tick::from_seconds_f64(1.0));
h.run_steps(3);
assert!(h.state().auto.find(&format!("viewer.motionPath.{}.1", box_id.0)).is_some());
let from = screen(&h, [500.0, 100.0]);
let to2 = screen(&h, [500.0, 300.0]);
drag(&mut h, from, to2);
let l = h.state().session.active_comp().unwrap().layer(box_id).unwrap().clone();
let k = &l.props.prop("transform/position").unwrap().keys;
let v1 = k[1].value.as_vec3();
assert!((v1[1] - 300.0).abs() < 2.0 && (v1[0] - 500.0).abs() < 2.0, "{v1:?}");
assert_eq!(k[0].value.as_vec3(), [100.0, 100.0, 0.0]);
}
#[test]
fn graph_editor_transform_box_scales_selected_keys_in_time() {
let mut h = harness();
let box_id: LayerId = h.state().session.active_comp().unwrap().layers[0].id;
let s = &mut h.state_mut().session;
s.execute("layer.select", json!({"layers": [box_id.0]})).unwrap();
for (t, v) in [(0.0, 0.0), (1.0, 50.0), (2.0, 100.0)] {
s.execute("prop.addKey", json!({"layer": box_id.0, "path": "transform/opacity", "time": t, "value": v})).unwrap();
}
let uid = s.active_comp().unwrap().layer(box_id).unwrap().props.prop("transform/opacity").unwrap().uid;
s.execute("prop.select", json!({"layer": box_id.0, "prop": uid})).unwrap();
let keys: Vec<_> = [0.0, 1.0, 2.0].iter().map(|t| json!({"layer": box_id.0, "prop": uid, "time": t})).collect();
s.execute("keys.select", json!({"keys": keys})).unwrap();
h.state_mut().ui.timeline.graph_editor = true;
h.run_steps(4);
for id in ["timeline.graph.snap", "timeline.graph.reference", "timeline.graph.transformBox", "timeline.graph.transformBox.5"] {
assert!(h.state().auto.find(id).is_some(), "missing {id}");
}
// Drag the right edge handle to the right: the keys spread out in time, the first stays.
let r = rect(&h, "timeline.graph.transformBox.5");
let k0 = rect(&h, &format!("timeline.graph.key.{uid}.0.0")).center();
let k2 = rect(&h, &format!("timeline.graph.key.{uid}.0.2")).center();
let to = r.center() + vec2((k2.x - k0.x) * 0.5, 0.0);
drag(&mut h, r.center(), to);
let p = h.state().session.active_comp().unwrap().layer(box_id).unwrap().props.prop("transform/opacity").unwrap().clone();
let ts: Vec<f64> = p.keys.iter().map(|k| k.time.seconds()).collect();
assert_eq!(ts[0], 0.0);
assert!(ts[2] > 2.5 && ts[2] < 3.5, "{ts:?}");
assert_eq!(p.keys.iter().map(|k| k.value.as_f64()).collect::<Vec<_>>(), vec![0.0, 50.0, 100.0]);
let undo = h.state().session.history.undo.iter().filter(|(l, _)| l == "Transform Keyframes").count();
assert_eq!(undo, 1, "one undo step per drag");
}
#[test]
fn timeline_alt_drag_scales_a_key_group_in_time() {
let mut h = harness();
let box_id: LayerId = h.state().session.active_comp().unwrap().layers[0].id;
let s = &mut h.state_mut().session;
s.execute("layer.select", json!({"layers": [box_id.0]})).unwrap();
for (t, v) in [(0.0, 0.0), (1.0, 50.0), (2.0, 100.0)] {
s.execute("prop.addKey", json!({"layer": box_id.0, "path": "transform/opacity", "time": t, "value": v})).unwrap();
}
let uid = s.active_comp().unwrap().layer(box_id).unwrap().props.prop("transform/opacity").unwrap().uid;
s.execute("keys.selectAll", json!({"layers": [box_id.0]})).unwrap();
let ctx = h.ctx.clone();
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "timeline.reveal.opacity", json!({})).unwrap();
h.run_steps(4);
let mut ks: Vec<Pos2> =
h.state().auto.query(&format!("timeline.key.{uid}.")).iter().map(|e| pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0)).collect();
ks.sort_by(|a, b| a.x.total_cmp(&b.x));
assert_eq!(ks.len(), 3, "{ks:?}");
let (k0, k2) = (ks[0], ks[2]);
// Alt-drag the last key to where 3 s would be: the group scales by 1.5 about the first key.
let to = pos2(k0.x + (k2.x - k0.x) * 1.5, k2.y);
let alt = egui::Modifiers { alt: true, ..Default::default() };
h.input_mut().events.push(Event::PointerMoved(k2));
h.step();
h.input_mut().events.push(Event::ModifiersChanged(alt));
h.input_mut().events.push(Event::PointerButton { pos: k2, button: egui::PointerButton::Primary, pressed: true, modifiers: alt });
h.step();
for i in 1..=8 {
h.input_mut().events.push(Event::PointerMoved(k2 + (to - k2) * (i as f32 / 8.0)));
h.step();
}
h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: alt });
h.run_steps(2);
let p = h.state().session.active_comp().unwrap().layer(box_id).unwrap().props.prop("transform/opacity").unwrap().clone();
let ts: Vec<f64> = p.keys.iter().map(|k| k.time.seconds()).collect();
assert_eq!(ts.len(), 3);
assert_eq!(ts[0], 0.0);
assert!((ts[2] - 3.0).abs() < 0.1 && (ts[1] - 1.5).abs() < 0.1, "{ts:?}");
}

View File

@ -103,6 +103,30 @@ groups into `Params` keys (`effects::flatten_params`). Commands: `paint.*`, `pup
Half, Third and Quarter resolution render proportionally fewer pixels end to end.
**Colour and bit depth** (`crates/render/src/color.rs`, `crates/color/src/space.rs`). Pixels are
`f32`, but 8 and 16 bpc projects clamp and quantise each layer after its source and masks and
after every effect, and the comp after every layer (16 bpc uses 0..32768), so over-range values
(Add, Screen, Exposure…) only survive in 32 bpc; blend modes without HDR support clamp their
inputs in 32 bpc. With a working space (sRGB, Rec. 709, Rec. 2020, Display P3; matrices derived
from the published primaries), footage is converted from its colour profile (stream metadata or
Interpret Footage, else sRGB) and the top-level comp is converted to the sRGB display. Linearize
Working Space runs everything in linear light; Blend Colors Using 1.0 Gamma linearises only for
blending. Layer cache keys include these settings.
**Frame blending**: footage and precomp layers whose source time falls between source frames
(rate conform, stretch, remap) blend the two neighbouring frames, by cross-fade (Frame Mix) or by
hierarchical block-matching optical flow and a bidirectional warp (Pixel Motion,
`raster::flow`).
**Collapse Transformations**: a collapsed precomp layer without masks, effects or styles draws
its nested layers straight into the parent with concatenated transforms (one resample), so
nested blend modes and adjustment layers act on the parent's layers; nested 3D layers use the
parent's camera and lights and, when the precomp layer is 3D, are depth-sorted with the parent's
3D run. Masks, effects or styles force a flattened render. On text and shape layers the switch is
Continuously Rasterize: the source is rasterised at its on-screen scale. Quality: Draft samples
nearest-neighbour, Wireframe draws the layer bounds. Slip edit (`layer.slip`, Alt+PageUp/Down,
dragging the source bar in the timeline) moves the source under fixed in/out points.
**Motion tracking** (Animation ▸ Track Motion / Stabilize Motion, Window ▸ Tracker): a tracker
is a `Tracker` group under the layer's Motion Trackers group, with Track Point groups (Feature
Center, Feature Size, Search Offset, Search Size, Confidence, Attach Point, Attach Point Offset)

View File

@ -81,7 +81,21 @@ Element ids are stable, for example `tools.Selection`, `panel.Timeline`, `panel.
`header.workspace.Animation`, `project.item.<id>`, `viewer.comp`, `viewer.handle.<layer>.<i>`,
`timeline.layer.<id>.bar`, `timeline.layer.<id>.twirl`, `timeline.prop.<uid>.stopwatch`,
`timeline.key.<uid>.<frame>`, `timeline.cti`, `effectControls.prop.<uid>.value` and
`effects.item.<name>`. Use `ui.elements` to see what is on screen.
`effects.item.<name>`. The Composition viewer adds `viewer.magnification`, `viewer.resolution`,
`viewer.roi`, `viewer.grid`, `viewer.channel`, `viewer.exposure`, `viewer.snapshot`,
`viewer.showSnapshot`, `viewer.fastPreviews` (their popup entries are `viewer.<menu>Item.<n>`),
`viewer.ruler.top|left|origin`, `viewer.mask.<uid>.vertex.<i>`, `viewer.shapePath.<uid>.vertex.<i>`,
`viewer.motionPath.<layer>.<key>[.in|.out]`, `viewer.freeTransform.handle.<i>` and
`viewer.regionOfInterest`; the Graph Editor adds `timeline.graph.transformBox[.<i>]`,
`timeline.graph.snap` and `timeline.graph.reference`. Use `ui.elements` to see what is on screen.
Viewer state that agents drive headless too: `view.snapping`, `view.channel {channel, colorized?}`,
`view.exposure {stops | delta}`, `view.resetExposure`, `view.takeSnapshot`, `view.showSnapshot`,
`view.fastPreviewMode {mode}`, `view.setRegionOfInterest {rect}`, `view.addGuide`,
`view.moveGuide`, `view.removeGuide`; editing: `shape.newPath` (Pen on shape layers),
`mask.insertVertex`, `mask.convertVertex`, `mask.deleteVertices`, `path.freeTransform` (masks and
shape paths by uid), `keys.setSpatialTangents` (motion-path handles) and `keys.transform` (Graph
Editor transform box, timeline Alt-drag scaling).
The MCP server's bridge mode (`effectcraft-cli mcp --bridge 9877`) is a thin client of this protocol;
see [agents.md](agents.md).

View File

@ -63,17 +63,17 @@ rendering, on-canvas text editing) are where the conservative figure is most lik
| Audio | 85% | 0.5 | audio to keyframes |
| Import | 78% | 4.5 | PSD, SVG as shapes, Lottie |
| Automation | 76% | 3.3 | a JavaScript scripting object model |
| Shapes | 68% | 3.9 | pen tool for shape paths, vertex editing, taper and wave strokes |
| Shapes | 68% | 3.9 | taper and wave strokes (pen tool for shape paths and vertex editing landed in M6.5) |
| Compositions | 67% | 7.4 | marker dialog, flowchart, Essential Graphics |
| Animation | 65% | 10.4 | motion-path handles in the viewer, graph editor transform box, puppet, Wiggler/Smoother/Motion Sketch |
| Animation | 65% | 10.4 | puppet, Wiggler/Smoother/Motion Sketch (motion-path handles and the graph editor transform box landed in M5.8) |
| Text | ≈ 85% | 1.5 | vertical type is basic (upright characters, no tate-chu-yoko), no OpenType feature panel, no text on 3D bevels; per-character styles, paragraph settings, on-canvas editing and the `sourceText` style API landed (M9.9–M9.10) |
| Web | 60% | 2.5 | threads, browser storage, audio |
| 3D | 58% | 20.5 | Advanced 3D (models, PBR, image-based light), 3D camera tracker, multi-view layouts |
| Effects | 56% | 14.6 | Effect Controls widgets (angle dial, point crosshair, eyedropper, curves), GPU effects, 41 missing effects |
| Interface | 55% | 10.4 | drag-to-dock and floating panels, viewer rulers/snapping/channels/snapshots, preferences, native macOS menus |
| Interface | 55% | 10.4 | drag-to-dock and floating panels, preferences, native macOS menus (viewer rulers/snapping/channels/snapshots landed in M0.13) |
| Project | 53% | 8.1 | 8/16/32-bit pipeline and colour management, auto-save, proxies, folder moves |
| Masks & roto | 44% | 13.0 | mask tracking, Roto Brush, free transform and RotoBezier |
| Preview | 43% | 8.1 | GPU compositor, disk cache, region of interest, snapshots and exposure |
| Masks & roto | 44% | 13.0 | mask tracking, Roto Brush, variable-width mask feather points |
| Preview | 43% | 8.1 | GPU compositor, disk cache (region of interest, snapshots, exposure and Fast Previews landed in M0.13) |
| Tracking | 0% | 13.0 | point tracker, Warp Stabilizer, mask and face tracking |
| Paint | 0% | 4.0 | Brush, Clone Stamp, Eraser |
@ -89,10 +89,10 @@ Mocha and Cineware are third-party and not counted.
1. ~~On-canvas text editing and per-character styles~~ (landed: M9.9–M9.10).
2. Effect Controls widgets: angle dial, point crosshair, eyedropper, curves and levels editors.
3. Viewer basics: snapping, rulers, channel view, snapshots, exposure, a drawable region of interest.
3. ~~Viewer basics: snapping, rulers, channel view, snapshots, exposure, a drawable region of interest.~~ (M0.13)
4. A GPU (wgpu) compositor, then GPU effects.
5. Pen tool for shape paths, shape vertex editing, the Layer viewer, free transform and RotoBezier.
6. Motion-path handles in the viewer; graph editor transform box and snapping.
5. ~~Pen tool for shape paths, shape vertex editing, free transform~~ (M6.5); the Layer viewer.
6. ~~Motion-path handles in the viewer; graph editor transform box and snapping.~~ (M5.8)
7. Point tracking and stabilization (in progress).
8. Frame blending, collapse transformations, slip edit.
9. A real 8/16/32-bit pipeline with linear blending and colour management.

Some files were not shown because too many files have changed in this diff Show More