Merge main into text editing (M9.10)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 558 B After Width: | Height: | Size: 343 KiB |
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
After Width: | Height: | Size: 347 KiB |
|
|
@ -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.
|
||||
|
After Width: | Height: | Size: 114 KiB |
|
|
@ -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.
|
||||
|
After Width: | Height: | Size: 343 KiB |
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
After Width: | Height: | Size: 89 KiB |
|
|
@ -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).
|
||||
|
After Width: | Height: | Size: 682 KiB |
|
|
@ -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.
|
||||
|
After Width: | Height: | Size: 17 KiB |
|
|
@ -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.
|
||||
|
After Width: | Height: | Size: 733 B |
|
|
@ -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.
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
|
|
@ -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.
|
||||
|
After Width: | Height: | Size: 56 KiB |
|
|
@ -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.
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
|
|
@ -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.
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
|
|
@ -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.
|
||||
|
After Width: | Height: | Size: 147 KiB |
|
|
@ -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.
|
||||
|
After Width: | Height: | Size: 5.4 KiB |
|
|
@ -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.
|
||||
|
After Width: | Height: | Size: 343 KiB |
|
|
@ -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.
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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}))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
]
|
||||
}
|
||||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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}"),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
]
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -256,6 +256,7 @@ impl Importer for MockImporter {
|
|||
codec: "mock".into(),
|
||||
missing: false,
|
||||
sequence: vec![],
|
||||
color_profile: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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>,
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
});
|
||||
}
|
||||
|
|
@ -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: ¶ms, time: lt.seconds(), layer_size, seed: g.uid as u32, adjustment, env };
|
||||
let t0 = web_time::Instant::now();
|
||||
buf = effectcraft_effects::apply(spec, &ectx, buf);
|
||||
// 8/16 bpc effects write integer pixels.
|
||||
self.pipe.quantize(&mut buf.img);
|
||||
if let Some(v) = timing.as_deref_mut() {
|
||||
v.push((spec.id.to_string(), t0.elapsed().as_secs_f64() * 1e3));
|
||||
}
|
||||
|
|
@ -289,15 +486,30 @@ impl<'a> Renderer<'a> {
|
|||
if layer.switches.adjustment {
|
||||
return Some(Buf { img: Image::filled(w, h, [1.0; 4]), offset: [0.0; 2], scale: s });
|
||||
}
|
||||
let c = sol.color;
|
||||
let mut c = sol.color;
|
||||
if let Some(conv) = self.pipe.authored_in() {
|
||||
c = conv.apply(c);
|
||||
}
|
||||
Some(Buf { img: Image::filled(w, h, [c[0], c[1], c[2], 1.0]), offset: [0.0; 2], scale: s })
|
||||
}
|
||||
LayerSource::Comp { item } => {
|
||||
if self.project.comp_contains(*item, ctx.comp_id) {
|
||||
return None;
|
||||
}
|
||||
let sub = Renderer { depth: self.depth + 1, ..*self };
|
||||
let sub = self.nested();
|
||||
let lt = ctx.source_time(layer);
|
||||
// Frame blending between the nested comp's frames (time-stretched/remapped).
|
||||
let mode = frame_blend_mode(ctx, layer);
|
||||
if mode != FrameBlend::Off
|
||||
&& let Some(nc) = self.project.comp(*item)
|
||||
{
|
||||
let (i, w) = frame_position(lt, nc.frame_rate);
|
||||
if w > 1e-6 {
|
||||
let a = sub.comp_frame(*item, nc.frame_rate.tick_of(i));
|
||||
let b = sub.comp_frame(*item, nc.frame_rate.tick_of(i + 1));
|
||||
return Some(Buf { img: blend_frames(mode, &a, &b, w as f32), offset: [0.0; 2], scale: s });
|
||||
}
|
||||
}
|
||||
Some(Buf { img: sub.comp_frame(*item, lt), offset: [0.0; 2], scale: s })
|
||||
}
|
||||
LayerSource::Footage { item } => {
|
||||
|
|
@ -307,40 +519,123 @@ impl<'a> Renderer<'a> {
|
|||
return None;
|
||||
}
|
||||
let lt = ctx.source_time(layer);
|
||||
let img = self.footage.frame(*item, f, lt)?;
|
||||
let img = self.footage_frame(ctx, layer, *item, f, lt)?;
|
||||
let k = if img.width > 0 { f.width.max(1) as f64 / img.width as f64 } else { 1.0 };
|
||||
// Keep native pixels when downsampling is small; resample otherwise.
|
||||
if s < 0.75 {
|
||||
let mut buf = if s < 0.75 {
|
||||
let w = ((f.width as f64 * s).round() as u32).max(1);
|
||||
let h = ((f.height as f64 * s).round() as u32).max(1);
|
||||
return Some(Buf { img: effectcraft_raster::resample(&img, w, h), offset: [0.0; 2], scale: s });
|
||||
Buf { img: effectcraft_raster::resample(&img, w, h), offset: [0.0; 2], scale: s }
|
||||
} else {
|
||||
Buf { img: (*img).clone(), offset: [0.0; 2], scale: 1.0 / k }
|
||||
};
|
||||
if let Some(c) = self.pipe.media_in(f.color_profile) {
|
||||
color::convert(&mut buf.img, &c);
|
||||
}
|
||||
Some(Buf { img: (*img).clone(), offset: [0.0; 2], scale: 1.0 / k })
|
||||
Some(buf)
|
||||
}
|
||||
LayerSource::Text => Some(text::render(ctx, layer, s)),
|
||||
LayerSource::Shape => layer.props.sub("contents").map(|c| shapes::render(ctx, layer, c, s)),
|
||||
LayerSource::Text => Some(self.authored(text::render(ctx, layer, self.raster_scale(ctx, layer)))),
|
||||
LayerSource::Shape => layer.props.sub("contents").map(|c| self.authored(shapes::render(ctx, layer, c, self.raster_scale(ctx, layer)))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Authored colours into the working space (linear working spaces).
|
||||
fn authored(&self, mut buf: Buf) -> Buf {
|
||||
if let Some(c) = self.pipe.authored_in() {
|
||||
color::convert(&mut buf.img, &c);
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
/// A footage frame at source time `t`, frame-blended between the two nearest source frames
|
||||
/// when the layer's Frame Blending switch (and the comp's Enable Frame Blending) is on and
|
||||
/// `t` falls between frames (footage rate ≠ comp rate, time stretch, time remapping).
|
||||
fn footage_frame(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, f: &Footage, t: Tick) -> Option<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;
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ fn audio_footage() -> Footage {
|
|||
codec: String::new(),
|
||||
missing: false,
|
||||
sequence: vec![],
|
||||
color_profile: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()?;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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()));
|
||||
|
|
|
|||
|
|
@ -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)");
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:?}");
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||