mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 04:57:54 -06:00
Merge enhance/filmcraft-avi-backend into enhance/avi-import
# Conflicts: # crates/export/tests/it/movie_bytes.rs
This commit is contained in:
@@ -29,7 +29,7 @@ permissions:
|
||||
jobs:
|
||||
windows-arm64:
|
||||
# Immutable supporting workflow revision until the new install contract lands upstream.
|
||||
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f
|
||||
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@fa5ef18780cf5e7d5d3b8de068de56cffc83cac5
|
||||
with:
|
||||
app-name: effectcraft
|
||||
app-dir: EffectCraft
|
||||
|
||||
@@ -89,6 +89,8 @@ Screenshots of EffectCraft itself, rendered headlessly, showing only procedurall
|
||||
`crates/engine/presets/transitions.json` (sidecar `.attribution`).
|
||||
- Backgrounds animation presets (23: drifting gradients, fog, cloud and smoke, plasma and magma, cells and crystals, scrolling grids
|
||||
and checks, pulse rings, bokeh, light sweep): `crates/engine/presets/backgrounds.json` (sidecar `.attribution`).
|
||||
- Behaviors animation presets (22: wiggle, shake, float, orbit, bounce, drift, pendulum, spin, pulse, heartbeat, blink, strobe, flicker):
|
||||
`crates/engine/presets/behaviors.json` (sidecar `.attribution`).
|
||||
- Sample scripts (File ▸ Scripts) and ScriptUI panel: `crates/engine/scripts/Rename Layers.jsx`,
|
||||
`crates/engine/scripts/Sort Layers by In Point.jsx`, `crates/engine/scripts/Create Null at Selected Layers.jsx`,
|
||||
`crates/engine/scripts/Render Queue Batch.jsx`, `crates/engine/scripts/ScriptUI Panels/Layer Tools.jsx`
|
||||
|
||||
@@ -7,12 +7,36 @@ work apply to everyone, people and AI agents alike, and they are not negotiable.
|
||||
|
||||
You need [Rust](https://rustup.rs/) 1.95 or newer.
|
||||
|
||||
On Linux the desktop app plays preview sound through `cpal`, so the build needs a usable ALSA
|
||||
pkg-config package: an `alsa.pc` that pkg-config can find, naming the linkable ALSA runtime
|
||||
library (usually `libasound.so.2`). `alsa-sys` runs pkg-config at build time; it does not compile
|
||||
ALSA's headers. Without a usable package the build fails at link time with an unrelated-looking
|
||||
error; `cargo xtask ci` probes pkg-config up front and names the package:
|
||||
|
||||
```sh
|
||||
sudo dnf install alsa-lib-devel # Fedora
|
||||
sudo apt install libasound2-dev # Debian / Ubuntu
|
||||
sudo pacman -S alsa-lib # Arch
|
||||
```
|
||||
|
||||
If you cannot install the package, point `PKG_CONFIG_PATH` at a directory holding an `alsa.pc`
|
||||
whose `Libs` line names your system's `libasound` (usually `libasound.so.2`); every CI run needs
|
||||
that variable set.
|
||||
|
||||
```sh
|
||||
cargo run -p effectcraft -- --demo # the desktop app, with the demo project
|
||||
cargo test --workspace
|
||||
cargo xtask ci # what every commit must pass
|
||||
```
|
||||
|
||||
The default `dev` profile keeps debug info to line tables: builds are fast and panics/backtraces
|
||||
still name the file and line, but a debugger cannot show local variables. For an interactive
|
||||
debugger session use the `dev-full` profile, which is the same build with full debug info:
|
||||
|
||||
```sh
|
||||
cargo run --profile dev-full -p effectcraft -- --demo
|
||||
```
|
||||
|
||||
## Ground rules
|
||||
|
||||
- **Clean room.** Work from After Effects' public documentation and its observable behaviour only.
|
||||
|
||||
Generated
+6
@@ -1623,6 +1623,7 @@ name = "effectcraft-media"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"effectcraft-color",
|
||||
"effectcraft-model",
|
||||
"effectcraft-pdf",
|
||||
"effectcraft-project",
|
||||
@@ -1632,11 +1633,14 @@ dependencies = [
|
||||
"effectcraft-svg",
|
||||
"effectcraft-time",
|
||||
"exr",
|
||||
"filmcraft-aac",
|
||||
"filmcraft-codecs",
|
||||
"filmcraft-color",
|
||||
"filmcraft-frame",
|
||||
"filmcraft-isobmff",
|
||||
"filmcraft-matroska",
|
||||
"filmcraft-media",
|
||||
"filmcraft-mpegts",
|
||||
"filmcraft-time",
|
||||
"gif",
|
||||
"image",
|
||||
@@ -1652,6 +1656,7 @@ version = "0.18.0"
|
||||
dependencies = [
|
||||
"effectcraft-geom",
|
||||
"image",
|
||||
"log",
|
||||
"miniz_oxide 0.9.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -7244,6 +7249,7 @@ name = "xtask"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"pkg-config",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
+12
@@ -87,6 +87,9 @@ filmcraft-h264enc = { git = "https://github.com/storytold/filmcraft", rev = "09a
|
||||
filmcraft-prores = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" }
|
||||
filmcraft-aac = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" }
|
||||
filmcraft-isobmff = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" }
|
||||
# container headers read for AAC channel layouts (crates/media)
|
||||
filmcraft-matroska = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" }
|
||||
filmcraft-mpegts = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" }
|
||||
# decoder used as the round-trip oracle of effectcraft-opusenc (crates/export dev-dependency)
|
||||
filmcraft-opus = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" }
|
||||
filmcraft-vp9 = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" }
|
||||
@@ -128,6 +131,8 @@ sys-locale = "0.3"
|
||||
# filmcraft-prores = { path = "../filmcraft/crates/prores" }
|
||||
# filmcraft-aac = { path = "../filmcraft/crates/aac" }
|
||||
# filmcraft-isobmff = { path = "../filmcraft/crates/isobmff" }
|
||||
# filmcraft-matroska = { path = "../filmcraft/crates/matroska" }
|
||||
# filmcraft-mpegts = { path = "../filmcraft/crates/mpegts" }
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
@@ -150,6 +155,13 @@ opt-level = 1
|
||||
# Panics and backtraces keep file and line; full debug info only made the many test binaries large.
|
||||
debug = "line-tables-only"
|
||||
|
||||
# Same settings as `dev`, but with full debug info for interactive debugger sessions: local
|
||||
# variables, parameters and column breakpoints work. Use `--profile dev-full` when debugging;
|
||||
# everything else stays on the fast default. See CONTRIBUTING.md, "Setup".
|
||||
[profile.dev-full]
|
||||
inherits = "dev"
|
||||
debug = true
|
||||
|
||||
# Pixel hot loops: keep debug builds interactive.
|
||||
[profile.dev.package.effectcraft-raster]
|
||||
opt-level = 3
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
<p align="center">
|
||||
A free, open-source compositor: compositions, layers,
|
||||
keyframes, 306 effects, layer styles, expressions, 3D cameras and lights, and a render queue, native on macOS,
|
||||
keyframes, 308 effects, layer styles, expressions, 3D cameras and lights, and a render queue, native on macOS,
|
||||
Windows and Linux, and in the browser. Young, moving fast, and already usable.
|
||||
</p>
|
||||
|
||||
@@ -84,9 +84,10 @@ a few places where it matters to us:
|
||||
without a plugin.
|
||||
- **A project file you can read.** Projects are versioned JSON (`.ecproj`), so they diff cleanly
|
||||
in version control.
|
||||
- **Everything is scriptable.** Every menu item, timeline drag and property edit goes through one
|
||||
command registry, so the same actions are reachable from a command line, a JSON control
|
||||
channel and an MCP server for agents.
|
||||
- **Everything is scriptable.** Menu actions, timeline edits and property changes are exposed as
|
||||
commands. Engine commands are available through the CLI, JSON control channel and MCP;
|
||||
UI-only commands use the frontend dispatcher and can be invoked through the control channel
|
||||
or an attached MCP bridge.
|
||||
- **No FFmpeg.** Video and audio decoding and encoding are pure Rust: FilmCraft's codecs and
|
||||
EffectCraft's own VP9, AV1, HEVC and Opus encoders.
|
||||
|
||||
@@ -126,7 +127,7 @@ or into the Timeline, between layers and at the time you point to.
|
||||
|
||||
## Effects
|
||||
|
||||
306 built-in effects across its categories, each with its parameter names, order and
|
||||
308 built-in effects across its categories, each with its parameter names, order and
|
||||
defaults: blur and sharpen, channel, color correction (Curves, Levels, Hue/Saturation, a full
|
||||
color-grading panel…), distort (Warp, Bulge, Turbulent Displace, CC Power Pin…), generate (Fractal Noise,
|
||||
Gradient Ramp, Stroke, Write-on, Audio Spectrum…), keying, matte, noise and grain, perspective,
|
||||
@@ -173,8 +174,10 @@ cannot express; File ▸ Import ▸ Lottie… opens one as a new composition.
|
||||
|
||||
## Built for agents
|
||||
|
||||
Everything you can do from a menu is a command with an id, and agents can reach every one of
|
||||
them:
|
||||
Engine commands are available through the CLI, JSON control channel and MCP. UI-only commands
|
||||
need the running app's frontend dispatcher: complete menu access requires the app's control
|
||||
channel or an attached MCP bridge. Headless MCP sessions expose supported engine commands,
|
||||
but not UI-only commands or bridge-only `ui_*` tools.
|
||||
|
||||
- **MCP server:** `effectcraft-cli mcp` speaks the Model Context Protocol over stdio, headless
|
||||
or bridged to the running app (`--bridge 9877`). Tools cover commands, the project and property
|
||||
@@ -228,7 +231,7 @@ tests, layering, asset attribution and the WebAssembly build). See [CONTRIBUTING
|
||||
## Where it stands
|
||||
|
||||
EffectCraft is young: its first commit was on 1 October 2026. A comprehensive feature set
|
||||
exists, including 306 effects, but breadth is not the same as full maturity across every edge
|
||||
exists, including 308 effects, but breadth is not the same as full maturity across every edge
|
||||
case. Today it's a good place to try things and to tell us what breaks. It isn't yet recommended
|
||||
for production client work. In particular:
|
||||
|
||||
|
||||
@@ -37,6 +37,8 @@ js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = [
|
||||
"Blob",
|
||||
"BlobPropertyBag",
|
||||
"AbortController",
|
||||
"AbortSignal",
|
||||
"CssStyleDeclaration",
|
||||
"Document",
|
||||
"Element",
|
||||
@@ -51,6 +53,12 @@ web-sys = { version = "0.3", features = [
|
||||
"HtmlInputElement",
|
||||
"Location",
|
||||
"Navigator",
|
||||
"ReadableStream",
|
||||
"ReadableStreamDefaultReader",
|
||||
"Request",
|
||||
"RequestCache",
|
||||
"RequestInit",
|
||||
"Response",
|
||||
"Url",
|
||||
"UrlSearchParams",
|
||||
"Window",
|
||||
|
||||
@@ -6,7 +6,8 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
|
||||
use crate::{api, audio, browse, diskcache, files, frames, persist, storage, worker};
|
||||
use crate::startup::Startup;
|
||||
use crate::{api, audio, browse, config_file, diskcache, files, frames, persist, storage, worker};
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use serde_json::json;
|
||||
use wasm_bindgen::prelude::*;
|
||||
@@ -83,8 +84,12 @@ pub(crate) fn open_or_import(app: &mut EffectcraftApp, paths: Vec<String>) {
|
||||
/// The eframe app: the shared EffectCraft UI plus the browser host's per-frame duties.
|
||||
pub struct WebApp {
|
||||
pub app: EffectcraftApp,
|
||||
/// Keep the session snapshot (off with `?empty`).
|
||||
/// Keep the session snapshot (off with `?empty`; off with an explicit `"demo"` startup until
|
||||
/// the user's own work begins, see `demo_start`).
|
||||
pub persist: bool,
|
||||
/// Started on the demo by the config: the snapshot stays off, so the demo never replaces the
|
||||
/// last visit's project, until the user opens a project from a file or edits the demo.
|
||||
pub demo_start: bool,
|
||||
}
|
||||
|
||||
impl eframe::App for WebApp {
|
||||
@@ -117,6 +122,11 @@ impl eframe::App for WebApp {
|
||||
api::poll_replies();
|
||||
// Render Queue outputs written this frame (here or by a worker) → downloads.
|
||||
files::flush_downloads();
|
||||
// The user's own work after a demo start is worth keeping too: turn the snapshot on.
|
||||
if self.demo_start && (self.app.session.path.is_some() || self.app.session.is_dirty()) {
|
||||
self.demo_start = false;
|
||||
self.persist = true;
|
||||
}
|
||||
// What a reload comes back to.
|
||||
if self.persist {
|
||||
// The app's monotonic clock: `ctx.input(|i| i.time)` is stale in a logic-only tick.
|
||||
@@ -223,6 +233,11 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
|
||||
};
|
||||
api::set_info("storage", json!({"backend": persist::backend(), "entries": stored, "loadMs": t0.elapsed().as_secs_f64() * 1000.0}));
|
||||
audio::install_unlock();
|
||||
// `./effectcraft-config.json`: what to open on load.
|
||||
// `?empty` and `?demo` decide by themselves, so they skip the request.
|
||||
let startup = if empty || query_flag("demo") { Startup::default() } else { config_file::startup().await };
|
||||
// An explicit `"demo"` start leaves the saved session alone until the user opens or edits something.
|
||||
let keep_session = !empty && startup.keeps_session();
|
||||
browse::restore();
|
||||
diskcache::init();
|
||||
storage::refresh();
|
||||
@@ -248,7 +263,7 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
|
||||
// The canvas can't present any more (#225): the page says so over it and
|
||||
// offers a reload, which brings the session back when browser storage keeps it.
|
||||
let lost = gpu_failures.clone();
|
||||
let kept = !empty && persist::backend() != "memory";
|
||||
let kept = keep_session && persist::backend() != "memory";
|
||||
rs.device.set_device_lost_callback(move |reason, message| {
|
||||
let message = format!("GPU device lost ({reason:?}): {message}");
|
||||
lost.report(&message, true);
|
||||
@@ -258,10 +273,11 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
|
||||
let mut session = session();
|
||||
session.load_settings();
|
||||
let recovery = session.begin_recovery();
|
||||
// `?empty`: a blank project; `?demo`: the demo project; otherwise the last visit's
|
||||
// project (the demo on a first visit).
|
||||
let restored = !empty && !query_flag("demo") && persist::restore(&mut session);
|
||||
if !restored && !empty {
|
||||
// `?empty`: a blank project; `?demo`: the demo project; otherwise what the config's
|
||||
// `startup` says (default: the last visit's project, the demo on a first visit).
|
||||
let demo = query_flag("demo");
|
||||
let restored = !empty && !demo && startup.restores() && persist::restore(&mut session);
|
||||
if !restored && !empty && (demo || startup.demo_when_nothing_restored()) {
|
||||
let _ = session.execute("file.openDemoProject", json!({}));
|
||||
}
|
||||
api::set_info("restored", json!(restored));
|
||||
@@ -287,7 +303,7 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
|
||||
app = app.with_control(rx);
|
||||
let backend = cc.wgpu_render_state.as_ref().map(|rs| format!("{:?}", rs.adapter.get_info().backend));
|
||||
api::set_info("backend", json!(backend));
|
||||
Ok(Box::new(WebApp { app, persist: !empty }))
|
||||
Ok(Box::new(WebApp { app, persist: keep_session, demo_start: !empty && !keep_session }))
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
//! `./effectcraft-config.json`, fetched once per page load and shared by whoever reads a key.
|
||||
|
||||
use std::cell::RefCell;
|
||||
|
||||
use wasm_bindgen::{JsCast, closure::Closure};
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
|
||||
use crate::startup::{self, Startup};
|
||||
|
||||
/// Longest the request and the body read may take (ms); a stalled server must not hold up start-up.
|
||||
const TIMEOUT_MS: i32 = 5_000;
|
||||
|
||||
thread_local! {
|
||||
/// `None`: not fetched yet; `Some(None)`: no usable file.
|
||||
static FILE: RefCell<Option<Option<Vec<u8>>>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// The config file's bytes, or `None` when it is missing, too large, unreadable or the request
|
||||
/// takes longer than [`TIMEOUT_MS`] (the timer aborts the request and covers the body read).
|
||||
async fn fetch() -> Option<Vec<u8>> {
|
||||
let win = web_sys::window()?;
|
||||
let ctl = web_sys::AbortController::new().ok()?;
|
||||
let init = web_sys::RequestInit::new();
|
||||
// Always ask the server: the file is the operator's switch, never a cached copy.
|
||||
init.set_cache(web_sys::RequestCache::NoCache);
|
||||
init.set_signal(Some(&ctl.signal()));
|
||||
let on_timeout = Closure::once(move || ctl.abort());
|
||||
let timer = win.set_timeout_with_callback_and_timeout_and_arguments_0(on_timeout.as_ref().unchecked_ref(), TIMEOUT_MS).ok()?;
|
||||
let out = read(&win, &init).await;
|
||||
win.clear_timeout_with_handle(timer);
|
||||
out
|
||||
}
|
||||
|
||||
/// Fetch the file and read its body chunk by chunk, giving up as soon as it passes `CONFIG_MAX`.
|
||||
async fn read(win: &web_sys::Window, init: &web_sys::RequestInit) -> Option<Vec<u8>> {
|
||||
let req = web_sys::Request::new_with_str_and_init("effectcraft-config.json", init).ok()?;
|
||||
let resp: web_sys::Response = JsFuture::from(win.fetch_with_request(&req)).await.ok()?.dyn_into().ok()?;
|
||||
if resp.status() != 200 {
|
||||
return None;
|
||||
}
|
||||
let reader: web_sys::ReadableStreamDefaultReader = resp.body()?.get_reader().unchecked_into();
|
||||
let mut out = Vec::new();
|
||||
loop {
|
||||
let chunk = JsFuture::from(reader.read()).await.ok()?;
|
||||
if js_sys::Reflect::get(&chunk, &"done".into()).ok()?.as_bool() == Some(true) {
|
||||
return Some(out);
|
||||
}
|
||||
let bytes: js_sys::Uint8Array = js_sys::Reflect::get(&chunk, &"value".into()).ok()?.dyn_into().ok()?;
|
||||
if out.len().saturating_add(bytes.length() as usize) > startup::CONFIG_MAX {
|
||||
// Stop the download: the limit is enforced while reading, not after the whole body arrived.
|
||||
let _ = reader.cancel();
|
||||
return None;
|
||||
}
|
||||
out.extend_from_slice(&bytes.to_vec());
|
||||
}
|
||||
}
|
||||
|
||||
/// The config file's bytes (`None` when there is none), fetched on the first call.
|
||||
pub async fn bytes() -> Option<Vec<u8>> {
|
||||
if let Some(done) = FILE.with(|f| f.borrow().clone()) {
|
||||
return done;
|
||||
}
|
||||
let got = fetch().await;
|
||||
FILE.with(|f| *f.borrow_mut() = Some(got.clone()));
|
||||
got
|
||||
}
|
||||
|
||||
/// The configured startup behaviour.
|
||||
pub async fn startup() -> Startup {
|
||||
let s = bytes().await.map(|b| startup::parse(&b)).unwrap_or_default();
|
||||
log::info!("startup: {s:?}");
|
||||
s
|
||||
}
|
||||
@@ -18,10 +18,11 @@
|
||||
//! | cpal audio output | Web Audio: an AudioWorklet fed from the preview mixdown (`audio`) |
|
||||
//! | TCP control channel / MCP | `window.effectcraft` JavaScript API (`api`) |
|
||||
//!
|
||||
//! [`store`] is portable (unit-tested natively); everything else is `wasm32`-only.
|
||||
//! [`store`] and [`startup`] are portable (unit-tested natively); everything else is `wasm32`-only.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod startup;
|
||||
pub mod store;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
@@ -33,6 +34,8 @@ pub mod audio;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod browse;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod config_file;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod diskcache;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod files;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
//! The `startup` key of `./effectcraft-config.json`: which project the page opens on load.
|
||||
//!
|
||||
//! | Value | On load |
|
||||
//! |---|---|
|
||||
//! | `"last"` (and absent) | the last visit's project; the demo project on a first visit |
|
||||
//! | `"empty"` | the last visit's project; a blank project on a first visit, never the demo |
|
||||
//! | `"demo"` | the demo project, whatever the last visit left |
|
||||
//!
|
||||
//! The `?empty` and `?demo` URL flags still win. Anything else (a missing key, another type, an
|
||||
//! unknown value, a config that is not JSON) is the default, `"last"`.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
/// What the page opens on load.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Startup {
|
||||
#[default]
|
||||
Last,
|
||||
Empty,
|
||||
Demo,
|
||||
}
|
||||
|
||||
/// The largest config file read (bytes).
|
||||
pub const CONFIG_MAX: usize = 64 * 1024;
|
||||
|
||||
/// The `startup` key of a config file's bytes.
|
||||
pub fn parse(bytes: &[u8]) -> Startup {
|
||||
if bytes.len() > CONFIG_MAX {
|
||||
return Startup::default();
|
||||
}
|
||||
let Ok(v) = serde_json::from_slice::<Value>(bytes) else { return Startup::default() };
|
||||
match v.get("startup").and_then(Value::as_str) {
|
||||
Some("empty") => Startup::Empty,
|
||||
Some("demo") => Startup::Demo,
|
||||
_ => Startup::Last,
|
||||
}
|
||||
}
|
||||
|
||||
impl Startup {
|
||||
/// Whether the last visit's project is restored (off for the demo).
|
||||
pub fn restores(self) -> bool {
|
||||
self != Startup::Demo
|
||||
}
|
||||
|
||||
/// Whether the session snapshot keeps being saved (off for the demo, so it never replaces the
|
||||
/// project the last visit left).
|
||||
pub fn keeps_session(self) -> bool {
|
||||
self != Startup::Demo
|
||||
}
|
||||
|
||||
/// Whether the demo project opens when nothing was restored (off for a blank start).
|
||||
pub fn demo_when_nothing_restored(self) -> bool {
|
||||
self != Startup::Empty
|
||||
}
|
||||
}
|
||||
@@ -366,6 +366,21 @@ impl ConfigStore for WebConfig {
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
/// The first name part of the keys under `dir/`.
|
||||
fn list_dirs(&self, dir: &str) -> Vec<String> {
|
||||
let pre = format!("{CONFIG}{}/", dir.trim_end_matches('/'));
|
||||
let mut v: Vec<String> = self
|
||||
.store
|
||||
.lock()
|
||||
.list(&pre)
|
||||
.into_iter()
|
||||
.filter_map(|(k, ..)| k.strip_prefix(&pre)?.split_once('/').map(|(d, _)| d.to_string()))
|
||||
.filter(|d| !d.is_empty())
|
||||
.collect();
|
||||
v.sort();
|
||||
v.dedup();
|
||||
v
|
||||
}
|
||||
fn dir(&self) -> Option<PathBuf> {
|
||||
Some(PathBuf::from("/"))
|
||||
}
|
||||
@@ -533,6 +548,22 @@ mod tests {
|
||||
assert_eq!(s.file("/clip.mp4").as_deref(), Some(&b"abc"[..]));
|
||||
}
|
||||
|
||||
/// Scripts in a subfolder of the Scripts folder (`Tools/Hello.jsx`) are listed, as on the desktop.
|
||||
#[test]
|
||||
fn config_store_lists_folders() {
|
||||
let c = WebConfig::new(store());
|
||||
for name in ["Scripts/Top.jsx", "Scripts/Tools/Hello.jsx", "Scripts/Tools/More.jsx", "Scripts/ScriptUI Panels/Kit/A.jsx"] {
|
||||
c.write(name, "x").unwrap();
|
||||
}
|
||||
assert_eq!(c.list("Scripts"), ["Top.jsx"]);
|
||||
assert_eq!(c.list_dirs("Scripts"), ["ScriptUI Panels", "Tools"]);
|
||||
assert_eq!(c.list_dirs("Scripts/ScriptUI Panels"), ["Kit"]);
|
||||
let mut s = effectcraft_engine::Session { config: Some(Arc::new(c)), ..Default::default() };
|
||||
let list = s.execute("file.scripts.list", serde_json::json!({})).unwrap();
|
||||
let installed: Vec<&str> = list.as_array().unwrap().iter().filter(|e| e["source"] == "installed").filter_map(|e| e["name"].as_str()).collect();
|
||||
assert_eq!(installed, ["Tools/Hello.jsx", "Tools/More.jsx", "Top.jsx", "Kit/A.jsx"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_store_and_file_ops() {
|
||||
let s = store();
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
mod startup_tests;
|
||||
@@ -0,0 +1,52 @@
|
||||
//! The `startup` key of the config file.
|
||||
|
||||
use effectcraft_web::startup::{CONFIG_MAX, Startup, parse};
|
||||
|
||||
/// The three accepted values parse.
|
||||
#[test]
|
||||
fn the_three_values() {
|
||||
assert_eq!(parse(br#"{"startup":"empty"}"#), Startup::Empty);
|
||||
assert_eq!(parse(br#"{"startup":"demo"}"#), Startup::Demo);
|
||||
assert_eq!(parse(br#"{"startup":"last"}"#), Startup::Last);
|
||||
}
|
||||
|
||||
/// A missing key, a wrong type, an unknown value or a non-object file gives `Last`.
|
||||
#[test]
|
||||
fn absent_or_invalid_is_the_default() {
|
||||
for bytes in
|
||||
[&b"{}"[..], b"", b"not json", b"[]", b"null", br#"{"startup":1}"#, br#"{"startup":"Empty"}"#, br#"{"startup":null}"#, br#"{"startup":["empty"]}"#]
|
||||
{
|
||||
assert_eq!(parse(bytes), Startup::Last, "{:?}", String::from_utf8_lossy(bytes));
|
||||
}
|
||||
}
|
||||
|
||||
/// Other keys do not hide `startup`.
|
||||
#[test]
|
||||
fn a_config_with_other_keys_still_has_its_startup() {
|
||||
assert_eq!(parse(br#"{"remote":{"mounts":[]},"startup":"empty"}"#), Startup::Empty);
|
||||
assert_eq!(parse(br#"{"startup":"demo","unknown":{"a":1}}"#), Startup::Demo);
|
||||
}
|
||||
|
||||
/// A file over `CONFIG_MAX` is ignored.
|
||||
#[test]
|
||||
fn an_oversized_file_is_the_default() {
|
||||
let mut big = br#"{"startup":"empty","pad":""#.to_vec();
|
||||
big.extend(std::iter::repeat_n(b'x', CONFIG_MAX));
|
||||
big.extend_from_slice(br#""}"#);
|
||||
assert_eq!(parse(&big), Startup::Last);
|
||||
}
|
||||
|
||||
/// Which project each value restores or opens.
|
||||
#[test]
|
||||
fn what_each_value_does_on_load() {
|
||||
assert!(Startup::Last.restores() && Startup::Last.demo_when_nothing_restored());
|
||||
assert!(Startup::Empty.restores() && !Startup::Empty.demo_when_nothing_restored());
|
||||
assert!(!Startup::Demo.restores() && Startup::Demo.demo_when_nothing_restored());
|
||||
}
|
||||
|
||||
/// Only the explicit demo start stops saving the session.
|
||||
#[test]
|
||||
fn only_the_demo_start_leaves_the_saved_session_alone() {
|
||||
assert!(Startup::Last.keeps_session() && Startup::Empty.keeps_session());
|
||||
assert!(!Startup::Demo.keeps_session());
|
||||
}
|
||||
@@ -3,7 +3,8 @@
|
||||
// `cargo xtask web` fills in the build's file list and version. Every file is precached into a
|
||||
// cache named after the version, served cache-first (responses keep the server's headers, so
|
||||
// the page stays cross-origin isolated: COOP/COEP), and old versions are dropped once the new
|
||||
// one activates. Anything else same-origin is fetched from the network and cached for next time.
|
||||
// one activates. Anything else same-origin is fetched from the network and cached for next time,
|
||||
// except the config file, which must stay live.
|
||||
const VERSION = "__EC_VERSION__";
|
||||
const FILES = __EC_FILES__;
|
||||
const CACHE = `effectcraft-${VERSION}`;
|
||||
@@ -30,6 +31,7 @@ self.addEventListener("fetch", (event) => {
|
||||
const req = event.request;
|
||||
const url = new URL(req.url);
|
||||
if (req.method !== "GET" || url.origin !== self.location.origin) return;
|
||||
if (url.pathname.endsWith("/effectcraft-config.json")) return;
|
||||
event.respondWith(
|
||||
(async () => {
|
||||
const cache = await caches.open(CACHE).catch(() => null);
|
||||
|
||||
@@ -35,7 +35,7 @@ fn import_footage(qa: &mut Qa) -> (Value, Value) {
|
||||
// Footage: the sequence, and a layered PSD written by the app itself (Save Frame As ▸
|
||||
// Photoshop Layers) from a small comp.
|
||||
let seq = write_sequence(qa);
|
||||
let imp = qa.exec("file.import", json!({"paths": [seq]}));
|
||||
let imp = qa.exec("file.import", json!({"paths": [seq], "sequence": true}));
|
||||
let seq_item = imp["items"][0].clone();
|
||||
let project = qa.tool("get_project", json!({}));
|
||||
let it = project["items"].as_array().unwrap().iter().find(|i| i["id"] == seq_item).unwrap().clone();
|
||||
|
||||
@@ -52,7 +52,7 @@ fn shake_offset(f: u32) -> (i64, i64) {
|
||||
|
||||
/// Import a sequence at 12 fps and make a comp of it; returns (comp, layer).
|
||||
fn plate_comp(qa: &mut Qa, first: &str, name: &str) -> (Value, Value) {
|
||||
let item = qa.exec("file.import", json!({"paths": [first]}))["items"][0].clone();
|
||||
let item = qa.exec("file.import", json!({"paths": [first], "sequence": true}))["items"][0].clone();
|
||||
qa.exec("file.interpretFootage", json!({"items": [item.clone()], "frameRate": 12}));
|
||||
let comp = qa.exec("comp.new", json!({"name": name, "width": 160, "height": 90, "frameRate": 12, "duration": 1}))["comp"].clone();
|
||||
let layer = qa.exec("layer.addItem", json!({"item": item}))["layer"].clone();
|
||||
|
||||
+108
-6
@@ -174,6 +174,8 @@ pub(crate) struct Decider<'a> {
|
||||
ac_q: f32,
|
||||
dc_qi: i64,
|
||||
ac_qi: i64,
|
||||
/// Smallest magnitude that quantises to a non-zero level, `[dc, ac][intra]`.
|
||||
dead_zone: [[f32; 2]; 2],
|
||||
lambda: f64,
|
||||
lambda_sad: f64,
|
||||
/// Half-sample interpolations of the reference luma (visible area): (8, 0), (0, 8), (8, 8).
|
||||
@@ -183,6 +185,9 @@ pub(crate) struct Decider<'a> {
|
||||
}
|
||||
|
||||
const MODE_BITS_INTRA: f64 = 3.0;
|
||||
/// Dead-zone quantiser rounding offsets (below one half) for inter and intra blocks.
|
||||
const INTER_RND: f32 = 0.30;
|
||||
const INTRA_RND: f32 = 0.38;
|
||||
|
||||
impl<'a> Decider<'a> {
|
||||
pub fn new(g: &'a FrameGeom, bd: u32, qidx: u8, intra_only: bool, src: &'a [Plane; 3], refr: Option<&'a RefFrame>) -> Self {
|
||||
@@ -209,6 +214,7 @@ impl<'a> Decider<'a> {
|
||||
ac_q: ac_qi as f32,
|
||||
dc_qi,
|
||||
ac_qi,
|
||||
dead_zone: [dc_qi, ac_qi].map(|q| [INTER_RND, INTRA_RND].map(|rnd| zero_threshold(q as f32, rnd))),
|
||||
lambda,
|
||||
lambda_sad: lambda.sqrt(),
|
||||
half: match refr {
|
||||
@@ -577,8 +583,9 @@ impl<'a> Decider<'a> {
|
||||
let (dy, dx) = (m[0] >> 3, m[1] >> 3);
|
||||
dx >= lim_x.0 && dx <= lim_x.1 && dy >= lim_y.0 && dy <= lim_y.1
|
||||
};
|
||||
let lg = log2_table();
|
||||
let mv_bits = |m: [i32; 2]| -> f64 {
|
||||
let comp = |d: i32| if d == 0 { 0.5 } else { 2.0 + 2.0 * ((d.abs() / 8) as f64 + 1.0).log2() };
|
||||
let comp = |d: i32| if d == 0 { 0.5 } else { 2.0 + 2.0 * log2_of(lg, (d.abs() / 8) as usize + 1) };
|
||||
comp(m[0] - pred_mv[0]) + comp(m[1] - pred_mv[1])
|
||||
};
|
||||
let cost =
|
||||
@@ -863,12 +870,18 @@ impl<'a> Decider<'a> {
|
||||
}
|
||||
forward_2d(res, coef, n, tx_type);
|
||||
// dead-zone quantiser: rounding offset below one half
|
||||
let rnd = if intra { 0.38f32 } else { 0.30f32 };
|
||||
let rnd = if intra { INTRA_RND } else { INTER_RND };
|
||||
let [dc_thr, ac_thr] = [self.dead_zone[0][intra as usize], self.dead_zone[1][intra as usize]];
|
||||
levels.fill(0);
|
||||
let mut nonzero = false;
|
||||
for (k, (&cf, l)) in coef.iter().zip(levels.iter_mut()).enumerate() {
|
||||
let q = if k == 0 { self.dc_q } else { self.ac_q };
|
||||
let a = (cf.abs() / q + rnd).floor() as i32;
|
||||
let (q, thr) = if k == 0 { (self.dc_q, dc_thr) } else { (self.ac_q, ac_thr) };
|
||||
// dead zone: below the exact threshold the level is zero, no division needed
|
||||
if cf.abs() < thr {
|
||||
continue;
|
||||
}
|
||||
// the operand is >= 0, so truncation equals floor
|
||||
let a = (cf.abs() / q + rnd) as i32;
|
||||
if a != 0 {
|
||||
*l = if cf < 0.0 { -a.min(1 << 16) } else { a.min(1 << 16) };
|
||||
nonzero = true;
|
||||
@@ -913,6 +926,33 @@ impl<'a> Decider<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Smallest `x >= 0` with `(x / q + rnd) as i32 >= 1` in f32 arithmetic. The expression is
|
||||
/// monotone in `x`, so a binary search over the bit patterns finds the exact boundary.
|
||||
fn zero_threshold(q: f32, rnd: f32) -> f32 {
|
||||
let (mut lo, mut hi) = (0u32, f32::MAX.to_bits());
|
||||
while lo < hi {
|
||||
let mid = lo + (hi - lo) / 2;
|
||||
if (f32::from_bits(mid) / q + rnd) as i32 >= 1 {
|
||||
hi = mid;
|
||||
} else {
|
||||
lo = mid + 1;
|
||||
}
|
||||
}
|
||||
f32::from_bits(lo)
|
||||
}
|
||||
|
||||
const LOG2_TABLE_LEN: usize = 1025;
|
||||
|
||||
/// `log2(i)` for `i < LOG2_TABLE_LEN`, computed with the same `f64::log2` call as the direct form.
|
||||
fn log2_table() -> &'static [f64; LOG2_TABLE_LEN] {
|
||||
static TABLE: std::sync::OnceLock<[f64; LOG2_TABLE_LEN]> = std::sync::OnceLock::new();
|
||||
TABLE.get_or_init(|| std::array::from_fn(|i| (i as f64).log2()))
|
||||
}
|
||||
|
||||
fn log2_of(table: &[f64; LOG2_TABLE_LEN], v: usize) -> f64 {
|
||||
if v < LOG2_TABLE_LEN { table[v] } else { (v as f64).log2() }
|
||||
}
|
||||
|
||||
/// Unnormalised Walsh-Hadamard transform of 4 or 8 values, in place.
|
||||
fn hadamard(v: &mut [i32]) {
|
||||
let n = v.len();
|
||||
@@ -941,13 +981,14 @@ fn estimate_bits(levels: &[i32], tx_sz: usize) -> f64 {
|
||||
if eob == 0 {
|
||||
return 1.0;
|
||||
}
|
||||
let mut bits = 3.0 + (eob as f64).log2();
|
||||
let lg = log2_table();
|
||||
let mut bits = 3.0 + log2_of(lg, eob);
|
||||
for &pos in &scan[..eob] {
|
||||
let a = levels[pos as usize].unsigned_abs();
|
||||
bits += match a {
|
||||
0 => 0.7,
|
||||
1 => 2.5,
|
||||
_ => 3.5 + 1.6 * (a as f64).log2(),
|
||||
_ => 3.5 + 1.6 * log2_of(lg, a as usize),
|
||||
};
|
||||
}
|
||||
bits
|
||||
@@ -961,3 +1002,64 @@ pub(crate) fn scan_for(tx_sz: usize) -> &'static [u16] {
|
||||
_ => &DEFAULT_SCAN_32X32,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::tables::{AC_QLOOKUP, DC_QLOOKUP};
|
||||
|
||||
/// The quantiser as it was before the dead-zone threshold and the truncation.
|
||||
fn quant_ref(cf: f32, q: f32, rnd: f32) -> i32 {
|
||||
(cf.abs() / q + rnd).floor() as i32
|
||||
}
|
||||
|
||||
fn quant_new(cf: f32, q: f32, rnd: f32, thr: f32) -> i32 {
|
||||
if cf.abs() < thr {
|
||||
return 0;
|
||||
}
|
||||
(cf.abs() / q + rnd) as i32
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dead_zone_threshold_is_exact() {
|
||||
let mut state = 0x1234_5678_9abc_def0u64;
|
||||
let mut next = move || {
|
||||
state ^= state << 13;
|
||||
state ^= state >> 7;
|
||||
state ^= state << 17;
|
||||
state
|
||||
};
|
||||
for table in AC_QLOOKUP.iter().chain(DC_QLOOKUP.iter()) {
|
||||
for &qi in table.iter() {
|
||||
let q = qi as f32;
|
||||
for rnd in [INTER_RND, INTRA_RND] {
|
||||
let thr = zero_threshold(q, rnd);
|
||||
// the boundary itself and its neighbours
|
||||
let b = thr.to_bits();
|
||||
for bits in b.saturating_sub(3)..=b + 3 {
|
||||
let cf = f32::from_bits(bits);
|
||||
assert_eq!(quant_ref(cf, q, rnd), quant_new(cf, q, rnd, thr), "q {q} rnd {rnd} cf {cf}");
|
||||
assert_eq!(quant_ref(-cf, q, rnd), quant_new(-cf, q, rnd, thr));
|
||||
}
|
||||
for _ in 0..200 {
|
||||
let cf = f32::from_bits((next() >> 32) as u32 & 0x7fff_ffff);
|
||||
if cf.is_nan() {
|
||||
continue;
|
||||
}
|
||||
assert_eq!(quant_ref(cf, q, rnd), quant_new(cf, q, rnd, thr));
|
||||
let cf = (next() % 100_000) as f32 / 100.0;
|
||||
assert_eq!(quant_ref(cf, q, rnd), quant_new(cf, q, rnd, thr));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log2_table_matches_direct_log2() {
|
||||
let t = log2_table();
|
||||
for v in 1..3000usize {
|
||||
assert_eq!(log2_of(t, v).to_bits(), (v as f64).log2().to_bits(), "{v}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ fn sin128(angle: i32) -> i64 {
|
||||
cos128(angle - 64)
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn brev(num_bits: u32, x: usize) -> usize {
|
||||
let mut t = 0;
|
||||
for i in 0..num_bits {
|
||||
@@ -63,8 +64,11 @@ fn hd(t: &mut [i32], a: usize, b: usize, flip: bool, r: u32) {
|
||||
t[b] = (x - y).clamp(lo, hi);
|
||||
}
|
||||
|
||||
/// Inverse DCT of length 2^n, n = 2..=5 (7.13.2.3), in place.
|
||||
fn inverse_dct(t: &mut [i32], n: u32, r: u32) {
|
||||
/// Inverse DCT of length 2^N, N = 2..=5 (7.13.2.3), in place. `N` is a const generic so that the
|
||||
/// bit-reversal permutations and loop bounds fold at compile time.
|
||||
#[inline(always)]
|
||||
fn inverse_dct_n<const N: u32>(t: &mut [i32], r: u32) {
|
||||
let n = N;
|
||||
let len = 1usize << n;
|
||||
let mut copy = [0i32; 32];
|
||||
copy[..len].copy_from_slice(&t[..len]);
|
||||
@@ -76,6 +80,7 @@ fn inverse_dct(t: &mut [i32], n: u32, r: u32) {
|
||||
}
|
||||
|
||||
/// Rotate the high-frequency branches before the four-point core.
|
||||
#[inline(always)]
|
||||
fn inverse_dct_initial_stages(t: &mut [i32], n: u32, r: u32) {
|
||||
if n >= 5 {
|
||||
for i in 0..8 {
|
||||
@@ -112,6 +117,7 @@ fn inverse_dct_initial_stages(t: &mut [i32], n: u32, r: u32) {
|
||||
}
|
||||
|
||||
/// Evaluate the four-point core and merge progressively larger branches.
|
||||
#[inline(always)]
|
||||
fn inverse_dct_final_stages(t: &mut [i32], n: u32, r: u32) {
|
||||
for i in 0..2 {
|
||||
bf(t, 2 * i, 2 * i + 1, 32 + 16 * i as i32, i == 0);
|
||||
@@ -190,6 +196,7 @@ const SINPI_2_9: i64 = 2482;
|
||||
const SINPI_3_9: i64 = 3344;
|
||||
const SINPI_4_9: i64 = 3803;
|
||||
|
||||
#[inline(always)]
|
||||
fn inverse_adst4(t: &mut [i32]) {
|
||||
let (t0, t1, t2, t3) = (t[0] as i64, t[1] as i64, t[2] as i64, t[3] as i64);
|
||||
let mut s = [SINPI_1_9 * t0, SINPI_2_9 * t0, SINPI_3_9 * t1, SINPI_4_9 * t2, SINPI_1_9 * t2, SINPI_2_9 * t3, SINPI_4_9 * t3];
|
||||
@@ -207,7 +214,9 @@ fn inverse_adst4(t: &mut [i32]) {
|
||||
}
|
||||
}
|
||||
|
||||
fn adst_in_permute(t: &mut [i32], n: u32) {
|
||||
#[inline(always)]
|
||||
fn adst_in_permute<const N: u32>(t: &mut [i32]) {
|
||||
let n = N;
|
||||
let n0 = 1usize << n;
|
||||
let mut copy = [0i32; 16];
|
||||
copy[..n0].copy_from_slice(&t[..n0]);
|
||||
@@ -217,7 +226,9 @@ fn adst_in_permute(t: &mut [i32], n: u32) {
|
||||
}
|
||||
}
|
||||
|
||||
fn adst_out_permute(t: &mut [i32], n: u32) {
|
||||
#[inline(always)]
|
||||
fn adst_out_permute<const N: u32>(t: &mut [i32]) {
|
||||
let n = N;
|
||||
let n0 = 1usize << n;
|
||||
let mut copy = [0i32; 16];
|
||||
copy[..n0].copy_from_slice(&t[..n0]);
|
||||
@@ -231,8 +242,9 @@ fn adst_out_permute(t: &mut [i32], n: u32) {
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn inverse_adst8(t: &mut [i32], r: u32) {
|
||||
adst_in_permute(t, 3);
|
||||
adst_in_permute::<3>(t);
|
||||
for i in 0..4 {
|
||||
bf(t, 2 * i, 2 * i + 1, 60 - 16 * i as i32, true);
|
||||
}
|
||||
@@ -250,11 +262,12 @@ fn inverse_adst8(t: &mut [i32], r: u32) {
|
||||
for i in 0..2 {
|
||||
bf(t, 2 + 4 * i, 3 + 4 * i, 32, true);
|
||||
}
|
||||
adst_out_permute(t, 3);
|
||||
adst_out_permute::<3>(t);
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn inverse_adst16(t: &mut [i32], r: u32) {
|
||||
adst_in_permute(t, 4);
|
||||
adst_in_permute::<4>(t);
|
||||
for i in 0..8 {
|
||||
bf(t, 2 * i, 2 * i + 1, 62 - 8 * i as i32, true);
|
||||
}
|
||||
@@ -283,7 +296,7 @@ fn inverse_adst16(t: &mut [i32], r: u32) {
|
||||
for i in 0..4 {
|
||||
bf(t, 2 + 4 * i, 3 + 4 * i, 32, true);
|
||||
}
|
||||
adst_out_permute(t, 4);
|
||||
adst_out_permute::<4>(t);
|
||||
}
|
||||
|
||||
/// 1-D transform kind.
|
||||
@@ -308,6 +321,17 @@ pub(crate) fn kinds(tx_type: usize) -> (Kind, Kind) {
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn inverse_dct(t: &mut [i32], n: u32, r: u32) {
|
||||
match n {
|
||||
2 => inverse_dct_n::<2>(t, r),
|
||||
3 => inverse_dct_n::<3>(t, r),
|
||||
4 => inverse_dct_n::<4>(t, r),
|
||||
_ => inverse_dct_n::<5>(t, r),
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn inverse_1d(t: &mut [i32], kind: Kind, n: u32, r: u32) {
|
||||
match kind {
|
||||
Kind::Dct => inverse_dct(t, n, r),
|
||||
@@ -322,7 +346,17 @@ fn inverse_1d(t: &mut [i32], kind: Kind, n: u32, r: u32) {
|
||||
/// 2-D inverse transform (7.13.3) of a square `n x n` block (n = 4..=32) of dequantised
|
||||
/// coefficients (row stride `n`) into `residual` (row stride `n`).
|
||||
pub(crate) fn inverse_2d(dequant: &[i32], residual: &mut [i32], tx_sz: usize, tx_type: usize, bit_depth: u32) {
|
||||
let log2 = TX_WIDTH_LOG2[tx_sz] as u32;
|
||||
match TX_WIDTH_LOG2[tx_sz] {
|
||||
2 => inverse_2d_n::<2>(dequant, residual, tx_sz, tx_type, bit_depth),
|
||||
3 => inverse_2d_n::<3>(dequant, residual, tx_sz, tx_type, bit_depth),
|
||||
4 => inverse_2d_n::<4>(dequant, residual, tx_sz, tx_type, bit_depth),
|
||||
_ => inverse_2d_n::<5>(dequant, residual, tx_sz, tx_type, bit_depth),
|
||||
}
|
||||
}
|
||||
|
||||
/// `inverse_2d` for a block of width `1 << LOG2`.
|
||||
fn inverse_2d_n<const LOG2: u32>(dequant: &[i32], residual: &mut [i32], tx_sz: usize, tx_type: usize, bit_depth: u32) {
|
||||
let log2 = LOG2;
|
||||
let n = 1usize << log2;
|
||||
let row_shift = TRANSFORM_ROW_SHIFT[tx_sz] as u32;
|
||||
let row_clamp = bit_depth + 8;
|
||||
@@ -429,6 +463,51 @@ fn basis(kind: Kind, n: usize) -> &'static [f32] {
|
||||
/// Forward 2-D transform of the `n x n` residual (row stride `n`) into orthonormal-domain
|
||||
/// coefficients scaled by 8 (so that `level = out / q` with the spec's dequantiser).
|
||||
pub(crate) fn forward_2d(residual: &[i32], out: &mut [f32], n: usize, tx_type: usize) {
|
||||
match n {
|
||||
4 => forward_2d_n::<4, 16>(residual, out, tx_type),
|
||||
8 => forward_2d_n::<8, 64>(residual, out, tx_type),
|
||||
16 => forward_2d_n::<16, 256>(residual, out, tx_type),
|
||||
_ => forward_2d_dyn(residual, out, n, tx_type),
|
||||
}
|
||||
}
|
||||
|
||||
/// `forward_2d` for a fixed size, so `tmp` is exactly `NN = N * N` floats (no 4 KiB zeroing).
|
||||
#[inline(always)]
|
||||
fn forward_2d_n<const N: usize, const NN: usize>(residual: &[i32], out: &mut [f32], tx_type: usize) {
|
||||
let n = N;
|
||||
let (ck, rk) = kinds(tx_type);
|
||||
let bc = &basis(ck, n)[..NN];
|
||||
let brt = &basis_t(rk, n)[..NN];
|
||||
let residual = &residual[..NN];
|
||||
let out = &mut out[..NN];
|
||||
let mut tmp = [0f32; NN];
|
||||
for i in 0..n {
|
||||
let t = &mut tmp[i * n..i * n + n];
|
||||
for j in 0..n {
|
||||
let r = residual[i * n + j];
|
||||
if r == 0 {
|
||||
continue;
|
||||
}
|
||||
let r = r as f32;
|
||||
for (o, &b) in t.iter_mut().zip(&brt[j * n..j * n + n]) {
|
||||
*o += r * b;
|
||||
}
|
||||
}
|
||||
}
|
||||
out.iter_mut().for_each(|v| *v = 0.0);
|
||||
for k in 0..n {
|
||||
let o = &mut out[k * n..k * n + n];
|
||||
for i in 0..n {
|
||||
let b = 8.0 * bc[k * n + i];
|
||||
for (v, &t) in o.iter_mut().zip(&tmp[i * n..i * n + n]) {
|
||||
*v += b * t;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 32x32 (and any other size): a const-generic 32 version measured slower.
|
||||
fn forward_2d_dyn(residual: &[i32], out: &mut [f32], n: usize, tx_type: usize) {
|
||||
let (ck, rk) = kinds(tx_type);
|
||||
let bc = basis(ck, n);
|
||||
let brt = basis_t(rk, n);
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
//! Bounded parsed-JSON caches. Keep the content key with the owned source text so repeated
|
||||
//! renders do not rehash long analyses. Compare contents on lookup: evaluated parameter
|
||||
//! strings can be cloned, edited in place, or allocated again at an old address.
|
||||
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
struct Entry<T> {
|
||||
json: Box<str>,
|
||||
key: u64,
|
||||
value: Arc<T>,
|
||||
}
|
||||
|
||||
pub(crate) struct AnalysisCache<T> {
|
||||
entries: OnceLock<Mutex<Vec<Entry<T>>>>,
|
||||
}
|
||||
|
||||
pub(crate) fn fnv(bytes: &[u8]) -> u64 {
|
||||
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
for b in bytes {
|
||||
h ^= *b as u64;
|
||||
h = h.wrapping_mul(0x0100_0000_01b3);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
impl<T> AnalysisCache<T> {
|
||||
pub(crate) const fn new() -> Self {
|
||||
Self { entries: OnceLock::new() }
|
||||
}
|
||||
|
||||
pub(crate) fn get(&self, json: &str, parse: impl FnOnce(&str) -> Option<T>) -> Option<(u64, Arc<T>)> {
|
||||
let entries = self.entries.get_or_init(Default::default);
|
||||
{
|
||||
let m = entries.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(e) = m.iter().rev().find(|e| e.json.as_ref() == json) {
|
||||
return Some((e.key, e.value.clone()));
|
||||
}
|
||||
}
|
||||
let value = Arc::new(parse(json)?);
|
||||
let key = fnv(json.as_bytes());
|
||||
let mut m = entries.lock().unwrap_or_else(|e| e.into_inner());
|
||||
// Another render may have filled the entry while this one parsed it.
|
||||
if let Some(e) = m.iter().rev().find(|e| e.json.as_ref() == json) {
|
||||
return Some((e.key, e.value.clone()));
|
||||
}
|
||||
// Preserve the existing nine-entry bound and clear-on-overflow policy.
|
||||
if m.len() > 8 {
|
||||
m.clear();
|
||||
}
|
||||
m.push(Entry { json: json.into(), key, value: value.clone() });
|
||||
Some((key, value))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::cell::Cell;
|
||||
|
||||
#[test]
|
||||
fn reuses_content_and_invalidates_same_allocation_edits() {
|
||||
let cache = AnalysisCache::new();
|
||||
let calls = Cell::new(0);
|
||||
let parse = |s: &str| {
|
||||
calls.set(calls.get() + 1);
|
||||
Some(s.to_owned())
|
||||
};
|
||||
let mut json = String::from("analysis-1");
|
||||
let (key, a) = cache.get(&json, parse).unwrap();
|
||||
let copy = json.clone();
|
||||
assert_ne!(json.as_ptr(), copy.as_ptr());
|
||||
let (same_key, same) = cache.get(©, parse).unwrap();
|
||||
assert_eq!(key, fnv(json.as_bytes()));
|
||||
assert_eq!(key, same_key);
|
||||
assert!(Arc::ptr_eq(&a, &same));
|
||||
assert_eq!(calls.get(), 1);
|
||||
let ptr = json.as_ptr();
|
||||
json.replace_range(9..10, "2");
|
||||
assert_eq!(ptr, json.as_ptr());
|
||||
let (changed_key, changed) = cache.get(&json, parse).unwrap();
|
||||
assert_ne!(key, changed_key);
|
||||
assert_eq!(&*changed, "analysis-2");
|
||||
assert_eq!(&*a, "analysis-1");
|
||||
assert_eq!(calls.get(), 2);
|
||||
assert!(cache.get("invalid", |_| None).is_none());
|
||||
assert!(Arc::ptr_eq(&cache.get(&json, parse).unwrap().1, &changed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_text_retention_is_bounded() {
|
||||
let cache = AnalysisCache::new();
|
||||
for i in 0..30 {
|
||||
cache.get(&i.to_string(), |s| Some(s.to_owned())).unwrap();
|
||||
assert!(cache.entries.get().unwrap().lock().unwrap().len() <= 9);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -206,6 +206,12 @@ pub fn reverses(id: &str) -> bool {
|
||||
id == "ec.audio.backwards"
|
||||
}
|
||||
|
||||
/// Does each output sample depend only on the input sample at the same time (no state), so the
|
||||
/// mixer may run the effect in short pieces with parameters animated between them?
|
||||
pub fn per_sample(id: &str) -> bool {
|
||||
matches!(id, "ec.audio.stereomixer" | "ec.audio.backwards")
|
||||
}
|
||||
|
||||
/// Seconds of input history the effect needs before a block to reach the state it would have
|
||||
/// had when processing continuously.
|
||||
pub fn preroll(id: &str, params: &Params) -> f64 {
|
||||
|
||||
@@ -16,8 +16,9 @@
|
||||
//! on it draws the solved points (as the viewer shows them) into the frame. The viewer overlay, target and the Create
|
||||
//! commands live in the engine and the UI.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::analysis_cache::AnalysisCache;
|
||||
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::ParamUi;
|
||||
@@ -122,50 +123,24 @@ pub fn solve_key(st: &SolveSettings) -> String {
|
||||
format!("{:?}|{hfov}|{:?}|{:?}{lens}", st.shot, st.method, st.deleted)
|
||||
}
|
||||
|
||||
fn fnv(bytes: &[u8]) -> u64 {
|
||||
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
for b in bytes {
|
||||
h ^= *b as u64;
|
||||
h = h.wrapping_mul(0x0100_0000_01b3);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
type Cache<T> = Mutex<HashMap<u64, Arc<T>>>;
|
||||
|
||||
fn cached<T>(c: &'static OnceLock<Cache<T>>, json: &str, parse: impl Fn(&str) -> Option<T>) -> Option<Arc<T>> {
|
||||
let key = fnv(json.as_bytes());
|
||||
let c = c.get_or_init(Default::default);
|
||||
if let Some(a) = c.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
|
||||
return Some(a.clone());
|
||||
}
|
||||
let a = Arc::new(parse(json)?);
|
||||
let mut m = c.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if m.len() > 8 {
|
||||
m.clear();
|
||||
}
|
||||
m.insert(key, a.clone());
|
||||
Some(a)
|
||||
}
|
||||
|
||||
/// The stored feature tracks of an instance (`None` when not analysed).
|
||||
pub fn tracks(params: &Params) -> Option<Arc<CameraTracks>> {
|
||||
static C: OnceLock<Cache<CameraTracks>> = OnceLock::new();
|
||||
static C: AnalysisCache<CameraTracks> = AnalysisCache::new();
|
||||
let j = s(params, TRACKS);
|
||||
if j.is_empty() {
|
||||
return None;
|
||||
}
|
||||
cached(&C, j, CameraTracks::from_json).filter(|t| !t.is_empty())
|
||||
C.get(j, CameraTracks::from_json).map(|(_, a)| a).filter(|t| !t.is_empty())
|
||||
}
|
||||
|
||||
/// The stored camera solve of an instance (`None` when not solved).
|
||||
pub fn solve(params: &Params) -> Option<Arc<CameraSolve>> {
|
||||
static C: OnceLock<Cache<CameraSolve>> = OnceLock::new();
|
||||
static C: AnalysisCache<CameraSolve> = AnalysisCache::new();
|
||||
let j = s(params, SOLVE);
|
||||
if j.is_empty() {
|
||||
return None;
|
||||
}
|
||||
cached(&C, j, CameraSolve::from_json).filter(|t| !t.is_empty())
|
||||
C.get(j, CameraSolve::from_json).map(|(_, a)| a).filter(|t| !t.is_empty())
|
||||
}
|
||||
|
||||
/// Screen size (layer pixels) of a target drawn for a point at depth `z`, relative to the
|
||||
@@ -338,6 +313,27 @@ mod tests {
|
||||
assert!(!st.lens_distortion && !solve_key(&st).contains("lens"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracks_and_solve_follow_analysis_replacements() {
|
||||
let mut t = CameraTracks { frames: 3, start: 1.0, ..Default::default() };
|
||||
let mut sol = CameraSolve { frames: vec![SolvedFrame::default()], start: 1.0, ..Default::default() };
|
||||
let mut ps = params_with(&sol, false);
|
||||
ps.values.insert(TRACKS.into(), Value::Str(t.to_json()));
|
||||
assert_eq!(*tracks(&ps).unwrap(), t);
|
||||
assert_eq!(*solve(&ps).unwrap(), sol);
|
||||
t.start = 2.0;
|
||||
sol.start = 2.0;
|
||||
ps.values.insert(TRACKS.into(), Value::Str(t.to_json()));
|
||||
ps.values.insert(SOLVE.into(), Value::Str(sol.to_json()));
|
||||
assert_eq!(*tracks(&ps).unwrap(), t);
|
||||
assert_eq!(*solve(&ps).unwrap(), sol);
|
||||
assert_eq!(*tracks(&ps.clone()).unwrap(), t);
|
||||
ps.values.insert(TRACKS.into(), Value::Str("invalid".into()));
|
||||
ps.values.insert(SOLVE.into(), Value::Str(String::new()));
|
||||
assert!(tracks(&ps).is_none());
|
||||
assert!(solve(&ps).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undistort_footage_remaps_through_the_solved_lens() {
|
||||
use effectcraft_track::camtrack::Distortion;
|
||||
|
||||
@@ -182,6 +182,20 @@ mod tests {
|
||||
assert!(have == want, "docs/effects.md is stale: UPDATE_DOCS=1 cargo test -p effectcraft-effects --lib effects_doc_is_current");
|
||||
}
|
||||
|
||||
/// The README quotes the number of effects (#2565): every count it gives is the registry's.
|
||||
#[test]
|
||||
fn readme_effect_counts_are_current() {
|
||||
let readme = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../../README.md")).unwrap_or_default();
|
||||
let words: Vec<&str> = readme.split_whitespace().collect();
|
||||
let counts: Vec<&str> = words
|
||||
.windows(3)
|
||||
.filter(|w| w[0].parse::<u32>().is_ok() && (w[1].starts_with("effects") || (w[1] == "built-in" && w[2].starts_with("effects"))))
|
||||
.map(|w| w[0])
|
||||
.collect();
|
||||
let n = super::registry().len().to_string();
|
||||
assert!(!counts.is_empty() && counts.iter().all(|c| *c == n), "README.md says {counts:?} effects; the registry has {n}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_only_controls_keep_effects_off_the_gpu() {
|
||||
use effectcraft_keyframe::Value;
|
||||
|
||||
@@ -7,6 +7,11 @@
|
||||
//! pass for a precomp of a 3D comp. Without auxiliary channels they pass the layer through
|
||||
//! (EXtractoR still maps the layer's own R, G, B and A).
|
||||
//!
|
||||
//! 3D Layer Instance (Create 3D Layer Instance's own effect, Layer ▸ Create) is not an aux
|
||||
//! reader: it replaces the layer's pixels with a referenced layer's render — for a 3D model
|
||||
//! layer, its render on its own through the comp's camera — so 2D effects can be applied to a
|
||||
//! 3D layer's render.
|
||||
//!
|
||||
//! Aux pixels are looked up nearest-neighbour (IDs must not be interpolated) at each buffer
|
||||
//! pixel's layer position, so render scale and layer padding are respected.
|
||||
|
||||
@@ -582,6 +587,19 @@ fn extractor(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
b
|
||||
}
|
||||
|
||||
/// A 2D copy of a referenced layer (Create 3D Layer Instance's solid): the layer's pixels
|
||||
/// replaced with the referenced layer's render, alpha included — the copy is transparent
|
||||
/// where the referenced layer isn't. A 3D model or primitive layer is rendered on its own
|
||||
/// through the comp's camera, as After Effects renders 3D layers; another layer is read with
|
||||
/// its masks and effects. With no layer chosen the effect passes the layer through, as a layer
|
||||
/// parameter with nothing chosen does.
|
||||
fn instance_3d(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
if let Some(o) = ctx.layer_param("layer", true) {
|
||||
b.img = crate::util::fit_layer(ctx, &b, &o, false);
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<EffectSpec> {
|
||||
let text = |id: &'static str, name: &'static str, d: &str| p(id, name, Value::Str(d.into()), ParamUi::Text);
|
||||
vec![
|
||||
@@ -682,6 +700,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
],
|
||||
identifier,
|
||||
),
|
||||
spec("ec.3d.instance3d", "3D Layer Instance", vec![p("layer", "Layer", Value::Layer(None), ParamUi::Layer)], instance_3d),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -373,3 +373,29 @@ fn channel_extract_antialias_averages_sub_pixels() {
|
||||
let soft = run("ec.3d.channelextract", &[vals.as_slice(), &[("antialias", Value::Bool(true))]].concat(), &host);
|
||||
assert!((soft.get(5, 5)[0] - 0.5).abs() < 1e-5, "{:?}", soft.get(5, 5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance3d_replaces_the_pixels_with_the_referenced_layer() {
|
||||
// Create 3D Layer Instance's copy: the chosen layer's render takes the place of the layer's
|
||||
// own pixels, alpha included — the copy is transparent where the referenced layer isn't —
|
||||
// and with no layer chosen the layer passes through.
|
||||
struct Host(Image);
|
||||
impl EffectHost for Host {
|
||||
fn layer(&self, id: u64, _: bool) -> Option<LayerPixels> {
|
||||
(id == 7).then(|| LayerPixels { buf: Buf { img: self.0.clone(), offset: [0.0; 2], scale: 1.0 }, size: [self.0.width as f64, self.0.height as f64] })
|
||||
}
|
||||
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
let solid = || Image::filled(4, 4, [1.0, 1.0, 1.0, 1.0]);
|
||||
let mut render = Image::new(4, 4);
|
||||
render.set(1, 1, [1.0, 0.0, 0.0, 1.0]);
|
||||
let env = EffectEnv { host: Some(&Host(render)), ..Default::default() };
|
||||
let out = run_fx("ec.3d.instance3d", &[("layer", Value::Layer(Some(7)))], solid(), 0.0, env);
|
||||
assert_eq!(out.img.get(1, 1), [1.0, 0.0, 0.0, 1.0], "the referenced layer's pixel");
|
||||
assert_eq!(out.img.get(3, 3), [0.0; 4], "transparent where the referenced layer isn't");
|
||||
// No layer chosen: the layer's own pixels.
|
||||
let out = run_fx("ec.3d.instance3d", &[], solid(), 0.0, EffectEnv::default());
|
||||
assert_eq!(out.img.data, solid().data);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod analysis_cache;
|
||||
pub mod audio_fx;
|
||||
mod blur2;
|
||||
mod blur3;
|
||||
@@ -311,17 +312,34 @@ impl Buf {
|
||||
self.offset[0] += l as f64;
|
||||
self.offset[1] += t as f64;
|
||||
}
|
||||
/// Grow by `pad` transparent pixels on every side.
|
||||
/// Grow by `pad` transparent pixels on every side, within [`pad_within_budget`] and as
|
||||
/// clamped by [`Image::effective_pad`].
|
||||
pub fn pad(&mut self, pad: u32) {
|
||||
if pad == 0 {
|
||||
let eff = self.img.effective_pad(pad_within_budget(self.img.width, self.img.height, pad));
|
||||
if eff == 0 {
|
||||
return;
|
||||
}
|
||||
self.img = self.img.padded(pad);
|
||||
self.offset[0] += pad as f64;
|
||||
self.offset[1] += pad as f64;
|
||||
self.img = self.img.padded(eff);
|
||||
self.offset[0] += eff as f64;
|
||||
self.offset[1] += eff as f64;
|
||||
}
|
||||
}
|
||||
|
||||
/// `pad` on every side of a `w` × `h` effect buffer, cut so the padded buffer stays within
|
||||
/// 16 Mpx (256 MiB of f32 RGBA) or four times its own area, whichever is larger: a small layer
|
||||
/// far from an effect's centre can't ask for gigabytes (#2261). Shared with the GPU path.
|
||||
pub fn pad_within_budget(w: u32, h: u32, pad: u32) -> u32 {
|
||||
const MIN_BUDGET: f64 = 16_777_216.0;
|
||||
let (w, h) = (f64::from(w), f64::from(h));
|
||||
let budget = MIN_BUDGET.max(4.0 * w * h);
|
||||
if (w + 2.0 * f64::from(pad)) * (h + 2.0 * f64::from(pad)) <= budget {
|
||||
return pad;
|
||||
}
|
||||
// (w + 2p)(h + 2p) = budget, solved for p.
|
||||
let p = (((w - h).powi(2) + 4.0 * budget).sqrt() - (w + h)) / 4.0;
|
||||
(p.floor().max(0.0) as u32).min(pad)
|
||||
}
|
||||
|
||||
/// Motion Tile's output frame for a buffer with layer origin `offset` (pixels) at `scale`:
|
||||
/// `(width, height, offset)`. Output Width / Height grow (or crop) the layer rectangle about its
|
||||
/// centre, on adjustment layers too, where the rectangle is the comp's and starts at the comp
|
||||
@@ -1312,11 +1330,11 @@ pub fn is_time_dependent(id: &str) -> bool {
|
||||
id.starts_with("ec.sim.") || TIME_DEPENDENT.contains(&id)
|
||||
}
|
||||
|
||||
/// Does effect `id` read its own parameters at earlier times (CC Particle World samples its
|
||||
/// Birth Rate at every step, #1373)? Caches keyed on the current values can't see what an
|
||||
/// Does effect `id` read its own parameters at earlier times (the stepped particle systems sample
|
||||
/// their Birth Rate at every step, #1373)? Caches keyed on the current values can't see what an
|
||||
/// expression returned then.
|
||||
pub fn reads_param_history(id: &str) -> bool {
|
||||
id == "ec.sim.ccparticleworld"
|
||||
matches!(id, "ec.sim.ccparticleworld" | "ec.sim.ccparticlesystems2" | "ec.sim.ccmrmercury")
|
||||
}
|
||||
|
||||
/// Run an effect on a buffer.
|
||||
@@ -1343,6 +1361,23 @@ pub(crate) fn run_fx(id: &str, vals: &[(&str, Value)], img: Image, time: f64, en
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// #2261: a 20 × 20 layer with CC Radial Fast Blur's Center far away grows to at most 16 Mpx;
|
||||
/// big layers may still grow to four times their area, and small pads are untouched.
|
||||
#[test]
|
||||
fn padded_buffers_stay_within_the_budget() {
|
||||
let area = |w: u32, h: u32, p: u32| (u64::from(w) + 2 * u64::from(p)) * (u64::from(h) + 2 * u64::from(p));
|
||||
let far = radial_fast_pad((6500.0, 6500.0), 20, 20, 0.8);
|
||||
assert_eq!(far, 4096, "the effect asks for its full pad");
|
||||
let mut b = Buf { img: Image::new(20, 20), offset: [0.0; 2], scale: 1.0 };
|
||||
b.pad(far);
|
||||
assert!(area(20, 20, 0) < area(b.img.width, b.img.height, 0) && area(b.img.width, b.img.height, 0) <= 1 << 24, "{}x{}", b.img.width, b.img.height);
|
||||
assert_eq!(b.offset, [f64::from((b.img.width - 20) / 2); 2], "the content keeps its place");
|
||||
let p = pad_within_budget(3840, 2160, 4096);
|
||||
assert!(p > 1000 && area(3840, 2160, p) <= 4 * 3840 * 2160 && area(3840, 2160, p + 1) > 4 * 3840 * 2160);
|
||||
assert_eq!(pad_within_budget(640, 360, 200), 200);
|
||||
assert_eq!(pad_within_budget(20, 20, 0), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_fx_passthrough() {
|
||||
let img = Image::filled(4, 3, [0.5, 0.25, 0.125, 1.0]);
|
||||
@@ -1350,6 +1385,20 @@ mod tests {
|
||||
assert_eq!(out.img.data, img.data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pad_offset_follows_the_clamped_pad() {
|
||||
// Image::padded clamps a hostile pad to keep each side within MAX_PADDED_SIDE; the offset
|
||||
// must move by the pad actually applied, or the content drifts from its layer position.
|
||||
let mut img = Image::new(29_990, 2);
|
||||
img.set(0, 0, [1.0, 0.0, 0.0, 1.0]);
|
||||
let mut buf = Buf { img, offset: [3.0, 4.0], scale: 1.0 };
|
||||
buf.pad(u32::MAX);
|
||||
// Width binds: (MAX_PADDED_SIDE - 29 990) / 2 = 5.
|
||||
assert_eq!(buf.offset, [8.0, 9.0]);
|
||||
let (x, y) = buf.to_px([-3.0, -4.0]);
|
||||
assert_eq!(buf.img.get(x as i64, y as i64), [1.0, 0.0, 0.0, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn dump_registry() {
|
||||
|
||||
@@ -15,7 +15,7 @@ use effectcraft_raster::{Image, Px};
|
||||
use rayon::prelude::*;
|
||||
|
||||
use crate::generate::value_noise;
|
||||
use crate::util::{SimCache, hash1, params_key, params_key_except, unpremul};
|
||||
use crate::util::{SimCache, hash1, params_key_except, unpremul};
|
||||
use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider};
|
||||
|
||||
mod common;
|
||||
@@ -28,13 +28,13 @@ mod weather;
|
||||
pub(crate) use common::{h, hs, pct, pt, spec};
|
||||
pub use engine::particle_state;
|
||||
#[cfg(test)]
|
||||
pub(crate) use engine::{PState, PW_ANIMS, Phys, simulate};
|
||||
use engine::{MERCURY, PS2, PW_EFFECT_CAMERA, Stepped, rate_history, sim_key, simulate_rates};
|
||||
#[cfg(test)]
|
||||
use engine::{PW_EFFECT_CAMERA, rate_history, sim_key, simulate_rates};
|
||||
pub(crate) use engine::{PState, PW_ANIMS, Phys, simulate};
|
||||
pub(crate) use engine::{SPS, steps_at};
|
||||
pub use particle_fx::{Blob, mercury_blobs};
|
||||
#[cfg(test)]
|
||||
use particle_fx::{PW_RATE, pw_phys};
|
||||
use particle_fx::{PW_RATE, mercury_phys, ps2_phys, pw_phys};
|
||||
pub use registry::{specs, sprite_plan};
|
||||
pub use sprites::{Acc, Post, Shape, Sprite, SpritePlan, Tint};
|
||||
pub(crate) use sprites::{combine, raster, splat};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::common::{h, hs};
|
||||
use super::particle_fx::{PS2_CACHE, PW_CACHE, PW_RATE, ps2_phys, pw_phys};
|
||||
use super::particle_fx::{MERCURY_CACHE, MERCURY_RATE, PS2_CACHE, PS2_RATE, PW_CACHE, PW_RATE, ps2_phys, pw_phys};
|
||||
use super::sprites::{Shape, Sprite};
|
||||
use super::{Buf, EffectCtx, Image, IntoParallelRefMutIterator, ParallelIterator, SimCache, params_key_except, value_noise};
|
||||
|
||||
@@ -114,6 +114,8 @@ impl Phys {
|
||||
Particle { p, v, age: 0.0, life, rnd: h(id, 11, seed), id }
|
||||
}
|
||||
|
||||
/// One step at the constant [`Phys::rate`] (the tests' reference).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn step(&self, st: &mut PState, step: u64) {
|
||||
self.step_at_rate(st, step, self.rate);
|
||||
}
|
||||
@@ -274,12 +276,39 @@ pub(super) fn simulate_rates(cache: &SimCache<PState>, key: u64, phys: &Phys, ra
|
||||
cache.run_where(key, rates.len() as u64, PState::default, |st, i| phys.step_at_rate(st, i, rate(i)), |s, st| prefix.get(s as usize) == Some(&st.hist))
|
||||
}
|
||||
|
||||
/// A stepped particle system's particles at `ctx.time`, born at the Birth Rate of their own
|
||||
/// time (`birthRate` × `k` particles per second, see [`rate_history`]). The cache key is every
|
||||
/// parameter but Birth Rate (its history is checked through `PState::hist`) and those under
|
||||
/// `skip`, with `salt`. `gpu`: the host's particle backend may simulate it.
|
||||
pub(super) struct Stepped<'a> {
|
||||
pub(super) cache: &'a SimCache<PState>,
|
||||
pub(super) salt: u64,
|
||||
pub(super) skip: &'a [&'a str],
|
||||
pub(super) k: f64,
|
||||
pub(super) gpu: bool,
|
||||
}
|
||||
|
||||
impl Stepped<'_> {
|
||||
pub(super) fn state(&self, ctx: &EffectCtx, phys: &Phys) -> std::sync::Arc<PState> {
|
||||
let rates = rate_history(ctx, "birthRate", self.k);
|
||||
let mut skip = self.skip.to_vec();
|
||||
skip.push("birthRate");
|
||||
let key = params_key_except(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, self.salt, &skip);
|
||||
let gpu = if self.gpu { accelerated(ctx, key, phys, Some(&rates)) } else { None };
|
||||
gpu.unwrap_or_else(|| simulate_rates(self.cache, key, phys, &rates))
|
||||
}
|
||||
}
|
||||
|
||||
/// CC Particle World.
|
||||
const PW: Stepped = Stepped { cache: &PW_CACHE, salt: 1, skip: &[PW_EFFECT_CAMERA], k: PW_RATE, gpu: true };
|
||||
/// CC Particle Systems II.
|
||||
pub(super) const PS2: Stepped = Stepped { cache: &PS2_CACHE, salt: 2, skip: &[], k: PS2_RATE, gpu: true };
|
||||
/// CC Mr. Mercury (its blobs are simulated on the CPU).
|
||||
pub(super) const MERCURY: Stepped = Stepped { cache: &MERCURY_CACHE, salt: 3, skip: &[], k: MERCURY_RATE, gpu: false };
|
||||
|
||||
/// CC Particle World's particles at `ctx.time`, born at the Birth Rate of their own time.
|
||||
pub(super) fn pw_state(ctx: &EffectCtx, phys: &Phys) -> std::sync::Arc<PState> {
|
||||
let rates = rate_history(ctx, "birthRate", PW_RATE);
|
||||
// Every parameter but Birth Rate (its history is checked through `PState::hist`).
|
||||
let key = params_key_except(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 1, &[PW_EFFECT_CAMERA, "birthRate"]);
|
||||
accelerated(ctx, key, phys, Some(&rates)).unwrap_or_else(|| simulate_rates(&PW_CACHE, key, phys, &rates))
|
||||
PW.state(ctx, phys)
|
||||
}
|
||||
|
||||
/// The live particles of a stepped particle effect (`ec.sim.ccparticleworld`,
|
||||
@@ -288,23 +317,22 @@ pub(super) fn pw_state(ctx: &EffectCtx, phys: &Phys) -> std::sync::Arc<PState> {
|
||||
pub fn particle_state(id: &str, ctx: &EffectCtx) -> Option<Vec<crate::psim::SimParticle>> {
|
||||
let st = match id {
|
||||
"ec.sim.ccparticleworld" => pw_state(ctx, &pw_phys(ctx)),
|
||||
"ec.sim.ccparticlesystems2" => {
|
||||
let (phys, key) = (ps2_phys(ctx), sim_key(ctx, 2));
|
||||
accelerated(ctx, key, &phys, None).unwrap_or_else(|| simulate(&PS2_CACHE, key, ctx.time, &phys))
|
||||
}
|
||||
"ec.sim.ccparticlesystems2" => PS2.state(ctx, &ps2_phys(ctx)),
|
||||
_ => return None,
|
||||
};
|
||||
Some(st.parts.iter().map(|q| crate::psim::SimParticle { p: q.p, v: q.v, age: q.age, life: q.life, rnd: q.rnd, id: q.id }).collect())
|
||||
}
|
||||
|
||||
/// The simulation cache key of a stepped particle effect: every parameter but CC Particle World's
|
||||
/// Effect Camera, which only changes how the particles are seen (animating it doesn't restart
|
||||
/// the simulation).
|
||||
/// The simulation cache key of a stepped particle effect at a constant Birth Rate (the tests'
|
||||
/// reference): every parameter but CC Particle World's Effect Camera.
|
||||
#[cfg(test)]
|
||||
pub(super) fn sim_key(ctx: &EffectCtx, salt: u64) -> u64 {
|
||||
params_key_except(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, salt, &[PW_EFFECT_CAMERA])
|
||||
}
|
||||
|
||||
/// Run a particle system to layer time `t` through `cache`.
|
||||
/// Run a particle system at its constant [`Phys::rate`] to layer time `t` through `cache` (the
|
||||
/// tests' reference).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn simulate(cache: &SimCache<PState>, key: u64, t: f64, phys: &Phys) -> std::sync::Arc<PState> {
|
||||
cache.run(key, steps_at(t), PState::default, |st, i| phys.step(st, i))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::engine::{PState, PW_EFFECT_CAMERA, Phys, accelerated, norm3, particle_look, pw_state, sim_key, simulate, type_sprite};
|
||||
use super::engine::{MERCURY, PS2, PState, PW_EFFECT_CAMERA, Phys, norm3, particle_look, pw_state, type_sprite};
|
||||
use super::sprites::{Acc, Post, Sprite, SpritePlan, raster, splat};
|
||||
use super::{Buf, EffectCtx, Image, IntoParallelRefIterator, ParallelIterator, SimCache, Value, params_key, unpremul};
|
||||
use super::{Buf, EffectCtx, Image, IntoParallelRefIterator, ParallelIterator, SimCache, Value, unpremul};
|
||||
|
||||
// ------------------------------------------------------------------ CC Particle World
|
||||
|
||||
@@ -192,6 +192,9 @@ fn particle_sprites(sprites: Vec<Sprite>, mode: u32) -> SpritePlan {
|
||||
|
||||
pub(super) static PS2_CACHE: SimCache<PState> = SimCache::new(6);
|
||||
|
||||
/// Particles per second per unit of CC Particle Systems II's Birth Rate.
|
||||
pub(super) const PS2_RATE: f64 = 60.0;
|
||||
|
||||
/// CC Particle Systems II's physics.
|
||||
pub(super) fn ps2_phys(ctx: &EffectCtx) -> Phys {
|
||||
let pr = ctx.params;
|
||||
@@ -200,7 +203,7 @@ pub(super) fn ps2_phys(ctx: &EffectCtx) -> Phys {
|
||||
let pos = pr.v2("producer/position");
|
||||
let dir = (pr.f("physics/direction") as f32).to_radians();
|
||||
Phys {
|
||||
rate: pr.f("birthRate").max(0.0) * 60.0,
|
||||
rate: pr.f("birthRate").max(0.0) * PS2_RATE,
|
||||
life: pr.f("longevity").max(0.0) as f32,
|
||||
producer: [pos[0] as f32 / unit, pos[1] as f32 / unit, 0.0],
|
||||
radius: [pr.f("producer/radiusX").max(0.0) as f32 / unit, pr.f("producer/radiusY").max(0.0) as f32 / unit, 0.0],
|
||||
@@ -227,9 +230,7 @@ pub(super) fn particle_systems2_plan(ctx: &EffectCtx, b: &Buf) -> SpritePlan {
|
||||
let pr = ctx.params;
|
||||
let lh = ctx.layer_size[1] as f32;
|
||||
let unit = lh.max(1.0);
|
||||
let phys = ps2_phys(ctx);
|
||||
let key = sim_key(ctx, 2);
|
||||
let st = accelerated(ctx, key, &phys, None).unwrap_or_else(|| simulate(&PS2_CACHE, key, ctx.time, &phys));
|
||||
let st = PS2.state(ctx, &ps2_phys(ctx));
|
||||
let kind = pr.e("particle/particleType");
|
||||
let birth = pr.color("particle/birthColor");
|
||||
let death = pr.color("particle/deathColor");
|
||||
@@ -252,7 +253,10 @@ pub(super) fn particle_systems2_plan(ctx: &EffectCtx, b: &Buf) -> SpritePlan {
|
||||
|
||||
// ------------------------------------------------------------------ CC Mr. Mercury
|
||||
|
||||
static MERCURY_CACHE: SimCache<PState> = SimCache::new(6);
|
||||
pub(super) static MERCURY_CACHE: SimCache<PState> = SimCache::new(6);
|
||||
|
||||
/// Blobs per second per unit of CC Mr. Mercury's Birth Rate.
|
||||
pub(super) const MERCURY_RATE: f64 = 10.0;
|
||||
|
||||
/// A CC Mr. Mercury blob (buffer px).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -262,15 +266,14 @@ pub struct Blob {
|
||||
pub r: f32,
|
||||
}
|
||||
|
||||
/// CC Mr. Mercury's blobs this frame, from its particle simulation (for buffer geometry `b`).
|
||||
pub fn mercury_blobs(ctx: &EffectCtx, b: &Buf) -> Vec<Blob> {
|
||||
/// CC Mr. Mercury's physics.
|
||||
pub(super) fn mercury_phys(ctx: &EffectCtx) -> Phys {
|
||||
let pr = ctx.params;
|
||||
let lh = ctx.layer_size[1] as f32;
|
||||
let unit = lh.max(1.0);
|
||||
let unit = (ctx.layer_size[1] as f32).max(1.0);
|
||||
let pos = pr.v2("producer");
|
||||
let dir = (pr.f("direction") as f32).to_radians();
|
||||
let phys = Phys {
|
||||
rate: pr.f("birthRate").max(0.0) * 10.0,
|
||||
Phys {
|
||||
rate: pr.f("birthRate").max(0.0) * MERCURY_RATE,
|
||||
life: pr.f("longevity").max(0.0) as f32,
|
||||
producer: [pos[0] as f32 / unit, pos[1] as f32 / unit, 0.0],
|
||||
radius: [pr.f("radiusX").max(0.0) as f32 / unit, pr.f("radiusY").max(0.0) as f32 / unit, 0.0],
|
||||
@@ -284,8 +287,15 @@ pub fn mercury_blobs(ctx: &EffectCtx, b: &Buf) -> Vec<Blob> {
|
||||
seed: ctx.seed.wrapping_mul(0xc2b2ae35),
|
||||
max: 4_000,
|
||||
flat: true,
|
||||
};
|
||||
let st = simulate(&MERCURY_CACHE, params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 3), ctx.time, &phys);
|
||||
}
|
||||
}
|
||||
|
||||
/// CC Mr. Mercury's blobs this frame, from its particle simulation (for buffer geometry `b`).
|
||||
pub fn mercury_blobs(ctx: &EffectCtx, b: &Buf) -> Vec<Blob> {
|
||||
let pr = ctx.params;
|
||||
let unit = (ctx.layer_size[1] as f32).max(1.0);
|
||||
let phys = mercury_phys(ctx);
|
||||
let st = MERCURY.state(ctx, &phys);
|
||||
let (bs, ds) = (pr.f("blobBirthSize") as f32, pr.f("blobDeathSize") as f32);
|
||||
let s = b.scale as f32;
|
||||
st.parts
|
||||
|
||||
@@ -561,3 +561,110 @@ fn the_birth_schedule_follows_the_rate_history() {
|
||||
assert_eq!(st.next_id as usize, births.len());
|
||||
assert!(births.iter().all(|&b| f64::from(b) / SPS < 0.5), "nothing born once the ramp reaches 0");
|
||||
}
|
||||
|
||||
// ------------------------- CC Particle Systems II and CC Mr. Mercury's Birth Rate over time
|
||||
|
||||
/// A stepped particle effect: its id, physics and simulation.
|
||||
struct System {
|
||||
id: &'static str,
|
||||
phys: fn(&EffectCtx) -> Phys,
|
||||
stepped: Stepped<'static>,
|
||||
}
|
||||
|
||||
const SYSTEMS: [System; 2] =
|
||||
[System { id: "ec.sim.ccparticlesystems2", phys: ps2_phys, stepped: PS2 }, System { id: "ec.sim.ccmrmercury", phys: mercury_phys, stepped: MERCURY }];
|
||||
|
||||
/// `id`'s parameters with Birth Rate `now` and Longevity 1 s.
|
||||
fn sys_params(id: &str, now: f64) -> crate::Params {
|
||||
let spec = crate::find(id).unwrap();
|
||||
let mut p = crate::Params { values: spec.params.iter().map(|ps| (ps.id.to_string(), crate::default_value(ps, [100.0, 100.0]))).collect() };
|
||||
for (k, v) in [("birthRate", num(now)), ("longevity", num(1.0))] {
|
||||
assert!(p.values.contains_key(k), "{id}: no {k}");
|
||||
p.values.insert(k.to_string(), v);
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// The context at layer time `t` (`seed` keeps each test's simulations apart).
|
||||
fn sys_ctx<'a>(p: &'a crate::Params, t: f64, host: Option<&'a Rate>, seed: u32) -> EffectCtx<'a> {
|
||||
let env = EffectEnv { host: host.map(|h| h as &dyn crate::EffectHost), effect_index: host.map_or(0, |h| h.index), ..Default::default() };
|
||||
EffectCtx { params: p, time: t, layer_size: [100.0, 100.0], seed, adjustment: false, env }
|
||||
}
|
||||
|
||||
/// The live particles (id, position, age) of `sys` at `t` through its simulation.
|
||||
fn sys_at(sys: &System, seed: u32, now: f64, t: f64, host: Option<&Rate>) -> Vec<(u32, [f32; 3], f32)> {
|
||||
let p = sys_params(sys.id, now);
|
||||
let ctx = sys_ctx(&p, t, host, seed);
|
||||
sys.stepped.state(&ctx, &(sys.phys)(&ctx)).parts.iter().map(|q| (q.id, q.p, q.age)).collect()
|
||||
}
|
||||
|
||||
/// The same, simulated from layer time 0 with a cache of its own (the reference).
|
||||
fn sys_fresh(sys: &System, seed: u32, now: f64, t: f64, host: &Rate) -> Vec<(u32, [f32; 3], f32)> {
|
||||
let p = sys_params(sys.id, now);
|
||||
let ctx = sys_ctx(&p, t, Some(host), seed);
|
||||
let mut skip = sys.stepped.skip.to_vec();
|
||||
skip.push("birthRate");
|
||||
let key = params_key_except(&ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, sys.stepped.salt, &skip);
|
||||
let st = simulate_rates(&SimCache::new(1), key, &(sys.phys)(&ctx), &rate_history(&ctx, "birthRate", sys.stepped.k));
|
||||
st.parts.iter().map(|q| (q.id, q.p, q.age)).collect()
|
||||
}
|
||||
|
||||
/// What `sys` draws at `t`: CC Particle Systems II's particles, CC Mr. Mercury's blobs.
|
||||
fn sys_drawn(sys: &System, seed: u32, now: f64, t: f64, host: Option<&Rate>) -> usize {
|
||||
let p = sys_params(sys.id, now);
|
||||
let ctx = sys_ctx(&p, t, host, seed);
|
||||
match sys.id {
|
||||
"ec.sim.ccmrmercury" => mercury_blobs(&ctx, &Buf { img: Image::new(100, 100), offset: [0.0; 2], scale: 1.0 }).len(),
|
||||
id => particle_state(id, &ctx).unwrap().len(),
|
||||
}
|
||||
}
|
||||
|
||||
/// #1373 for CC Particle Systems II and CC Mr. Mercury: a burst lives on after Birth Rate
|
||||
/// drops to 0 (what they draw too), moving and ageing, and is gone after its Longevity.
|
||||
#[test]
|
||||
fn particle_systems_ii_and_mr_mercury_keep_a_burst_after_birth_rate_drops() {
|
||||
let host = Rate { index: 1, rate: burst };
|
||||
for sys in &SYSTEMS {
|
||||
let born = crate::psim::births_at(steps_at(0.5), 40_000, |i| burst(i as f64 / SPS) * sys.stepped.k).unwrap().len();
|
||||
assert_eq!(sys_at(sys, 201, 3.0, 0.4, Some(&host)), sys_at(sys, 201, 3.0, 0.4, None), "{}: during the burst", sys.id);
|
||||
let late = sys_at(sys, 201, 0.0, 0.9, Some(&host));
|
||||
assert!(late.len() > born / 2 && late.iter().all(|q| (q.0 as usize) < born), "{}: {} live of {born}", sys.id, late.len());
|
||||
assert!(sys_drawn(sys, 201, 0.0, 0.9, Some(&host)) > 0, "{}: drawn", sys.id);
|
||||
assert_eq!(sys_drawn(sys, 201, 0.0, 0.9, None), 0, "{}: a constant 0 bore none", sys.id);
|
||||
let early = sys_at(sys, 201, 0.0, 0.7, Some(&host));
|
||||
let q0 = early.iter().find(|q| q.0 == late[0].0).unwrap();
|
||||
assert!(q0.1 != late[0].1 && late[0].2 > q0.2 + 0.19, "{}: {q0:?} -> {:?}", sys.id, late[0]);
|
||||
assert!(sys_at(sys, 201, 0.0, 1.8, Some(&host)).is_empty(), "{}: gone after Longevity", sys.id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Seeking matches a simulation from 0, and moving an earlier key (same current rate, same
|
||||
/// cache key) isn't served from the old history's checkpoints.
|
||||
#[test]
|
||||
fn particle_systems_ii_and_mr_mercury_seek_and_drop_stale_checkpoints() {
|
||||
let (long, short) = (Rate { index: 0, rate: burst }, Rate { index: 0, rate: short_burst });
|
||||
for sys in &SYSTEMS {
|
||||
for t in [1.4, 0.6, 0.9, 0.3, 0.9, 1.1] {
|
||||
let now = burst(t);
|
||||
assert_eq!(sys_at(sys, 202, now, t, Some(&long)), sys_fresh(sys, 202, now, t, &long), "{} at {t}", sys.id);
|
||||
}
|
||||
let b = sys_at(sys, 202, 0.0, 0.9, Some(&short));
|
||||
assert_eq!(b, sys_fresh(sys, 202, 0.0, 0.9, &short), "{}: the moved key", sys.id);
|
||||
assert!(!b.is_empty() && b.len() < sys_at(sys, 202, 0.0, 0.9, Some(&long)).len(), "{}", sys.id);
|
||||
}
|
||||
}
|
||||
|
||||
/// A constant Birth Rate gives exactly the particles the constant-rate simulation did.
|
||||
#[test]
|
||||
fn particle_systems_ii_and_mr_mercury_are_unchanged_at_a_constant_rate() {
|
||||
let host = Rate { index: 0, rate: |_| 2.0 };
|
||||
for sys in &SYSTEMS {
|
||||
let p = sys_params(sys.id, 2.0);
|
||||
let ctx = sys_ctx(&p, 1.3, None, 203);
|
||||
let before = simulate(&SimCache::new(1), 1, 1.3, &(sys.phys)(&ctx));
|
||||
let before: Vec<_> = before.parts.iter().map(|q| (q.id, q.p, q.age)).collect();
|
||||
assert!(!before.is_empty());
|
||||
assert_eq!(sys_at(sys, 203, 2.0, 1.3, Some(&host)), before, "{}: keyed to a constant", sys.id);
|
||||
assert_eq!(sys_at(sys, 203, 2.0, 1.3, None), before, "{}: no host", sys.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,15 +152,6 @@ pub fn settings(params: &Params, fps: f64) -> StabSettings {
|
||||
}
|
||||
}
|
||||
|
||||
fn fnv(bytes: &[u8]) -> u64 {
|
||||
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
for b in bytes {
|
||||
h ^= *b as u64;
|
||||
h = h.wrapping_mul(0x0100_0000_01b3);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
struct KeyHasher(u64);
|
||||
impl Hasher for KeyHasher {
|
||||
fn finish(&self) -> u64 {
|
||||
@@ -183,24 +174,12 @@ fn settings_key(s: &StabSettings) -> u64 {
|
||||
h.finish()
|
||||
}
|
||||
|
||||
type AnalysisCache = Mutex<HashMap<u64, Arc<WarpAnalysis>>>;
|
||||
type PlanCache = Mutex<HashMap<(u64, u64), Arc<Plan>>>;
|
||||
|
||||
/// Parsed analyses by content hash (parsing a long clip's JSON every frame would dominate).
|
||||
/// Parsed analyses and their content keys; cache hits compare source text without rehashing.
|
||||
fn analysis_cached(json: &str) -> Option<(u64, Arc<WarpAnalysis>)> {
|
||||
static C: OnceLock<AnalysisCache> = OnceLock::new();
|
||||
let key = fnv(json.as_bytes());
|
||||
let c = C.get_or_init(Default::default);
|
||||
if let Some(a) = c.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
|
||||
return Some((key, a.clone()));
|
||||
}
|
||||
let a = Arc::new(WarpAnalysis::from_json(json)?);
|
||||
let mut m = c.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if m.len() > 8 {
|
||||
m.clear();
|
||||
}
|
||||
m.insert(key, a.clone());
|
||||
Some((key, a))
|
||||
static C: crate::analysis_cache::AnalysisCache<WarpAnalysis> = crate::analysis_cache::AnalysisCache::new();
|
||||
C.get(json, WarpAnalysis::from_json)
|
||||
}
|
||||
|
||||
fn plans() -> &'static PlanCache {
|
||||
@@ -544,6 +523,7 @@ fn dot(img: &mut Image, c: [f64; 2], r: f64, col: Px) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::EffectEnv;
|
||||
use crate::analysis_cache::fnv;
|
||||
use effectcraft_track::stabilize::FrameMotion;
|
||||
|
||||
fn analysis_json(n: usize, w: f64, h: f64, shift: impl Fn(usize) -> [f64; 2]) -> String {
|
||||
@@ -650,6 +630,25 @@ mod tests {
|
||||
assert!(!prepare_default_plan(&WarpAnalysis::default().to_json()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changing_analysis_changes_the_plan_key() {
|
||||
let json = analysis_json(10, 64.0, 48.0, |k| [k as f64 * 0.1, 0.0]);
|
||||
let mut ps = params(&json, 2, 0);
|
||||
let before = summary_key(&ps).unwrap();
|
||||
assert_eq!(before[0], fnv(json.as_bytes()));
|
||||
let mut a = WarpAnalysis::from_json(&json).unwrap();
|
||||
a.start = 1.0;
|
||||
ps.values.insert(ANALYSIS.into(), Value::Str(a.to_json()));
|
||||
let after = summary_key(&ps).unwrap();
|
||||
assert_ne!(before[0], after[0]);
|
||||
assert_eq!(before[1], after[1]);
|
||||
assert_eq!(*analysis(&ps).unwrap(), a);
|
||||
ps.values.insert(ANALYSIS.into(), Value::Str(json));
|
||||
assert_eq!(summary_key(&ps).unwrap(), before);
|
||||
ps.values.insert(ANALYSIS.into(), Value::Str(String::new()));
|
||||
assert!(analysis(&ps).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_param_lookup() {
|
||||
let mut p = Params::default();
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
{
|
||||
"format": "effectcraft.animationPresets/1",
|
||||
"notes": "Original EffectCraft behavior presets (MIT OR Apache-2.0), written from scratch. Each one drives a transform property or opacity with a time-based expression built on the property's own value, so the motion runs for the layer's whole duration and still follows the property's keyframes. Apply to any layer; no effects are added.",
|
||||
"presets": [
|
||||
{
|
||||
"id": "wiggleSoft",
|
||||
"name": "Soft Wiggle",
|
||||
"category": "Behaviors",
|
||||
"description": "The layer wanders gently around its position.",
|
||||
"layer": {
|
||||
"expr": {
|
||||
"transform/position": "var w = wiggle(1.2, 30); [w[0], w[1]]"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "shake",
|
||||
"name": "Shake",
|
||||
"category": "Behaviors",
|
||||
"description": "A fast, tight shake of the layer's position.",
|
||||
"layer": {
|
||||
"expr": {
|
||||
"transform/position": "var w = wiggle(14, 12); [w[0], w[1]]"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "floatUpDown",
|
||||
"name": "Float",
|
||||
"category": "Behaviors",
|
||||
"description": "The layer bobs up and down like it floats on water.",
|
||||
"layer": {
|
||||
"expr": {
|
||||
"transform/position": "[value[0]+(0), value[1]+(Math.sin(time*2)*18)]"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "sway",
|
||||
"name": "Sway",
|
||||
"category": "Behaviors",
|
||||
"description": "The layer sways left and right.",
|
||||
"layer": {
|
||||
"expr": {
|
||||
"transform/position": "[value[0]+(Math.sin(time*1.6)*40), value[1]+(0)]"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "orbit",
|
||||
"name": "Orbit",
|
||||
"category": "Behaviors",
|
||||
"description": "The layer travels round a circle around its position.",
|
||||
"layer": {
|
||||
"expr": {
|
||||
"transform/position": "[value[0]+(Math.cos(time*2)*80), value[1]+(Math.sin(time*2)*80)]"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "figureEight",
|
||||
"name": "Figure Eight",
|
||||
"category": "Behaviors",
|
||||
"description": "The layer traces a figure eight around its position.",
|
||||
"layer": {
|
||||
"expr": {
|
||||
"transform/position": "[value[0]+(Math.sin(time*1.5)*100), value[1]+(Math.sin(time*3)*45)]"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "bouncingBall",
|
||||
"name": "Bouncing Ball",
|
||||
"category": "Behaviors",
|
||||
"description": "The layer bounces up from its position again and again.",
|
||||
"layer": {
|
||||
"expr": {
|
||||
"transform/position": "[value[0]+(0), value[1]+(-Math.abs(Math.sin(time*3.2))*140)]"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "driftRight",
|
||||
"name": "Drift Right",
|
||||
"category": "Behaviors",
|
||||
"description": "The layer drifts steadily to the right.",
|
||||
"layer": {
|
||||
"expr": {
|
||||
"transform/position": "[value[0]+(time*40), value[1]+(0)]"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "driftDiagonal",
|
||||
"name": "Drift Diagonal",
|
||||
"category": "Behaviors",
|
||||
"description": "The layer drifts slowly down and to the right.",
|
||||
"layer": {
|
||||
"expr": {
|
||||
"transform/position": "[value[0]+(time*30), value[1]+(time*18)]"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "pendulum",
|
||||
"name": "Pendulum",
|
||||
"category": "Behaviors",
|
||||
"description": "The layer swings like a pendulum.",
|
||||
"layer": {
|
||||
"expr": {
|
||||
"transform/rotation": "value + Math.sin(time*2.4)*25"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "wobble",
|
||||
"name": "Wobble",
|
||||
"category": "Behaviors",
|
||||
"description": "The layer rotates with an irregular wobble.",
|
||||
"layer": {
|
||||
"expr": {
|
||||
"transform/rotation": "wiggle(2, 10)"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "spinSlow",
|
||||
"name": "Spin Slow",
|
||||
"category": "Behaviors",
|
||||
"description": "The layer rotates once every ten seconds.",
|
||||
"layer": {
|
||||
"expr": {
|
||||
"transform/rotation": "value + time*36"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "spinFast",
|
||||
"name": "Spin Fast",
|
||||
"category": "Behaviors",
|
||||
"description": "The layer rotates twice a second.",
|
||||
"layer": {
|
||||
"expr": {
|
||||
"transform/rotation": "value + time*720"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "pulse",
|
||||
"name": "Pulse",
|
||||
"category": "Behaviors",
|
||||
"description": "The layer swells and shrinks smoothly.",
|
||||
"layer": {
|
||||
"expr": {
|
||||
"transform/scale": "[value[0]*(1+0.12*Math.sin(time*4)), value[1]*(1+0.12*Math.sin(time*4))]"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "heartbeat",
|
||||
"name": "Heartbeat",
|
||||
"category": "Behaviors",
|
||||
"description": "Two quick beats, then a rest, repeating every second.",
|
||||
"layer": {
|
||||
"expr": {
|
||||
"transform/scale": "var t = time % 1; var b = t < 0.3 ? Math.sin(t/0.3*Math.PI) : (t < 0.6 ? 0.7*Math.sin((t-0.3)/0.3*Math.PI) : 0); var k = 1 + 0.2*b; [value[0]*k, value[1]*k]"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "breathe",
|
||||
"name": "Breathe",
|
||||
"category": "Behaviors",
|
||||
"description": "The layer slowly grows and settles back.",
|
||||
"layer": {
|
||||
"expr": {
|
||||
"transform/scale": "[value[0]*(1+0.06*Math.sin(time*1.1)), value[1]*(1+0.06*Math.sin(time*1.1))]"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "rubberBand",
|
||||
"name": "Rubber Band",
|
||||
"category": "Behaviors",
|
||||
"description": "Width and height stretch against each other.",
|
||||
"layer": {
|
||||
"expr": {
|
||||
"transform/scale": "[value[0]*(1+0.15*Math.sin(time*5)), value[1]*(1-0.15*Math.sin(time*5))]"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jitterScale",
|
||||
"name": "Jitter Scale",
|
||||
"category": "Behaviors",
|
||||
"description": "The layer's size jitters randomly.",
|
||||
"layer": {
|
||||
"expr": {
|
||||
"transform/scale": "var w = wiggle(8, 6); [w[0], w[1]]"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "blink",
|
||||
"name": "Blink",
|
||||
"category": "Behaviors",
|
||||
"description": "The layer switches on and off twice a second.",
|
||||
"layer": {
|
||||
"expr": {
|
||||
"transform/opacity": "(Math.floor(time*4) % 2 === 0) ? value : 0"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "strobe",
|
||||
"name": "Strobe",
|
||||
"category": "Behaviors",
|
||||
"description": "The layer flashes on for a few frames each beat.",
|
||||
"layer": {
|
||||
"expr": {
|
||||
"transform/opacity": "((time*6) % 1 < 0.25) ? value : 0"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "flicker",
|
||||
"name": "Flicker",
|
||||
"category": "Behaviors",
|
||||
"description": "The layer's opacity flickers like a failing lamp.",
|
||||
"layer": {
|
||||
"expr": {
|
||||
"transform/opacity": "var w = wiggle(14, 45); Math.max(0, Math.min(100, w))"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "fadePulse",
|
||||
"name": "Fade Pulse",
|
||||
"category": "Behaviors",
|
||||
"description": "Opacity breathes between full and a third.",
|
||||
"layer": {
|
||||
"expr": {
|
||||
"transform/opacity": "value*(0.67+0.33*Math.sin(time*2.5))"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
asset: behaviors.json
|
||||
title: EffectCraft Behaviors animation presets (Soft Wiggle, Shake, Float, Sway, Orbit, Figure Eight, Bouncing Ball, Drift Right, Drift Diagonal, Pendulum, Wobble, Spin Slow, Spin Fast, Pulse, Heartbeat, Breathe, Rubber Band, Jitter Scale, Blink, Strobe, Flicker, Fade Pulse)
|
||||
author: EffectCraft contributors
|
||||
source: original work
|
||||
license: MIT OR Apache-2.0
|
||||
added: 2026-10-15 by vangel76 (Claude Code agent, gap 7, #1510)
|
||||
notes: Expressions and values written from scratch for EffectCraft on its own transform properties and expression engine; not derived from any Adobe preset (.ffx) or its values.
|
||||
@@ -77,7 +77,7 @@
|
||||
"name": "Tracking In",
|
||||
"properties": { "tracking": 0, "opacity": 100 },
|
||||
"keys": {
|
||||
"tracking": [[0, 400], [1.6, 0]],
|
||||
"tracking": [[0, 40], [1.6, 0]],
|
||||
"opacity": [[0, 0], [0.8, 100]]
|
||||
},
|
||||
"ease": true,
|
||||
|
||||
@@ -207,7 +207,7 @@ pub(crate) fn source_frame(
|
||||
t: Tick,
|
||||
expr: Option<&dyn ExprHost>,
|
||||
) -> Option<(Arc<Image>, [f64; 2])> {
|
||||
let ctx = EvalCtx { project, comp_id: cid, comp, time: t, expr, footage: None };
|
||||
let ctx = EvalCtx { project, comp_id: cid, comp, time: t, expr, footage: None, property_overrides: &[], media_overrides: &[] };
|
||||
if let effectcraft_project::LayerSource::Footage { item } = &layer.source
|
||||
&& let Some(effectcraft_project::ItemKind::Footage(f)) = project.item(*item).map(|i| &i.kind)
|
||||
&& f.has_video
|
||||
|
||||
@@ -45,6 +45,7 @@ fn copy(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
clip.push(KeyClip { layer: lid, path, keys });
|
||||
}
|
||||
let n: usize = clip.iter().map(|c| c.keys.len()).sum();
|
||||
s.state.keyframe_text = crate::keyframe_text::text(&s.project, comp, &s.state.selected_keys, &[]);
|
||||
s.state.key_clipboard = clip;
|
||||
s.state.clip_is_keys = true;
|
||||
Ok(json!(n))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! EffectCraft's own animation preset library (Effects & Presets ▸ * Animation Presets): the
|
||||
//! Transitions category (original presets built on our Transition effects and layer opacity) and
|
||||
//! the Backgrounds category (generators, noise and colour effects driven by expressions).
|
||||
//! the Backgrounds category (generators, noise and colour effects driven by expressions) and the
|
||||
//! Behaviors category (transform and opacity driven by expressions, no effects).
|
||||
//!
|
||||
//! Each preset adds effects (with values and keyframes) and/or keys layer properties, starting
|
||||
//! at the current time, on every selected layer. The library is data
|
||||
@@ -26,13 +27,17 @@ pub const TRANSITIONS_JSON: &str = include_str!("../../presets/transitions.json"
|
||||
/// The original background presets (`presets/backgrounds.json`).
|
||||
pub const BACKGROUNDS_JSON: &str = include_str!("../../presets/backgrounds.json");
|
||||
|
||||
/// The original behavior presets (`presets/behaviors.json`): expression-driven motion on a layer's
|
||||
/// transform and opacity, no effects.
|
||||
pub const BEHAVIORS_JSON: &str = include_str!("../../presets/behaviors.json");
|
||||
|
||||
/// Keys per animated property, so a hostile inline preset can't allocate without bound.
|
||||
const MAX_KEYS: usize = 256;
|
||||
/// Longest expression text an inline preset may carry.
|
||||
const MAX_EXPR: usize = 4096;
|
||||
|
||||
fn presets() -> Vec<Value> {
|
||||
[TRANSITIONS_JSON, BACKGROUNDS_JSON]
|
||||
[TRANSITIONS_JSON, BACKGROUNDS_JSON, BEHAVIORS_JSON]
|
||||
.iter()
|
||||
.flat_map(|src| serde_json::from_str::<Value>(src).ok().and_then(|v| v.get("presets").and_then(Value::as_array).cloned()).unwrap_or_default())
|
||||
.collect()
|
||||
|
||||
@@ -10,11 +10,16 @@ use effectcraft_project::{GroupKind, Layer, LayerId, LayerSource, Node, Project,
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::effect::{effect_time_error, retime_effect};
|
||||
use super::{CommandSpec, bad, frontend, has_comp, has_keys, has_layers, layer_mut, layers_p, match_path_of, resolve_effect_path, selected_leaf_props, str_p};
|
||||
use crate::{EngineError, KeyRef, Result, Session, cmd};
|
||||
|
||||
/// Preset file format version tag (`*.ecpreset`, JSON).
|
||||
pub const PRESET_TAG: &str = "effectcraftAnimationPreset";
|
||||
/// The preset format version that `anim.savePreset` writes.
|
||||
pub const PRESET_VERSION: u64 = 2;
|
||||
/// The first preset version that stores effect keys relative to the earliest key (#1971).
|
||||
const RELATIVE_EFFECT_KEYS: u64 = 2;
|
||||
|
||||
fn has_selected_props(s: &Session) -> std::result::Result<(), String> {
|
||||
has_comp(s)?;
|
||||
@@ -45,13 +50,26 @@ fn selected_effects(s: &Session) -> Vec<(LayerId, PropGroup)> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The earliest key anywhere in `groups`, nested controls included.
|
||||
fn earliest_key<'a>(groups: impl IntoIterator<Item = &'a PropGroup>) -> Option<Tick> {
|
||||
let mut first: Option<Tick> = None;
|
||||
for g in groups {
|
||||
g.walk("", &mut |_, pr| {
|
||||
if let Some(t) = pr.keys.iter().map(|k| k.time).min() {
|
||||
first = Some(first.map_or(t, |f| f.min(t)));
|
||||
}
|
||||
});
|
||||
}
|
||||
first
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- presets
|
||||
|
||||
fn save_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = str_p(p, "path").ok_or_else(|| bad("anim.savePreset", "missing `path`"))?.to_string();
|
||||
let comp = s.active_comp().ok_or(EngineError::NoComp)?;
|
||||
let t = s.time();
|
||||
let effects: Vec<(LayerId, PropGroup)> = selected_effects(s);
|
||||
let mut effects: Vec<(LayerId, PropGroup)> = selected_effects(s);
|
||||
let effect_uids: Vec<Uid> = effects.iter().map(|(_, g)| g.uid).collect();
|
||||
let mut props = vec![];
|
||||
for (lid, uid) in selected_leaf_props(s) {
|
||||
@@ -67,7 +85,11 @@ fn save_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad("anim.savePreset", "select properties or effects to save"));
|
||||
}
|
||||
// Key times are stored relative to the earliest key so the preset applies at the CTI.
|
||||
let first = props.iter().flat_map(|(_, _, pr)| pr.keys.iter().map(|k| k.time)).min().unwrap_or(Tick::ZERO);
|
||||
let first =
|
||||
props.iter().flat_map(|(_, _, pr)| pr.keys.iter().map(|k| k.time)).chain(earliest_key(effects.iter().map(|(_, g)| g))).min().unwrap_or(Tick::ZERO);
|
||||
for (_, g) in &mut effects {
|
||||
retime_effect(g, 1.0, "anim.savePreset", |t| Ok(Tick(t.0.checked_sub(first.0).ok_or_else(|| effect_time_error("anim.savePreset"))?)))?;
|
||||
}
|
||||
let props_json: Vec<Value> = props
|
||||
.iter()
|
||||
.map(|(_, mp, pr)| {
|
||||
@@ -82,7 +104,7 @@ fn save_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_p(p, "name")
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| std::path::Path::new(&path).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default());
|
||||
let doc = json!({PRESET_TAG: 1, "name": name, "properties": props_json, "effects": fx_json});
|
||||
let doc = json!({PRESET_TAG: PRESET_VERSION, "name": name, "properties": props_json, "effects": fx_json});
|
||||
let text = serde_json::to_string_pretty(&doc).unwrap_or_default();
|
||||
s.services.write_file(&path, text.as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
|
||||
Ok(json!({"path": path, "properties": props.len(), "effects": effects.len()}))
|
||||
@@ -125,8 +147,16 @@ fn apply_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.save_prefs();
|
||||
s.prefs_revision += 1;
|
||||
}
|
||||
let effects: Vec<PropGroup> =
|
||||
let mut effects: Vec<PropGroup> =
|
||||
doc["effects"].as_array().map(|a| a.iter().filter_map(|v| serde_json::from_value(v.clone()).ok()).collect()).unwrap_or_default();
|
||||
// Older presets stored effect keys at the source layer's times.
|
||||
if doc[PRESET_TAG].as_u64().unwrap_or(1) < RELATIVE_EFFECT_KEYS
|
||||
&& let Some(first) = earliest_key(&effects)
|
||||
{
|
||||
for g in &mut effects {
|
||||
retime_effect(g, 1.0, "anim.applyPreset", |t| Ok(Tick(t.0.checked_sub(first.0).ok_or_else(|| effect_time_error("anim.applyPreset"))?)))?;
|
||||
}
|
||||
}
|
||||
let props: Vec<(String, Property)> = doc["properties"]
|
||||
.as_array()
|
||||
.map(|a| {
|
||||
@@ -168,6 +198,7 @@ fn apply_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(spec) = spec {
|
||||
effectcraft_effects::migrate::upgrade_instance(spec, &mut g, &mut Ids(&mut next), size);
|
||||
}
|
||||
retime_effect(&mut g, 1.0, "anim.applyPreset", |t| Ok(Tick(t.0.checked_add(lt.0).ok_or_else(|| effect_time_error("anim.applyPreset"))?)))?;
|
||||
g.reassign_uids(&mut next);
|
||||
next += 1;
|
||||
fx.children.push(Node::Group(g));
|
||||
|
||||
@@ -152,7 +152,7 @@ fn clear_recent_presets(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
fn save_copy_xml(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = str_p(p, "path").ok_or_else(|| bad("file.saveCopyAsXml", "missing `path` (.ecprojx)"))?;
|
||||
let xml = crate::xml_project::to_xml(&s.project.to_file_json_with_view(&s.saved_view())?).map_err(EngineError::Other)?;
|
||||
let xml = crate::xml_project::to_xml(&s.project.to_file_json_with_view(&s.saved_view(), false)?).map_err(EngineError::Other)?;
|
||||
s.services.write_file(path, xml.as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
|
||||
s.toast(format!("Saved an XML copy to {path}"));
|
||||
Ok(json!({"path": path, "bytes": xml.len()}))
|
||||
|
||||
@@ -5,7 +5,7 @@ use effectcraft_project::Marker;
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, comp_id, f_p, has_comp, has_layers, layers_p, str_p};
|
||||
use super::{CommandSpec, always, b_p, bad, comp_id, f_p, has_comp, has_layers, layers_p, str_p};
|
||||
use crate::prefs::CompPreset;
|
||||
use crate::{EngineError, Result, Session, cmd, query};
|
||||
|
||||
@@ -82,7 +82,7 @@ pub(crate) fn encode_png(rgba: &[u8], w: u32, h: u32) -> std::result::Result<Vec
|
||||
/// Save Frame As ▸ File with `queue`: a Render Queue item for the current frame, from the Frame
|
||||
/// Default templates (Edit ▸ Templates).
|
||||
fn queue_frame(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
use effectcraft_project::render_queue::{RenderQueueItem, TimeSpan};
|
||||
use effectcraft_project::render_queue::{Channels, RenderQueueItem, TimeSpan};
|
||||
use effectcraft_project::render_templates::TemplateSlot;
|
||||
let cid = comp_id(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
@@ -91,6 +91,9 @@ fn queue_frame(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
it.settings = s.project.render_templates.default_render_settings(TemplateSlot::Still);
|
||||
it.settings.time_span = TimeSpan::Custom { start: t, end: t + comp.frame_duration() };
|
||||
it.output = s.project.render_templates.default_output_module(TemplateSlot::Still);
|
||||
if b_p(p, "transparent") == Some(false) {
|
||||
it.output.channels = Channels::Rgb;
|
||||
}
|
||||
if let Some(path) = str_p(p, "path") {
|
||||
it.output.output = path.to_string();
|
||||
} else if !it.output.format.is_sequence() {
|
||||
@@ -107,7 +110,7 @@ fn queue_frame(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn save_frame(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if p.get("queue").and_then(Value::as_bool) == Some(true) {
|
||||
if b_p(p, "queue") == Some(true) {
|
||||
return queue_frame(s, p);
|
||||
}
|
||||
let path = str_p(p, "path").ok_or_else(|| bad("comp.saveFrameAs", "missing `path` (.png)"))?.to_string();
|
||||
@@ -117,7 +120,7 @@ fn save_frame(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let scale = f_p(p, "scale").unwrap_or(1.0).clamp(0.01, 4.0);
|
||||
let bg = comp.background;
|
||||
let img = s.render(cid, t, effectcraft_render::RenderOpts { scale, backend: effectcraft_render::Backend::Auto, ..Default::default() });
|
||||
let rgba = img.to_rgba8_over(bg);
|
||||
let rgba = if b_p(p, "transparent") == Some(false) { img.to_rgba8_over(bg) } else { img.to_rgba8() };
|
||||
let png = encode_png(&rgba, img.width, img.height).map_err(|e| EngineError::Other(format!("PNG encoding failed: {e}")))?;
|
||||
s.services.write_file(&path, &png).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
|
||||
s.toast(format!("Saved frame to {path}"));
|
||||
@@ -235,7 +238,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"File...",
|
||||
["Composition", "Save Frame As"],
|
||||
Some("Cmd+Alt+S"),
|
||||
"{path (.png; with queue: an output path or template), time?, scale?, queue?: bool (add a Render Queue item from the Frame Default templates instead of writing now)}",
|
||||
"{path (.png; with queue: an output path or template), time?, scale?, queue?: bool (add a Render Queue item from the Frame Default templates instead of writing now), transparent?: bool (default true)}",
|
||||
has_comp,
|
||||
save_frame
|
||||
),
|
||||
|
||||
@@ -170,6 +170,21 @@ pub struct FillPlan {
|
||||
pub dir: String,
|
||||
}
|
||||
|
||||
/// The fill holds every frame as float RGBA (16 bytes a pixel), its hole mask (1) and the filled
|
||||
/// copy (16) until it finishes (#1789): when even that is more than `available`, the error to
|
||||
/// report up front, instead of running out of memory partway through.
|
||||
fn memory_shortfall(frames: usize, w: u32, h: u32, available: u64) -> Option<String> {
|
||||
let need = (frames as u64).saturating_mul(u64::from(w)).saturating_mul(u64::from(h)).saturating_mul(33);
|
||||
(need > available).then(|| {
|
||||
let gb = |b: u64| b as f64 / 1e9;
|
||||
format!(
|
||||
"Content-Aware Fill of {frames} frames at {w}×{h} needs at least {:.1} GB of memory, and {:.1} GB is free: fill a shorter range (set the work area and choose Work Area) or close other applications",
|
||||
gb(need),
|
||||
gb(available)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Run a fill: the layer's frames (rendered in passes with a job worker's GPU), the fill, the
|
||||
/// PNG sequence written into `plan.dir` (`Services::store_file`). Returns the files.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -275,6 +290,9 @@ fn generate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let (fa, fb) = (fr.frame_at(fr.snap_nearest(a)), fr.frame_at(b - Tick(1)));
|
||||
let times: Vec<Tick> = (fa..=fb).map(|f| fr.tick_of(f)).collect();
|
||||
if let Some(e) = crate::sysinfo::memory().and_then(|m| memory_shortfall(times.len(), w, h, m.available)) {
|
||||
return Err(bad(c, e));
|
||||
}
|
||||
let opts = FillOpts {
|
||||
method: FillMethod::from_name(&st.method).unwrap_or_default(),
|
||||
lighting: lighting(&st.lighting_correction).unwrap_or(0.0),
|
||||
@@ -424,3 +442,18 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::memory_shortfall;
|
||||
|
||||
/// #1789: 10 s of 1080p30 (300 frames) needs at least 20 GB, which 4 GB free can't hold.
|
||||
#[test]
|
||||
fn a_fill_that_cannot_fit_in_memory_is_refused_up_front() {
|
||||
let e = memory_shortfall(300, 1920, 1080, 4_000_000_000).expect("refused");
|
||||
assert!(e.contains("300 frames") && e.contains("Work Area"), "{e}");
|
||||
assert_eq!(memory_shortfall(30, 1920, 1080, 4_000_000_000), None, "a second of it fits");
|
||||
// Hostile sizes saturate instead of overflowing.
|
||||
assert!(memory_shortfall(usize::MAX, u32::MAX, u32::MAX, u64::MAX - 1).is_some());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
//! Layer ▸ Create: Convert to Editable Text (Photoshop type layers), Create Shapes from Text,
|
||||
//! Create Masks from Text and Create Shapes from Vector Layer (SVG footage).
|
||||
//! Create Masks from Text, Create Shapes from Vector Layer (SVG footage) and Create 3D Layer
|
||||
//! Instance (a 2D copy of a 3D model layer that 2D effects can be applied to).
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_keyframe::Value as KV;
|
||||
use effectcraft_project::build::{self, Ids};
|
||||
use effectcraft_project::{ItemKind, LayerSource, Solid};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, bad, has_layers, layers_p, str_p};
|
||||
use crate::{EngineError, Result, Session, cmd};
|
||||
|
||||
const OPS: &str = "editableText|shapesFromText|masksFromText|shapesFromVector";
|
||||
const OPS: &str = "editableText|shapesFromText|masksFromText|shapesFromVector|instance3d";
|
||||
|
||||
fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let op = str_p(p, "op").ok_or_else(|| bad("layer.create", format!("missing `op` ({OPS})")))?;
|
||||
@@ -19,6 +22,69 @@ fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let time = s.time();
|
||||
let mut created = vec![];
|
||||
let mut skipped = vec![];
|
||||
// All the selected models in one edit: one undo (or redo) removes (or restores) every
|
||||
// instance at once, as one command in the History panel.
|
||||
if op == "instance3d" {
|
||||
// After Effects' Create 3D Layer Instance (Layer ▸ Create): 3D model layers take
|
||||
// no masks or effects, so a comp-sized solid goes at the top of the layer stack
|
||||
// with a 3D Layer Instance effect referencing the model — a 2D copy of its render,
|
||||
// through the comp's camera, that 2D effects can be applied to. The model is switched
|
||||
// off, as After Effects does (back on to keep its shadow).
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
|
||||
let mut eligible = vec![];
|
||||
for lid in &lids {
|
||||
match comp.layer(*lid) {
|
||||
Some(src) if src.source.is_model() => eligible.push((*lid, src.clone())),
|
||||
Some(src) => skipped.push(format!("{}: not a 3D model layer", src.name)),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
if !eligible.is_empty() {
|
||||
let ids = s.edit("Create 3D Layer Instance", None, |proj, st| {
|
||||
let mut ids = vec![];
|
||||
for (lid, src) in &eligible {
|
||||
let name = proj.comp(cid).ok_or(EngineError::NoComp)?.unique_layer_name(&format!("{} Instance", src.name));
|
||||
let folder = proj.folder_named("Solids").unwrap_or_else(|| proj.add_item("Solids", Label::Yellow, None, ItemKind::Folder));
|
||||
let sid = proj.add_item(
|
||||
&name,
|
||||
src.label,
|
||||
Some(folder),
|
||||
ItemKind::Solid(Solid { color: [1.0, 1.0, 1.0], width: comp.width, height: comp.height, pixel_aspect: 1.0 }),
|
||||
);
|
||||
let mut l = build::layer(proj, &comp, &name, LayerSource::Solid { item: sid }, (comp.width, comp.height), None);
|
||||
// A soloed source keeps its Solo switch with video off, and any_solo hides
|
||||
// the non-soloed copy: the copy takes the Solo over to stay visible.
|
||||
l.switches.solo = src.switches.solo;
|
||||
// A 3D Layer Instance effect referencing the 3D layer: the solid shows its
|
||||
// render (the 2D copy).
|
||||
let fx = effectcraft_effects::find("ec.3d.instance3d").ok_or_else(|| bad("layer.create", "the 3D Layer Instance effect is missing"))?;
|
||||
let mut g = effectcraft_effects::instantiate(fx, &mut Ids(&mut proj.next_id), fx.name, [comp.width as f64, comp.height as f64]);
|
||||
if let Some(pr) = g.get_mut("layer") {
|
||||
pr.value = KV::Layer(Some(lid.0));
|
||||
}
|
||||
if let Some(fxg) = l.props.sub_mut("effects") {
|
||||
fxg.children.push(g.into());
|
||||
}
|
||||
// The model is switched off (After Effects turns the referenced layer off);
|
||||
// the copies go at the top of the stack, selected.
|
||||
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
if let Some(m) = c.layers.iter_mut().find(|x| x.id == *lid) {
|
||||
m.switches.video = false;
|
||||
}
|
||||
let id = l.id;
|
||||
c.layers.insert(0, l);
|
||||
ids.push(id);
|
||||
}
|
||||
st.selected_layers = ids.clone();
|
||||
Ok(ids)
|
||||
})?;
|
||||
created.extend(ids.iter().map(|i| i.0));
|
||||
}
|
||||
if created.is_empty() && !skipped.is_empty() {
|
||||
return Err(bad("layer.create", skipped.join("; ")));
|
||||
}
|
||||
return Ok(json!({"layers": created, "skipped": skipped}));
|
||||
}
|
||||
for lid in lids {
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
|
||||
let Some(src) = comp.layer(lid).cloned() else { continue };
|
||||
@@ -198,5 +264,5 @@ fn insert_above(
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!("layer.create", "Create", [], None, "{op: editableText|shapesFromText|masksFromText|shapesFromVector, layers?}", has_layers, create)]
|
||||
vec![cmd!("layer.create", "Create", [], None, "{op: editableText|shapesFromText|masksFromText|shapesFromVector|instance3d, layers?}", has_layers, create)]
|
||||
}
|
||||
|
||||
@@ -74,9 +74,9 @@ fn select_all(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!(s.state.selected_layers.len()))
|
||||
}
|
||||
fn deselect_all(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
// A deselected layer never keeps an open text edit: commit the edit first, exactly as
|
||||
// `text.endEdit` (an empty Type-tool layer included), so every route — menu, shortcut,
|
||||
// script, MCP — leaves one final state.
|
||||
// A deselected layer never keeps an open text edit: commit it first, exactly as `text.endEdit`,
|
||||
// so every route — menu, shortcut, script, MCP — leaves one final state. An empty Type-tool
|
||||
// layer survives, as it does in After Effects.
|
||||
super::text_edit::end_edit(s, &Value::Null)?;
|
||||
s.state.selected_layers.clear();
|
||||
s.state.selected_props.clear();
|
||||
@@ -146,6 +146,11 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
st.selected_layers = created.clone();
|
||||
Ok(created)
|
||||
})?;
|
||||
// Duplicating selects the copies, so an open text edit on a now-deselected layer ends
|
||||
// (#2226): typing must not keep editing an unselected layer.
|
||||
if s.state.text_edit.as_ref().is_some_and(|e| !s.state.selected_layers.contains(&e.layer)) {
|
||||
super::text_edit::end_edit(s, &Value::Null)?;
|
||||
}
|
||||
Ok(json!(new.iter().map(|l| l.0).collect::<Vec<_>>()))
|
||||
}
|
||||
|
||||
@@ -263,6 +268,7 @@ fn clear_clipboards(s: &mut Session) {
|
||||
s.state.contents_clipboard.clear();
|
||||
s.state.link_clipboard = None;
|
||||
s.state.value_clipboard.clear();
|
||||
s.state.keyframe_text = None;
|
||||
s.state.clip_is_keys = false;
|
||||
}
|
||||
|
||||
@@ -329,10 +335,12 @@ fn copy_properties(s: &mut Session) -> Result<Option<Value>> {
|
||||
}
|
||||
let mut keys = s.state.selected_keys.clone();
|
||||
let mut values = vec![];
|
||||
let mut statics = vec![];
|
||||
for (lid, uid) in &leaves {
|
||||
let Some(l) = comp.layer(*lid) else { continue };
|
||||
let Some(pr) = l.props.find(*uid) else { continue };
|
||||
if pr.keys.is_empty() {
|
||||
statics.push((*lid, *uid));
|
||||
if let Some(path) = l.props.match_path_of(*uid)
|
||||
&& !values.iter().any(|(p, _)| *p == path)
|
||||
{
|
||||
@@ -342,6 +350,7 @@ fn copy_properties(s: &mut Session) -> Result<Option<Value>> {
|
||||
keys.extend(pr.keys.iter().map(|k| KeyRef { layer: *lid, prop: *uid, time: k.time }));
|
||||
}
|
||||
}
|
||||
let text = crate::keyframe_text::text(&s.project, comp, &keys, &statics);
|
||||
clear_clipboards(s);
|
||||
let mut n = 0;
|
||||
if !keys.is_empty() {
|
||||
@@ -350,6 +359,7 @@ fn copy_properties(s: &mut Session) -> Result<Option<Value>> {
|
||||
s.state.selected_keys = selected;
|
||||
n = r?.as_u64().unwrap_or(0);
|
||||
}
|
||||
s.state.keyframe_text = text;
|
||||
let v = values.len();
|
||||
s.state.value_clipboard = values;
|
||||
// Keys alone answer as `keys.copy` does (their count).
|
||||
|
||||
@@ -136,7 +136,16 @@ fn warning(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
|
||||
let g = layer.effects().and_then(|fx| fx.groups().find(|g| g.uid == uid)).ok_or_else(|| bad(cmd, "no such effect"))?;
|
||||
let t = super::time_p(s, p, Some(comp));
|
||||
let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None };
|
||||
let ctx = effectcraft_render::EvalCtx {
|
||||
project: &s.project,
|
||||
comp_id: cid,
|
||||
comp,
|
||||
time: t,
|
||||
expr: s.expr.as_deref(),
|
||||
footage: None,
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
};
|
||||
Ok(json!({"effect": uid, "warning": effectcraft_effects::warning(g, &mut |pr| ctx.value(layer, pr))}))
|
||||
}
|
||||
|
||||
@@ -160,7 +169,16 @@ fn pick_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.filter(|pr| pr.ui == effectcraft_project::ParamUi::Color)
|
||||
.ok_or_else(|| bad(cmd, "not a colour parameter of this effect"))?;
|
||||
let t = super::time_p(s, p, Some(comp));
|
||||
let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None };
|
||||
let ctx = effectcraft_render::EvalCtx {
|
||||
project: &s.project,
|
||||
comp_id: cid,
|
||||
comp,
|
||||
time: t,
|
||||
expr: s.expr.as_deref(),
|
||||
footage: None,
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
};
|
||||
let mut r = effectcraft_render::Renderer::new(&s.project, &*s.footage, effectcraft_render::RenderOpts::default());
|
||||
r.expr = s.expr.as_deref();
|
||||
r.cache = Some(&s.layer_cache);
|
||||
@@ -288,7 +306,16 @@ impl CryptoProbe {
|
||||
let comp = r.project.comp(cid).ok_or("no composition")?;
|
||||
let layer = comp.layer(lid).ok_or("no such layer")?;
|
||||
let g = layer.effects().and_then(|fx| fx.groups().nth(pos)).ok_or("no such effect")?;
|
||||
let ctx = effectcraft_render::EvalCtx { project: r.project, comp_id: cid, comp, time, expr: r.expr, footage: None };
|
||||
let ctx = effectcraft_render::EvalCtx {
|
||||
project: r.project,
|
||||
comp_id: cid,
|
||||
comp,
|
||||
time,
|
||||
expr: r.expr,
|
||||
footage: None,
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
};
|
||||
let params = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr));
|
||||
let want = effectcraft_effects::CRYPTO_LAYERS.get(params.e("layer") as usize).copied().unwrap_or("CryptoAsset");
|
||||
let aux = r.layer_aux(&ctx, layer).ok_or(NO_CRYPTO)?;
|
||||
@@ -351,7 +378,16 @@ fn probe_key<'a>(s: &'a Session, p: &Value, cmd: &str) -> Result<(ProbeKey, Tick
|
||||
let g = fx.groups().nth(pos).ok_or_else(|| bad(cmd, "no such effect"))?;
|
||||
let prop = g.prop("selection").filter(|_| g.match_id == "ec.3d.cryptomatte").ok_or_else(|| bad(cmd, "not a Cryptomatte effect"))?;
|
||||
let time = super::time_p(s, p, Some(comp));
|
||||
let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time, expr: s.expr.as_deref(), footage: None };
|
||||
let ctx = effectcraft_render::EvalCtx {
|
||||
project: &s.project,
|
||||
comp_id: cid,
|
||||
comp,
|
||||
time,
|
||||
expr: s.expr.as_deref(),
|
||||
footage: None,
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
};
|
||||
let frame = probe_frame(&ctx, layer, pos);
|
||||
let o = s.render_opts(Default::default());
|
||||
let prefs = (o.nested_switches, o.draft_shadows);
|
||||
@@ -503,7 +539,16 @@ fn crypto_hit(s: &Session, p: &Value, cmd: &str) -> Result<Hit> {
|
||||
let Some(hash) = b.aux.index_at(ax, ay).and_then(|i| b.aux.crypto_id_at(&b.layer, i)) else { return Ok(Hit::Nothing) };
|
||||
let (cid, lid, ..) = key.at;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time, expr: s.expr.as_deref(), footage: None };
|
||||
let ctx = effectcraft_render::EvalCtx {
|
||||
project: &s.project,
|
||||
comp_id: cid,
|
||||
comp,
|
||||
time,
|
||||
expr: s.expr.as_deref(),
|
||||
footage: None,
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
};
|
||||
let selection = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr)).s("selection").to_string();
|
||||
let driven = prop.expr.as_ref().is_some_and(|e| e.enabled);
|
||||
let (aux, layer_name) = (b.aux.clone(), b.layer.clone());
|
||||
@@ -700,7 +745,7 @@ fn effects_p(s: &Session, p: &Value, cmd: &str) -> Result<Vec<(LayerId, Uid)>> {
|
||||
Ok(sel)
|
||||
}
|
||||
|
||||
fn effect_time_error(cmd: &str) -> EngineError {
|
||||
pub(crate) fn effect_time_error(cmd: &str) -> EngineError {
|
||||
bad(cmd, "an effect keyframe converts outside the supported time range")
|
||||
}
|
||||
|
||||
@@ -736,7 +781,7 @@ fn effect_time(layer: &Layer, time: Tick, to_comp: bool, cmd: &str) -> Result<Ti
|
||||
|
||||
/// Change the key time coordinate system throughout an effect, including nested controls.
|
||||
/// Ease speeds are per second in that coordinate system; reversed time swaps the key sides.
|
||||
fn retime_effect(group: &mut PropGroup, scale: f64, cmd: &str, mut time: impl FnMut(Tick) -> Result<Tick>) -> Result<()> {
|
||||
pub(crate) fn retime_effect(group: &mut PropGroup, scale: f64, cmd: &str, mut time: impl FnMut(Tick) -> Result<Tick>) -> Result<()> {
|
||||
let mut result = Ok(());
|
||||
group.walk_mut(&mut |pr| {
|
||||
if result.is_err() {
|
||||
@@ -804,6 +849,7 @@ fn copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
s.state.clipboard.clear();
|
||||
s.state.key_clipboard.clear();
|
||||
s.state.keyframe_text = None;
|
||||
s.state.contents_clipboard.clear();
|
||||
s.state.link_clipboard = None;
|
||||
s.state.clip_is_keys = false;
|
||||
|
||||
@@ -163,6 +163,8 @@ pub fn language_menu() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)>
|
||||
("easeIn(t, tMin, tMax, value1, value2)", "easeIn(t, tMin, tMax, value1, value2)"),
|
||||
("easeOut(t, value1, value2)", "easeOut(t, value1, value2)"),
|
||||
("easeOut(t, tMin, tMax, value1, value2)", "easeOut(t, tMin, tMax, value1, value2)"),
|
||||
("bezier(t, value1, value2, [x1, y1, x2, y2])", "bezier(t, value1, value2, [x1, y1, x2, y2])"),
|
||||
("bezier(t, tMin, tMax, value1, value2, [x1, y1, x2, y2])", "bezier(t, tMin, tMax, value1, value2, [x1, y1, x2, y2])"),
|
||||
],
|
||||
),
|
||||
(
|
||||
|
||||
@@ -87,7 +87,8 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// project opened from an XML copy saves as XML again). Fails, writing nothing, when the project
|
||||
/// wouldn't read back.
|
||||
pub(crate) fn file_text(s: &Session, path: &str) -> Result<String> {
|
||||
let json = s.project.to_file_json_with_view(&s.saved_view())?;
|
||||
let compact = s.prefs.project.compact_project_files && !crate::xml_project::is_xml_path(path);
|
||||
let json = s.project.to_file_json_with_view(&s.saved_view(), compact)?;
|
||||
if crate::xml_project::is_xml_path(path) { crate::xml_project::to_xml(&json).map_err(EngineError::Other) } else { Ok(json) }
|
||||
}
|
||||
|
||||
@@ -745,7 +746,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"File...",
|
||||
["File", "Import"],
|
||||
Some("Cmd+I"),
|
||||
"{paths: [string] (files or folders), sequence?: bool (the Sequence checkbox: true imports numbered stills of one run as one image sequence, one picked file bringing its whole run and several picked files that range; default: a numbered still brings its run, except dropped files (drag), which import as stills as in After Effects; folders' runs import as sequences; false imports stills and a folder's files individually), alphabetical?: bool (Force Alphabetical Order: every image of that type in the folder, by name), frameRate?: fps (image sequences; default Settings ▸ Import ▸ Sequence Footage), importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), page?: number from 1 (PDF / Illustrator page), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As; files import as stills unless sequence), addToComp?: bool (also add them to the active comp, at time?, index?, position? as in layer.addItem), background?: bool (probe the files in a background job, jobs.list / jobs.wait; returns {job})}",
|
||||
"{paths: [string] (files or folders), sequence?: bool (the Sequence checkbox: true imports numbered stills of one run as one image sequence, one picked file bringing its whole run and several picked files that range; default false: picked or dropped files import as stills, as in After Effects; folders' runs import as sequences; false imports stills and a folder's files individually), alphabetical?: bool (Force Alphabetical Order: every image of that type in the folder, by name), frameRate?: fps (image sequences; default Settings ▸ Import ▸ Sequence Footage), importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), page?: number from 1 (PDF / Illustrator page), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As; files import as stills unless sequence), addToComp?: bool (also add them to the active comp, at time?, index?, position? as in layer.addItem), background?: bool (probe the files in a background job, jobs.list / jobs.wait; returns {job})}",
|
||||
always,
|
||||
import_cmd
|
||||
),
|
||||
|
||||
@@ -878,10 +878,34 @@ fn replace_footage(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = str_p(p, "path").ok_or_else(|| bad("file.replaceFootage", "missing `path`"))?.to_string();
|
||||
let item = *items_p(s, p).first().ok_or_else(|| bad("file.replaceFootage", "select footage"))?;
|
||||
let importer = s.importer.clone().ok_or_else(|| EngineError::Other("media import is not available in this build".into()))?;
|
||||
// A numbered still brings its image sequence, as on import (`sequence: false` for one frame).
|
||||
let opts = SequenceOptions::from_params(p);
|
||||
// Replacing a still keeps it a still; replacing a sequence keeps bringing the new file's run.
|
||||
// `sequence`/`alphabetical` override that (#2073).
|
||||
let previous = match s.project.item(item).map(|it| &it.kind) {
|
||||
Some(ItemKind::Footage(f)) if f.kind == FootageKind::Sequence => (true, true, f.alphabetical),
|
||||
_ => (false, false, false),
|
||||
};
|
||||
// Only `sequence` decides whether the item keeps its kind; `alphabetical` is a sequence
|
||||
// modifier, not a request to make one.
|
||||
let inherited = b_p(p, "sequence").is_none();
|
||||
let mut opts = SequenceOptions::from_params(p);
|
||||
if inherited {
|
||||
(opts.sequence, opts.folders) = (previous.0, previous.1);
|
||||
if previous.0 && b_p(p, "alphabetical").is_none() {
|
||||
opts.alphabetical = previous.2;
|
||||
}
|
||||
}
|
||||
let src = crate::sequence::source_of(s.services.as_ref(), &path, opts);
|
||||
let f = crate::sequence::probe(importer.as_ref(), &src, s.sequence_rate()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let mut f = crate::sequence::probe(importer.as_ref(), &src, s.sequence_rate()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
// A single-file source comes back as a still; an inherited sequence kind keeps it a
|
||||
// one-frame sequence, so a later default replacement still brings numbered runs (#2073).
|
||||
if inherited && previous.0 && matches!(src, crate::sequence::Source::File(_)) && f.kind == FootageKind::Still {
|
||||
f.kind = FootageKind::Sequence;
|
||||
f.sequence = vec![f.path.clone()];
|
||||
f.alphabetical = opts.alphabetical;
|
||||
f.frame_rate = s.sequence_rate();
|
||||
f.native_rate = None;
|
||||
f.sync_sequence_duration();
|
||||
}
|
||||
let name = src.name();
|
||||
// Replacing a missing file relinks the other missing files that moved with it (as After Effects
|
||||
// does when you locate one).
|
||||
@@ -1093,7 +1117,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"File...",
|
||||
["File", "Replace Footage"],
|
||||
Some("Cmd+H"),
|
||||
"{path, item?, sequence?: bool (default true: a numbered still brings its image sequence), alphabetical?: bool}",
|
||||
"{path, item?, sequence?: bool (a numbered still brings its image sequence; default: the item keeps its kind, so a still stays a still and a sequence stays a sequence), alphabetical?: bool}",
|
||||
has_footage_selection,
|
||||
replace_footage
|
||||
),
|
||||
|
||||
@@ -78,6 +78,12 @@ fn set_to_layer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "camera.setFocusToLayer";
|
||||
let (cid, cam, target) = refs(s, p, c, true)?;
|
||||
let d = focus_depth(s, cid, cam, target.ok_or_else(|| bad(c, "no layer"))?).ok_or_else(|| bad(c, "can't evaluate the layers"))?;
|
||||
// Only a finite depth can be stored: JSON has no infinity or NaN, so the project could no
|
||||
// longer be saved. Extreme but finite positions can still produce one, and `d <= 0.0` is
|
||||
// false for NaN.
|
||||
if !d.is_finite() {
|
||||
return Err(bad(c, "can't measure the distance to the layer (the camera or layer position is out of range)"));
|
||||
}
|
||||
if d <= 0.0 {
|
||||
return Err(bad(c, "the layer is behind the camera"));
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Text",
|
||||
["Layer", "New"],
|
||||
Some("Cmd+Alt+Shift+T"),
|
||||
"{text?, name? (layer name; default the text), position? [x,y], box? [x,y,w,h] (paragraph text, comp space), vertical?, edit? (start editing), font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?, baselineShift?, hScale?, vScale?, tsume?, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?, superscript?, subscript?, kerning?, ligatures?, justify?, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?, composer?, hangingPunctuation?, strokeOverFill?} (attributes as layer.setText)",
|
||||
"{text?, name? (layer name; default the text), position? [x,y], box? [x,y,w,h] (paragraph text, comp space), vertical?, edit? (start editing), font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?, baselineShift?, hScale?, vScale?, tsume?, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?, superscript?, subscript?, kerning?, ligatures?, justify?, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?, composer?, hangingPunctuation?, strokeOverFill?, lineJoin?} (attributes as layer.setText)",
|
||||
has_comp,
|
||||
new_text
|
||||
),
|
||||
@@ -121,7 +121,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Track Matte",
|
||||
[],
|
||||
None,
|
||||
"{layer?, matte: layer|null, kind?: alpha|alphaInverted|luma|lumaInverted}",
|
||||
"{layers? | layer?, matte: layer|null, kind?: alpha|alphaInverted|luma|lumaInverted|preserve}",
|
||||
has_layers,
|
||||
track_matte
|
||||
),
|
||||
@@ -192,7 +192,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Edit Text",
|
||||
[],
|
||||
None,
|
||||
"{layer?, range?: [start, end] (characters; default all), text?, font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?: px|\"auto\", baselineShift? px, hScale? %, vScale? %, tsume? %, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?: normal|superscript|subscript, superscript?, subscript?, kerning?: metrics|optical|number, ligatures?, discretionaryLigatures? (OpenType), contextualAlternates?, stylisticAlternates?, stylisticSets?: [1–20], ss01?, ss02?, ss03?, ss04?, ss05?, ss06?, ss07?, ss08?, ss09?, ss10?, ss11?, ss12?, ss13?, ss14?, ss15?, ss16?, ss17?, ss18?, ss19?, ss20?, swash?, titling?, ordinals?, fractions?, allSmallCaps?, figureStyle?: default|lining|oldStyle, figureWidth?: default|proportional|tabular, figures?, variations?: {tag: value} (variable font axes; null resets), justify?: left|center|right|justifyLeft|justifyCenter|justifyRight|justifyAll, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?: ltr|rtl, composer?: everyLine|singleLine, hangingPunctuation?, strokeOverFill?, box?: [x,y,w,h]|null (layer space), vertical?}",
|
||||
"{layer?, range?: [start, end] (characters; default all), text?, font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?: px|\"auto\", baselineShift? px, hScale? %, vScale? %, tsume? %, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?: normal|superscript|subscript, superscript?, subscript?, kerning?: metrics|optical|number, ligatures?, discretionaryLigatures? (OpenType), contextualAlternates?, stylisticAlternates?, stylisticSets?: [1–20], ss01?, ss02?, ss03?, ss04?, ss05?, ss06?, ss07?, ss08?, ss09?, ss10?, ss11?, ss12?, ss13?, ss14?, ss15?, ss16?, ss17?, ss18?, ss19?, ss20?, swash?, titling?, ordinals?, fractions?, allSmallCaps?, figureStyle?: default|lining|oldStyle, figureWidth?: default|proportional|tabular, figures?, variations?: {tag: value} (variable font axes; null resets), justify?: left|center|right|justifyLeft|justifyCenter|justifyRight|justifyAll, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?: ltr|rtl, composer?: everyLine|singleLine, hangingPunctuation?, strokeOverFill?, lineJoin?: miter|round|bevel, box?: [x,y,w,h]|null (layer space), vertical?}",
|
||||
has_layers,
|
||||
set_text
|
||||
),
|
||||
|
||||
@@ -161,7 +161,7 @@ pub(super) fn text_range_p(p: &Value) -> Option<std::ops::Range<usize>> {
|
||||
/// It is left justified unless `justify` is given. The layer is named by `name`, else the first 40
|
||||
/// characters of the first text line, else "Text". `edit: true` also opens a new editing
|
||||
/// session with anchor `0` and the caret after the text, selecting all initial text so the next
|
||||
/// insertion replaces it. The session has `created: true`, so leaving it empty removes the layer.
|
||||
/// insertion replaces it. The session has `created: true`; leaving it empty keeps the layer, as After Effects does.
|
||||
pub(super) fn new_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = comp_id(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{Comp, EngineError, ItemId, KV, Layer, LayerId, PropGroup, Result, Session, Tick, Value, b_p, bad, layers_p, resolve_layer};
|
||||
use super::{Comp, EngineError, ItemId, KV, Layer, LayerId, PropGroup, Result, Session, Tick, Value, b_p, bad, layers_p, resolve_layer, unlocked};
|
||||
|
||||
/// Re-express a layer in its new parent space without moving its anchor.
|
||||
pub(crate) struct ParentFix {
|
||||
@@ -9,10 +9,20 @@ pub(crate) struct ParentFix {
|
||||
rotation: f64,
|
||||
/// Multiply Scale.
|
||||
scale: [f64; 2],
|
||||
/// 3D AV layers retain their world-space anchor, including Z.
|
||||
/// 3D AV layers retain their world-space anchor, including Z. Cameras and lights also
|
||||
/// re-express their Point of Interest.
|
||||
position_3d: Option<effectcraft_geom::Mat4>,
|
||||
/// A uniform relative parent transform can preserve static Orientation and Scale.
|
||||
pose_3d: Option<([f64; 3], f64)>,
|
||||
/// A uniform relative parent transform re-expresses static Orientation and Scale.
|
||||
pose_3d: Option<Pose3d>,
|
||||
}
|
||||
|
||||
/// A uniform relative parent transform, as it applies to a 3D layer's Orientation and Scale.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Pose3d {
|
||||
/// Rotation that Orientation is re-expressed under: `Orientation' = correction * Orientation`.
|
||||
correction: effectcraft_geom::Mat4,
|
||||
/// Uniform scale factor, multiplied into Scale (negative for a mirrored parent).
|
||||
factor: f64,
|
||||
}
|
||||
|
||||
impl ParentFix {
|
||||
@@ -22,7 +32,7 @@ impl ParentFix {
|
||||
if let Some(m) = self.position_3d {
|
||||
// Spatial Position speed measures distance along its path. Its parent-space
|
||||
// unit changes even when the child's Orientation cannot be re-expressed.
|
||||
let position_speed = uniform_parent_pose(m, [0.0; 3]).map(|(_, factor)| factor.abs());
|
||||
let position_speed = uniform_rotation(m).map(|(_, factor)| factor.abs());
|
||||
let map_position = |v: &KV| {
|
||||
let q = m.apply(effectcraft_geom::Vec3::from(v.as_vec3()));
|
||||
KV::Vec3([q.x, q.y, q.z])
|
||||
@@ -44,7 +54,8 @@ impl ParentFix {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(pr) = tr.get_mut("position") {
|
||||
// Position and a camera's or light's Point of Interest are both points in the parent's space.
|
||||
let map_point = |pr: &mut effectcraft_project::Property| {
|
||||
pr.value = map_position(&pr.value);
|
||||
for key in &mut pr.keys {
|
||||
key.value = map_position(&key.value);
|
||||
@@ -64,34 +75,19 @@ impl ParentFix {
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
if let Some(pr) = tr.get_mut("position") {
|
||||
map_point(pr);
|
||||
}
|
||||
// Cameras and lights aim with Point of Interest: move it by the same parent-space
|
||||
// change, so a two-node rig keeps its world frame (#1010, #1020).
|
||||
if let Some(pr) = tr.get_mut("poi") {
|
||||
pr.value = map_position(&pr.value);
|
||||
for key in &mut pr.keys {
|
||||
key.value = map_position(&key.value);
|
||||
for tangent in [&mut key.spatial_in, &mut key.spatial_out] {
|
||||
let v = m.apply_vec(effectcraft_geom::Vec3::from(*tangent));
|
||||
*tangent = [v.x, v.y, v.z];
|
||||
}
|
||||
}
|
||||
if let Some(factor) = position_speed
|
||||
.filter(|_| pr.spatial)
|
||||
.filter(|factor| pr.keys.iter().flat_map(|key| key.in_ease.iter().chain(&key.out_ease)).all(|ease| (ease.speed * *factor).is_finite()))
|
||||
{
|
||||
for key in &mut pr.keys {
|
||||
for ease in key.in_ease.iter_mut().chain(&mut key.out_ease) {
|
||||
ease.speed *= factor;
|
||||
}
|
||||
}
|
||||
}
|
||||
map_point(pr);
|
||||
}
|
||||
if let Some((orientation, factor)) = self.pose_3d {
|
||||
if let Some(pose) = self.pose_3d {
|
||||
if let Some(pr) = tr.get_mut("orientation") {
|
||||
pr.value = KV::Vec3(orientation);
|
||||
reexpress_orientation(pose.correction, pr);
|
||||
}
|
||||
if let Some(pr) = tr.get_mut("scale") {
|
||||
let factor = pose.factor;
|
||||
let scaled = |value: &KV| KV::Vec3(value.as_vec3().map(|v| v * factor));
|
||||
pr.value = scaled(&pr.value);
|
||||
for key in &mut pr.keys {
|
||||
@@ -154,13 +150,10 @@ impl ParentFix {
|
||||
}
|
||||
}
|
||||
|
||||
/// A uniform factor commutes with authored axis Rotation and Scale; prepend its orthogonal
|
||||
/// rotation to static Orientation using the existing Rz * Ry * Rx order.
|
||||
fn uniform_parent_pose(m: effectcraft_geom::Mat4, orientation: [f64; 3]) -> Option<([f64; 3], f64)> {
|
||||
use effectcraft_geom::{Mat4, Vec3, vec3};
|
||||
if !orientation.iter().all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
/// The rotation and signed scale factor of a uniform parent transform's linear part (negative for a
|
||||
/// mirrored parent). `None` for a non-uniform or degenerate transform.
|
||||
fn uniform_rotation(m: effectcraft_geom::Mat4) -> Option<(effectcraft_geom::Mat4, f64)> {
|
||||
use effectcraft_geom::{Mat4, vec3};
|
||||
let a = m.0;
|
||||
let columns = [vec3(a[0][0], a[1][0], a[2][0]), vec3(a[0][1], a[1][1], a[2][1]), vec3(a[0][2], a[1][2], a[2][2])];
|
||||
let magnitude = columns[0].x.hypot(columns[0].y).hypot(columns[0].z);
|
||||
@@ -179,15 +172,188 @@ fn uniform_parent_pose(m: effectcraft_geom::Mat4, orientation: [f64; 3]) -> Opti
|
||||
rotation.0[row][col] = a[row][col] / factor;
|
||||
}
|
||||
}
|
||||
if (0..3).all(|row| (0..3).all(|col| (rotation.0[row][col] - if row == col { 1.0 } else { 0.0 }).abs() < 1e-12)) {
|
||||
return Some((orientation, factor));
|
||||
}
|
||||
let r = (rotation * Mat4::orientation(Vec3::from(orientation))).0;
|
||||
Some((rotation, factor))
|
||||
}
|
||||
|
||||
/// The turn (degrees) of a rotation about Z alone, or `None` for any other rotation.
|
||||
fn z_only_turn(correction: effectcraft_geom::Mat4) -> Option<f64> {
|
||||
let m = correction.0;
|
||||
let about_z = [m[0][2], m[1][2], m[2][0], m[2][1]].iter().all(|v| v.abs() < 1e-9) && (m[2][2] - 1.0).abs() < 1e-9;
|
||||
about_z.then(|| m[1][0].atan2(m[0][0]).to_degrees())
|
||||
}
|
||||
|
||||
/// ZYX Euler angles in degrees of a row-major rotation (`Rz * Ry * Rx`), safe at gimbal lock.
|
||||
fn euler_zyx(r: [[f64; 4]; 4]) -> [f64; 3] {
|
||||
// atan2 rather than asin: near ±90 degrees asin loses precision, while cos(y) stays exact.
|
||||
let cy = r[0][0].hypot(r[1][0]);
|
||||
let y = (-r[2][0]).atan2(cy);
|
||||
let (x, z) = if cy > 1e-9 { (r[2][1].atan2(r[2][2]), r[1][0].atan2(r[0][0])) } else { ((-r[1][2]).atan2(r[1][1]), 0.0) };
|
||||
Some(([x.to_degrees(), y.to_degrees(), z.to_degrees()], factor))
|
||||
[x.to_degrees(), y.to_degrees(), z.to_degrees()]
|
||||
}
|
||||
|
||||
/// The equivalent ZYX Euler triple nearest `reference`: each axis moved by whole turns, or the
|
||||
/// `(x + 180, 180 - y, z + 180)` form when that is nearer.
|
||||
fn nearest_euler(euler: [f64; 3], reference: [f64; 3]) -> [f64; 3] {
|
||||
let near = |v: [f64; 3]| -> [f64; 3] { std::array::from_fn(|i| v[i] + ((reference[i] - v[i]) / 360.0).round() * 360.0) };
|
||||
let natural = near(euler);
|
||||
let flipped = near([euler[0] + 180.0, 180.0 - euler[1], euler[2] + 180.0]);
|
||||
let distance = |v: [f64; 3]| v.iter().zip(reference.iter()).map(|(a, b)| (a - b).powi(2)).sum::<f64>();
|
||||
if distance(flipped) < distance(natural) { flipped } else { natural }
|
||||
}
|
||||
|
||||
/// The Euler Orientation of `correction * Orientation(o)`, in the form nearest `reference`.
|
||||
fn reexpress_euler(correction: effectcraft_geom::Mat4, o: [f64; 3], reference: [f64; 3]) -> [f64; 3] {
|
||||
let world = correction * effectcraft_geom::Mat4::orientation(effectcraft_geom::Vec3::from(o));
|
||||
nearest_euler(euler_zyx(world.0), reference)
|
||||
}
|
||||
|
||||
/// Largest angle (degrees) a baked Orientation may drift from the exact rotation between its keys.
|
||||
const BAKE_TOLERANCE_DEGREES: f64 = 0.05;
|
||||
/// How many times a segment may be halved while baking, and the most keys a property may end up with.
|
||||
const BAKE_MAX_DEPTH: u32 = 10;
|
||||
const BAKE_MAX_KEYS: usize = 4096;
|
||||
|
||||
/// The angle (degrees) between two rotations.
|
||||
fn rotation_angle(a: effectcraft_geom::Mat4, b: effectcraft_geom::Mat4) -> f64 {
|
||||
let trace: f64 = (0..3).flat_map(|i| (0..3).map(move |j| (i, j))).map(|(i, j)| a.0[i][j] * b.0[i][j]).sum();
|
||||
((trace - 1.0) / 2.0).clamp(-1.0, 1.0).acos().to_degrees()
|
||||
}
|
||||
|
||||
/// Adds keys between `(ta, ea)` and `(tb, eb)` (already converted) wherever the straight Euler blend
|
||||
/// between them drifts from `correction * Orientation(authored)` by more than the tolerance. `out`
|
||||
/// never grows past `budget` keys: what the whole property may still add, not what one segment may.
|
||||
fn bake_between(
|
||||
correction: effectcraft_geom::Mat4,
|
||||
authored: &effectcraft_project::Property,
|
||||
(ta, ea): (Tick, [f64; 3]),
|
||||
(tb, eb): (Tick, [f64; 3]),
|
||||
depth: u32,
|
||||
budget: usize,
|
||||
out: &mut Vec<effectcraft_keyframe::Keyframe>,
|
||||
) {
|
||||
use effectcraft_geom::{Mat4, Vec3};
|
||||
if depth == 0 || tb.0 - ta.0 < 2 || out.len() >= budget {
|
||||
return;
|
||||
}
|
||||
let tm = Tick(ta.0 + (tb.0 - ta.0) / 2);
|
||||
let blend = [(ea[0] + eb[0]) / 2.0, (ea[1] + eb[1]) / 2.0, (ea[2] + eb[2]) / 2.0];
|
||||
let exact = correction * Mat4::orientation(Vec3::from(authored.value_at(tm).as_vec3()));
|
||||
// Symmetric easing crosses the straight blend at its midpoint. Check both sides too before
|
||||
// accepting a segment whose original easing will be replaced with a linear blend.
|
||||
let quarter = (tb.0 - ta.0) / 4;
|
||||
if [Tick(ta.0 + quarter), tm, Tick(tb.0 - quarter)].into_iter().all(|t| {
|
||||
let u = (t.0 - ta.0) as f64 / (tb.0 - ta.0) as f64;
|
||||
let linear = std::array::from_fn(|i| ea[i] + (eb[i] - ea[i]) * u);
|
||||
let target = correction * Mat4::orientation(Vec3::from(authored.value_at(t).as_vec3()));
|
||||
rotation_angle(Mat4::orientation(Vec3::from(linear)), target) <= BAKE_TOLERANCE_DEGREES
|
||||
}) {
|
||||
return;
|
||||
}
|
||||
let em = nearest_euler(euler_zyx(exact.0), blend);
|
||||
bake_between(correction, authored, (ta, ea), (tm, em), depth - 1, budget, out);
|
||||
if out.len() >= budget {
|
||||
return;
|
||||
}
|
||||
out.push(effectcraft_keyframe::Keyframe::new(tm, KV::Vec3(em)));
|
||||
bake_between(correction, authored, (tm, em), (tb, eb), depth - 1, budget, out);
|
||||
}
|
||||
|
||||
/// Re-expresses Orientation as `correction * Orientation`. A Z-only correction shifts Z exactly in the
|
||||
/// value and every key. For any other correction a static value is converted directly; keys are
|
||||
/// converted, and keys are added between them wherever the Euler blend between two keys would drift
|
||||
/// from the exact rotation (the segments become linear, with the original easing baked in).
|
||||
fn reexpress_orientation(correction: effectcraft_geom::Mat4, pr: &mut effectcraft_project::Property) {
|
||||
if let Some(turn) = z_only_turn(correction) {
|
||||
// No turn (an identity correction) leaves every authored value as it is.
|
||||
if turn.abs() <= 1e-12 {
|
||||
return;
|
||||
}
|
||||
let shift = |value: &KV| {
|
||||
let [x, y, z] = value.as_vec3();
|
||||
KV::Vec3([x, y, z + turn])
|
||||
};
|
||||
pr.value = shift(&pr.value);
|
||||
for key in &mut pr.keys {
|
||||
key.value = shift(&key.value);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let value = pr.value.as_vec3();
|
||||
pr.value = KV::Vec3(reexpress_euler(correction, value, value));
|
||||
if pr.keys.is_empty() {
|
||||
return;
|
||||
}
|
||||
let authored = pr.clone();
|
||||
// Each key keeps the authored change from the previous key on top of its converted value.
|
||||
let mut converted: Vec<[f64; 3]> = vec![];
|
||||
let mut prev: Option<([f64; 3], [f64; 3])> = None;
|
||||
for key in &authored.keys {
|
||||
let o = key.value.as_vec3();
|
||||
let reference = match prev {
|
||||
Some((before, after)) => [after[0] + o[0] - before[0], after[1] + o[1] - before[1], after[2] + o[2] - before[2]],
|
||||
None => o,
|
||||
};
|
||||
let n = reexpress_euler(correction, o, reference);
|
||||
converted.push(n);
|
||||
prev = Some((o, n));
|
||||
}
|
||||
use effectcraft_keyframe::Interp;
|
||||
let mut keys: Vec<effectcraft_keyframe::Keyframe> = vec![];
|
||||
let mut after_baked_segment = false;
|
||||
for (i, key) in authored.keys.iter().enumerate() {
|
||||
let mut k = key.clone();
|
||||
k.value = KV::Vec3(converted[i]);
|
||||
if after_baked_segment {
|
||||
k.in_interp = Interp::Linear;
|
||||
k.in_ease.clear();
|
||||
k.auto_bezier = false;
|
||||
k.continuous = false;
|
||||
}
|
||||
after_baked_segment = false;
|
||||
let segment = authored.keys.get(i + 1).filter(|_| key.out_interp != Interp::Hold);
|
||||
let mut between = vec![];
|
||||
if let Some(next) = segment {
|
||||
// What the whole property may still add: the keys it will have already (the ones written
|
||||
// and the authored ones still to come) count against the cap.
|
||||
let budget = BAKE_MAX_KEYS.saturating_sub(keys.len() + authored.keys.len() - i);
|
||||
bake_between(correction, &authored, (key.time, converted[i]), (next.time, converted[i + 1]), BAKE_MAX_DEPTH, budget, &mut between);
|
||||
// With the cap reached the segment can't be checked or subdivided: it keeps its authored easing between
|
||||
// the converted keys, rather than becoming a straight blend that was never checked against the original.
|
||||
if between.len() >= budget {
|
||||
between.clear();
|
||||
// An eased segment, or one that gained keys, becomes a run of straight blends.
|
||||
} else if !between.is_empty() || key.out_interp != Interp::Linear || next.in_interp != Interp::Linear {
|
||||
k.out_interp = Interp::Linear;
|
||||
k.out_ease.clear();
|
||||
k.auto_bezier = false;
|
||||
k.continuous = false;
|
||||
after_baked_segment = true;
|
||||
}
|
||||
}
|
||||
keys.push(k);
|
||||
keys.extend(between);
|
||||
}
|
||||
pr.keys = keys;
|
||||
}
|
||||
|
||||
/// Correction for a two-node rig's Orientation: after a uniform parent change its aim is
|
||||
/// `rotation * aim`, so the roll is re-expressed against that aim. `None` for a degenerate aim.
|
||||
fn aim_correction(rotation: effectcraft_geom::Mat4, aim: [f64; 3]) -> Option<effectcraft_geom::Mat4> {
|
||||
use effectcraft_geom::vec3;
|
||||
use effectcraft_render::three_d::camera::basis_rotation;
|
||||
let [x, y, z] = aim;
|
||||
let length = (x * x + y * y + z * z).sqrt();
|
||||
if !length.is_finite() || length <= 1e-9 {
|
||||
return None;
|
||||
}
|
||||
let dir = vec3(x / length, y / length, z / length);
|
||||
let turned = rotation.apply_vec(dir);
|
||||
// A view along the up axis has no roll to keep, and its basis is undefined.
|
||||
if 1.0 - dir.y * dir.y < 1e-12 || 1.0 - turned.y * turned.y < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
let up = vec3(0.0, 1.0, 0.0);
|
||||
Some(basis_rotation(turned, up).transpose() * rotation * basis_rotation(dir, up))
|
||||
}
|
||||
|
||||
/// A parent change retains 2D transforms and the world-space anchor of 3D layers (Position,
|
||||
@@ -196,8 +362,16 @@ fn uniform_parent_pose(m: effectcraft_geom::Mat4, orientation: [f64; 3]) -> Opti
|
||||
/// time.
|
||||
pub(crate) fn parent_fixes(s: &Session, cid: ItemId, ids: &[LayerId], parent: Option<LayerId>) -> Vec<ParentFix> {
|
||||
let Some(comp) = s.project.comp(cid) else { return vec![] };
|
||||
let ectx =
|
||||
effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: s.time(), expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) };
|
||||
let ectx = effectcraft_render::EvalCtx {
|
||||
project: &s.project,
|
||||
comp_id: cid,
|
||||
comp,
|
||||
time: s.time(),
|
||||
expr: s.expr.as_deref(),
|
||||
footage: Some(s.footage.as_ref()),
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
};
|
||||
let space = |id: Option<LayerId>| -> effectcraft_geom::Mat3 {
|
||||
id.and_then(|i| comp.layer(i)).filter(|l| !l.is_3d()).map(|l| ectx.layer_to_comp(l).0).unwrap_or(effectcraft_geom::Mat3::IDENTITY)
|
||||
};
|
||||
@@ -235,31 +409,58 @@ pub(crate) fn parent_fixes(s: &Session, cid: ItemId, ids: &[LayerId], parent: Op
|
||||
})
|
||||
};
|
||||
if let Some(inv) = space_3d(parent).inverse().filter(|m| m.0.iter().flatten().all(|v| v.is_finite())) {
|
||||
// Cameras and lights compensate like 3D layers (Position, and Point of Interest for
|
||||
// two-node rigs), so parenting keeps the world frame (#1010, #1020). They have no
|
||||
// Scale, so `pose_3d` below stays `None` and Orientation is kept as authored.
|
||||
for layer in comp.layers.iter().filter(|l| ids.contains(&l.id) && l.is_3d() && l.parent != parent) {
|
||||
let m = inv * space_3d(layer.parent);
|
||||
// A one-node camera or light has no Scale to carry a mirror, and its frame (forward and
|
||||
// down) would come out reversed: compensate none of it, so Position and Orientation stay
|
||||
// as authored instead of half-following the parent.
|
||||
let rig = layer.is_camera() || layer.is_light();
|
||||
if rig && !effectcraft_render::three_d::camera::is_two_node(layer) && uniform_rotation(m).is_some_and(|(_, factor)| factor <= 0.0) {
|
||||
continue;
|
||||
}
|
||||
if m.0.iter().flatten().all(|v| v.is_finite()) {
|
||||
let pose_3d = layer.transform().and_then(|tr| {
|
||||
let orientation = tr.get("orientation")?;
|
||||
let scale = tr.get("scale")?;
|
||||
// Animated Euler Orientation and auto-orient cannot be re-expressed by
|
||||
// prepending a constant Euler value. Expression-driven values stay authored.
|
||||
if !orientation.keys.is_empty()
|
||||
|| orientation.expr.is_some()
|
||||
|| scale.expr.is_some()
|
||||
|| layer.auto_orient != effectcraft_project::AutoOrient::Off
|
||||
// Cameras and lights have no Scale; their Orientation is re-expressed alone.
|
||||
let scale = tr.get("scale");
|
||||
// Expression-driven values stay authored. Auto-orient replaces Orientation, except
|
||||
// a two-node rig, whose roll is re-expressed against its new aim.
|
||||
let two_node = (layer.is_camera() || layer.is_light()) && effectcraft_render::three_d::camera::is_two_node(layer);
|
||||
if orientation.expr.is_some()
|
||||
|| scale.is_some_and(|scale| scale.expr.is_some())
|
||||
|| (layer.auto_orient != effectcraft_project::AutoOrient::Off && !two_node)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let pose = uniform_parent_pose(m, orientation.value.as_vec3())?;
|
||||
let factor = pose.1;
|
||||
let values_finite = std::iter::once(&scale.value)
|
||||
.chain(scale.keys.iter().map(|key| &key.value))
|
||||
.all(|value| value.as_vec3().iter().all(|v| (v * factor).is_finite()));
|
||||
let speeds_finite = scale.keys.iter().flat_map(|key| key.in_ease.iter().chain(&key.out_ease)).all(|ease| (ease.speed * factor).is_finite());
|
||||
(values_finite && speeds_finite).then_some(pose)
|
||||
let (rotation, factor) = uniform_rotation(m)?;
|
||||
let correction = if two_node {
|
||||
// A mirrored parent flips the aim, which this correction does not model.
|
||||
if factor <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let (w, h) = (comp.width as f64, comp.height as f64);
|
||||
let pos = ectx.v3(layer, tr, "position", [w / 2.0, h / 2.0, -1000.0]);
|
||||
let poi = ectx.v3(layer, tr, "poi", [w / 2.0, h / 2.0, 0.0]);
|
||||
aim_correction(rotation, [poi[0] - pos[0], poi[1] - pos[1], poi[2] - pos[2]])?
|
||||
} else {
|
||||
rotation
|
||||
};
|
||||
let orientation_finite = std::iter::once(&orientation.value)
|
||||
.chain(orientation.keys.iter().map(|key| &key.value))
|
||||
.all(|value| value.as_vec3().iter().all(|v| v.is_finite()));
|
||||
if !correction.0.iter().flatten().all(|v| v.is_finite()) || !orientation_finite {
|
||||
return None;
|
||||
}
|
||||
if let Some(scale) = scale {
|
||||
let values_finite = std::iter::once(&scale.value)
|
||||
.chain(scale.keys.iter().map(|key| &key.value))
|
||||
.all(|value| value.as_vec3().iter().all(|v| (v * factor).is_finite()));
|
||||
let speeds_finite = scale.keys.iter().all(|key| key.in_ease.iter().chain(&key.out_ease).all(|ease| (ease.speed * factor).is_finite()));
|
||||
if !values_finite || !speeds_finite {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(Pose3d { correction, factor })
|
||||
});
|
||||
fixes.push(ParentFix { layer: layer.id, m: effectcraft_geom::Mat3::IDENTITY, rotation: 0.0, scale: [1.0; 2], position_3d: Some(m), pose_3d });
|
||||
}
|
||||
@@ -309,6 +510,8 @@ fn check_parent_chain(comp: &Comp, ids: &[LayerId], parent: Option<LayerId>, max
|
||||
|
||||
pub(super) fn set_parent(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
// Locked layers keep their parent (several at once: the others are parented).
|
||||
let ids = unlocked(s, cid, ids, "layer.setParent")?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let parent = match p.get("parent") {
|
||||
None | Some(Value::Null) => None,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::{
|
||||
BlendMode, EngineError, FrameBlend, ItemId, KV, Layer, LayerId, MatteKind, Quality, Result, Session, TrackMatte, Value, b_p, bad, json, layer_mut, layer_p,
|
||||
layers_p, merge_p, resolve_layer, str_p,
|
||||
layers_p, merge_p, resolve_layer, str_p, unlocked,
|
||||
};
|
||||
|
||||
pub(super) fn set_switch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -181,33 +181,122 @@ pub(super) fn blend(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// `layer.setTrackMatte`: `layers` (or `layer`, else the first selected layer; locked ones left
|
||||
/// as they are) use `matte` as their track matte (none when null), in one undo step. `kind` sets the mode; `preserve` keeps
|
||||
/// each layer's own (Alpha for a layer without a matte), so changing the matte of several layers
|
||||
/// at once keeps their modes.
|
||||
pub(super) fn track_matte(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, lid) = layer_p(s, p, "layer.setTrackMatte")?;
|
||||
let (cid, ids) = match p.get("layers") {
|
||||
Some(_) => layers_p(s, p)?,
|
||||
None => layer_p(s, p, "layer.setTrackMatte").map(|(c, l)| (c, vec![l]))?,
|
||||
};
|
||||
// Locked layers keep their matte (several at once: the others get it).
|
||||
let ids = unlocked(s, cid, ids, "layer.setTrackMatte")?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let matte = match p.get("matte") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(resolve_layer(comp, v).ok_or_else(|| bad("layer.setTrackMatte", "no such matte layer"))?),
|
||||
};
|
||||
let kind = str_p(p, "kind")
|
||||
.map(|k| match k.to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() {
|
||||
if ids.is_empty() {
|
||||
return Err(bad("layer.setTrackMatte", "no layers"));
|
||||
}
|
||||
if matte.is_some_and(|m| ids.contains(&m)) {
|
||||
return Err(bad("layer.setTrackMatte", "a layer can't be its own matte"));
|
||||
}
|
||||
// None: each layer keeps its own mode.
|
||||
let kind = match str_p(p, "kind").map(|k| k.to_ascii_lowercase().replace([' ', '_', '-'], "")) {
|
||||
Some(k) if k == "preserve" => None,
|
||||
Some(k) => Some(match k.as_str() {
|
||||
"alphainverted" | "alphainv" => MatteKind::AlphaInverted,
|
||||
"luma" => MatteKind::Luma,
|
||||
"lumainverted" | "lumainv" => MatteKind::LumaInverted,
|
||||
_ => MatteKind::Alpha,
|
||||
})
|
||||
.unwrap_or(MatteKind::Alpha);
|
||||
}),
|
||||
None => Some(MatteKind::Alpha),
|
||||
};
|
||||
s.edit("Track Matte", None, |proj, _| {
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
if let Some(m) = matte {
|
||||
if m == lid {
|
||||
return Err(bad("layer.setTrackMatte", "a layer can't be its own matte"));
|
||||
}
|
||||
if let Some(ml) = comp.layer_mut(m) {
|
||||
ml.switches.video = false;
|
||||
}
|
||||
if let Some(ml) = matte.and_then(|m| comp.layer_mut(m)) {
|
||||
ml.switches.video = false;
|
||||
}
|
||||
for lid in &ids {
|
||||
let l = comp.layer_mut(*lid).ok_or(EngineError::NoComp)?;
|
||||
let kind = kind.unwrap_or_else(|| l.track_matte.map(|m| m.kind).unwrap_or_default());
|
||||
l.track_matte = matte.map(|layer| TrackMatte { layer, kind });
|
||||
}
|
||||
comp.layer_mut(lid).ok_or(EngineError::NoComp)?.track_matte = matte.map(|layer| TrackMatte { layer, kind });
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod track_matte_batch_tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn matte_of(s: &Session, id: u64) -> Option<(u64, MatteKind)> {
|
||||
s.active_comp().unwrap().layer(LayerId(id)).unwrap().track_matte.map(|m| (m.layer.0, m.kind))
|
||||
}
|
||||
|
||||
/// #1260: several layers take one matte layer at once, in one undo step; `kind: "preserve"`
|
||||
/// keeps each one's mode; refused calls change nothing; one `layer` still works.
|
||||
#[test]
|
||||
fn several_layers_take_one_matte_and_keep_their_modes() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name":"Mattes","width":16,"height":16,"duration":1})).unwrap();
|
||||
let [a, b, c, x, y] = ["A", "B", "C", "X", "Y"].map(|n| s.execute("layer.newSolid", json!({"name": n})).unwrap()["layer"].as_u64().unwrap());
|
||||
s.execute("layer.setTrackMatte", json!({"layer": a, "matte": x, "kind": "luma"})).unwrap();
|
||||
s.execute("layer.setTrackMatte", json!({"layer": b, "matte": x})).unwrap();
|
||||
assert_eq!((matte_of(&s, a), matte_of(&s, b)), (Some((x, MatteKind::Luma)), Some((x, MatteKind::Alpha))), "one layer, as before");
|
||||
let (before, undo) = (s.project.clone(), s.history.undo.len());
|
||||
s.execute("layer.setTrackMatte", json!({"layers": [a, b, c], "matte": y, "kind": "preserve"})).unwrap();
|
||||
assert_eq!(
|
||||
[matte_of(&s, a), matte_of(&s, b), matte_of(&s, c)],
|
||||
[Some((y, MatteKind::Luma)), Some((y, MatteKind::Alpha)), Some((y, MatteKind::Alpha))],
|
||||
"each keeps its mode (Alpha without one)"
|
||||
);
|
||||
assert!(!s.active_comp().unwrap().layer(LayerId(y)).unwrap().switches.video, "the matte is hidden");
|
||||
assert_eq!(s.history.undo.len(), undo + 1, "one undo step");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(s.project.same_content(&before));
|
||||
// Refused: a layer as its own matte, an unknown layer, no layers. Nothing changes.
|
||||
for p in [
|
||||
json!({"layers": [a, y], "matte": y}),
|
||||
json!({"layers": [a, 9999], "matte": y}),
|
||||
json!({"layers": [], "matte": y}),
|
||||
json!({"layers": [a], "matte": 9999}),
|
||||
] {
|
||||
let (project, revision) = (s.project.clone(), s.revision);
|
||||
assert!(s.execute("layer.setTrackMatte", p.clone()).is_err(), "{p}");
|
||||
assert!(Arc::ptr_eq(&project, &s.project) && s.revision == revision, "{p} changed nothing");
|
||||
}
|
||||
s.execute("layer.setTrackMatte", json!({"layers": [a, b], "matte": null})).unwrap();
|
||||
assert_eq!((matte_of(&s, a), matte_of(&s, b)), (None, None), "both lose it");
|
||||
}
|
||||
|
||||
/// Locked layers keep their parent and matte when they are among several (the others change),
|
||||
/// and alone they are an error that changes nothing.
|
||||
#[test]
|
||||
fn locked_layers_keep_their_parent_and_matte() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name":"Locks","width":16,"height":16,"duration":1})).unwrap();
|
||||
let [a, b, p, m] = ["A", "B", "P", "M"].map(|n| s.execute("layer.newSolid", json!({"name": n})).unwrap()["layer"].as_u64().unwrap());
|
||||
s.execute("layer.setSwitch", json!({"layers": [b], "switch": "lock", "value": true})).unwrap();
|
||||
let layer = |s: &Session, id: u64| s.active_comp().unwrap().layer(LayerId(id)).unwrap().clone();
|
||||
s.execute("layer.setParent", json!({"layers": [a, b], "parent": p})).unwrap();
|
||||
s.execute("layer.setTrackMatte", json!({"layers": [a, b], "matte": m, "kind": "preserve"})).unwrap();
|
||||
s.execute("layer.trackMatte", json!({"layers": [a, b], "op": "luma"})).unwrap();
|
||||
assert_eq!((layer(&s, a).parent, matte_of(&s, a)), (Some(LayerId(p)), Some((m, MatteKind::Luma))));
|
||||
assert_eq!((layer(&s, b).parent, matte_of(&s, b)), (None, None), "the locked layer is left as it was");
|
||||
for (cmd, p) in [
|
||||
("layer.setParent", json!({"layers": [b], "parent": p})),
|
||||
("layer.setTrackMatte", json!({"layers": [b], "matte": m})),
|
||||
("layer.trackMatte", json!({"layers": [b], "op": "alpha"})),
|
||||
] {
|
||||
let (project, revision) = (s.project.clone(), s.revision);
|
||||
let err = s.execute(cmd, p).unwrap_err();
|
||||
assert!(err.to_string().contains("is locked"), "{cmd}: {err}");
|
||||
assert!(Arc::ptr_eq(&project, &s.project) && s.revision == revision, "{cmd} changed nothing");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,9 @@ pub(super) fn timing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
shift(l, d);
|
||||
}
|
||||
"trimInToTime" => l.in_point = t.min(l.out_point - fd),
|
||||
"trimOutToTime" => l.out_point = t.max(l.in_point + fd),
|
||||
// Alt+]: the Out edge lands one frame after the playhead, so its frame stays the
|
||||
// layer's last visible one (as in After Effects), never before the In point.
|
||||
"trimOutToTime" => l.out_point = t.max(l.in_point) + fd,
|
||||
_ => {
|
||||
if let Some(d) = delta {
|
||||
shift(l, d);
|
||||
|
||||
@@ -74,14 +74,39 @@ pub(super) fn transform_op(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let op = str_p(p, "op").unwrap_or("reset").to_string();
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
|
||||
let t = s.time();
|
||||
let sizes: Vec<(LayerId, (u32, u32))> =
|
||||
comp.layers.iter().filter(|l| ids.contains(&l.id)).map(|l| (l.id, effectcraft_render::source_size(&s.project, l))).collect();
|
||||
// A shape or text layer has no source item, so source_size is (0, 0): the fit ops size it by
|
||||
// its painted bounds at the current time instead, and anchor at the bounds' centre — content
|
||||
// bounds live in layer space, where a shape is centred on the origin and text sits above its
|
||||
// baseline, so (w/2, h/2) would push it off-centre (#2198). Reset and the other ops keep the
|
||||
// source size.
|
||||
let ectx = effectcraft_render::EvalCtx {
|
||||
expr: s.expr.as_deref(),
|
||||
footage: Some(s.footage.as_ref()),
|
||||
..effectcraft_render::EvalCtx::new(&s.project, cid, &comp, t)
|
||||
};
|
||||
let fitting = matches!(op.as_str(), "fit" | "fitWidth" | "fitHeight");
|
||||
let sizes: Vec<(LayerId, [f64; 2], [f64; 2])> = comp
|
||||
.layers
|
||||
.iter()
|
||||
.filter(|l| ids.contains(&l.id))
|
||||
.map(|l| {
|
||||
let (w, h) = effectcraft_render::source_size(&s.project, l);
|
||||
if fitting && (w == 0 || h == 0) {
|
||||
match effectcraft_render::content_bounds(&ectx, l).filter(|b| b.iter().all(|v| v.is_finite()) && b[2] > b[0] && b[3] > b[1]) {
|
||||
Some(b) => (l.id, [b[2] - b[0], b[3] - b[1]], [(b[0] + b[2]) / 2.0, (b[1] + b[3]) / 2.0]),
|
||||
None => (l.id, [0.0; 2], [0.0; 2]),
|
||||
}
|
||||
} else {
|
||||
(l.id, [w as f64, h as f64], [w as f64 / 2.0, h as f64 / 2.0])
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
s.edit("Transform", None, |proj, _| {
|
||||
for (lid, (w, h)) in &sizes {
|
||||
for (lid, size, anchor) in &sizes {
|
||||
let l = layer_mut(proj, cid, *lid)?;
|
||||
let lt = l.layer_time(t);
|
||||
let (cw, ch) = (comp.width as f64, comp.height as f64);
|
||||
let (w, h) = (*w as f64, *h as f64);
|
||||
let (w, h) = (size[0], size[1]);
|
||||
let Some(tr) = l.transform_mut() else { continue };
|
||||
let set = |tr: &mut PropGroup, m: &str, v: KV| {
|
||||
if let Some(pr) = tr.get_mut(m) {
|
||||
@@ -105,11 +130,15 @@ pub(super) fn transform_op(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"fitHeight" => (ch / h, ch / h),
|
||||
_ => (cw / w, ch / h),
|
||||
};
|
||||
// A tiny shape's bounds can overflow the scale; refuse rather than write infinity.
|
||||
if !(sx * 100.0).is_finite() || !(sy * 100.0).is_finite() {
|
||||
return Err(EngineError::Other(format!("layer {} is too small to fit to the composition", lid.0)));
|
||||
}
|
||||
let z = |m: &str, d: f64| tr.get(m).map(|p| p.value_at(lt).as_vec3()[2]).unwrap_or(d);
|
||||
let (sz, az) = (z("scale", 100.0), z("anchor", 0.0));
|
||||
set(tr, "scale", KV::Vec3([sx * 100.0, sy * 100.0, sz]));
|
||||
update_position(tr, lt, |p| [cw / 2.0, ch / 2.0, p[2]]);
|
||||
set(tr, "anchor", KV::Vec3([w / 2.0, h / 2.0, az]));
|
||||
set(tr, "anchor", KV::Vec3([anchor[0], anchor[1], az]));
|
||||
}
|
||||
"flipH" | "flipV" => {
|
||||
let cur = tr.get("scale").map(|p| p.value_at(lt).as_vec3()).unwrap_or([100.0; 3]);
|
||||
|
||||
@@ -609,6 +609,7 @@ fn delete_all_markers(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn track_matte_op(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
let ids = super::unlocked(s, cid, ids, "layer.trackMatte")?;
|
||||
let op = str_p(p, "op").ok_or_else(|| bad("layer.trackMatte", "missing `op`"))?.to_string();
|
||||
let kind = match op.as_str() {
|
||||
"alpha" => Some(MatteKind::Alpha),
|
||||
|
||||
@@ -185,7 +185,16 @@ fn freeze(s: &mut Session, p: &Value, last: bool) -> Result<Value> {
|
||||
return Err(bad("layer.freezeFrame", "freezing needs a footage or composition layer"));
|
||||
}
|
||||
// What each layer shows now (honouring an existing remap) — evaluated before the edit.
|
||||
let ectx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: cti, expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) };
|
||||
let ectx = effectcraft_render::EvalCtx {
|
||||
project: &s.project,
|
||||
comp_id: cid,
|
||||
comp,
|
||||
time: cti,
|
||||
expr: s.expr.as_deref(),
|
||||
footage: Some(s.footage.as_ref()),
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
};
|
||||
let now: Vec<(effectcraft_project::LayerId, f64)> = comp
|
||||
.layers
|
||||
.iter()
|
||||
|
||||
@@ -296,8 +296,9 @@ fn convert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// Layer ▸ Markers ▸ Update Markers From Source: copy a precomp's composition markers onto the
|
||||
/// layer (layer time is the nested comp's time), skipping ones already there.
|
||||
/// Layer ▸ Markers ▸ Update Markers From Source: copy a precomp's composition markers, or the
|
||||
/// markers a footage file carries (a WAV's cue points), onto the layer (layer time is the source's
|
||||
/// time), skipping ones already there.
|
||||
fn update_from_source(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
let src: Vec<(LayerId, Vec<Marker>)> = {
|
||||
@@ -307,13 +308,17 @@ fn update_from_source(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let l = comp.layer(*id)?;
|
||||
match &l.source {
|
||||
LayerSource::Comp { item } => Some((*id, s.project.comp(*item)?.markers.clone())),
|
||||
LayerSource::Footage { item } => match &s.project.item(*item)?.kind {
|
||||
effectcraft_project::ItemKind::Footage(f) => Some((*id, f.markers.clone())),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
if src.is_empty() {
|
||||
return Err(bad("layer.updateMarkersFromSource", "select a precomposition layer"));
|
||||
return Err(bad("layer.updateMarkersFromSource", "select a precomposition or footage layer"));
|
||||
}
|
||||
let n = s.edit("Update Markers From Source", None, |proj, _| {
|
||||
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
@@ -323,8 +328,11 @@ fn update_from_source(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if l.markers_locked {
|
||||
continue;
|
||||
}
|
||||
// One set of the times already on the layer: a file with thousands of markers must not
|
||||
// scan the layer's list for each of them.
|
||||
let mut times: std::collections::HashSet<_> = l.markers.iter().map(|x| x.time).collect();
|
||||
for m in ms {
|
||||
if !l.markers.iter().any(|x| x.time == m.time) {
|
||||
if times.insert(m.time) {
|
||||
l.markers.push(m.clone());
|
||||
n += 1;
|
||||
}
|
||||
@@ -339,10 +347,10 @@ fn update_from_source(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn has_precomp_layer(s: &Session) -> std::result::Result<(), String> {
|
||||
has_layers(s)?;
|
||||
let c = s.active_comp().ok_or("no composition")?;
|
||||
if s.state.selected_layers.iter().filter_map(|l| c.layer(*l)).any(|l| matches!(l.source, LayerSource::Comp { .. })) {
|
||||
if s.state.selected_layers.iter().filter_map(|l| c.layer(*l)).any(|l| matches!(l.source, LayerSource::Comp { .. } | LayerSource::Footage { .. })) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("select a precomposition layer".into())
|
||||
Err("select a precomposition or footage layer".into())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -289,6 +289,7 @@ fn extract_face(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
clip.push(crate::KeyClip { layer: lid, path, keys });
|
||||
}
|
||||
s.state.key_clipboard = clip;
|
||||
s.state.keyframe_text = None;
|
||||
s.state.clip_is_keys = true;
|
||||
// The same data as text (tab-separated, one row per frame) for other applications.
|
||||
let mut text = String::from("EffectCraft Face Measurements\n\nFrame\tTime");
|
||||
|
||||
@@ -78,7 +78,16 @@ fn info(s: &Session, cid: ItemId, lid: LayerId, uid: Uid, cmd: &str) -> Result<P
|
||||
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
|
||||
let pr = layer.props.find(uid).ok_or_else(|| bad(cmd, "no such property"))?;
|
||||
let t = s.time();
|
||||
let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) };
|
||||
let ctx = effectcraft_render::EvalCtx {
|
||||
project: &s.project,
|
||||
comp_id: cid,
|
||||
comp,
|
||||
time: t,
|
||||
expr: s.expr.as_deref(),
|
||||
footage: Some(s.footage.as_ref()),
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
};
|
||||
let KV::Path(path) = ctx.value(layer, pr) else { return Err(bad(cmd, "that property is not a path")) };
|
||||
if path.vertices.is_empty() {
|
||||
return Err(bad(cmd, "the path has no vertices"));
|
||||
@@ -147,8 +156,16 @@ fn pin_targets(s: &Session, p: &Value, cmd: &str) -> Result<Option<(ItemId, Vec<
|
||||
};
|
||||
let cid = pins.first().map(|x| x.0).ok_or_else(|| bad(cmd, "no puppet pins"))?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let ctx =
|
||||
effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: s.time(), expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) };
|
||||
let ctx = effectcraft_render::EvalCtx {
|
||||
project: &s.project,
|
||||
comp_id: cid,
|
||||
comp,
|
||||
time: s.time(),
|
||||
expr: s.expr.as_deref(),
|
||||
footage: Some(s.footage.as_ref()),
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
};
|
||||
let mut out = vec![];
|
||||
for (_, lid, uid) in pins {
|
||||
let Some(layer) = comp.layer(lid) else { continue };
|
||||
|
||||
@@ -8,7 +8,7 @@ use effectcraft_project::{FeatherFalloff, GroupKind, ItemId, LayerId, LayerSourc
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::layer_menu::target_masks;
|
||||
use super::{CommandSpec, b_p, bad, f_p, has_comp, has_layers, layer_mut, layer_p, str_p};
|
||||
use super::{CommandSpec, b_p, bad, f_p, has_comp, has_layers, layer_mut, layer_p, merge_p, str_p};
|
||||
use crate::{EngineError, Result, Session, VertexRef, cmd};
|
||||
|
||||
// ---------------------------------------------------------------- RotoBezier
|
||||
@@ -308,7 +308,7 @@ fn free_transform(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let y = v[1] * scale[1];
|
||||
[x * c - y * sn, x * sn + y * c]
|
||||
};
|
||||
let n = s.edit("Free Transform Points", None, |proj, _| {
|
||||
let n = s.edit("Free Transform Points", merge_p(p), |proj, _| {
|
||||
let mut n = 0;
|
||||
for (lid, uid) in &targets {
|
||||
let l = layer_mut(proj, cid, *lid)?;
|
||||
@@ -542,7 +542,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Free Transform Points",
|
||||
["Layer", "Mask and Shape Path"],
|
||||
None,
|
||||
"{layer?, mask?, scale?: % | [x%, y%], rotation?: deg, offset?: [dx, dy], anchor?: [x, y]}",
|
||||
"{layer?, mask?, scale?: % | [x%, y%], rotation?: deg, offset?: [dx, dy], anchor?: [x, y], merge?: string}",
|
||||
has_layers,
|
||||
free_transform
|
||||
),
|
||||
|
||||
@@ -1285,8 +1285,16 @@ fn convert_one_expr(s: &mut Session, cid: ItemId, lid: LayerId, uid: Uid) -> Res
|
||||
let fd = comp.frame_duration();
|
||||
let mut t = layer.in_point;
|
||||
while t < layer.out_point {
|
||||
let ctx =
|
||||
effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp: &comp, time: t, expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) };
|
||||
let ctx = effectcraft_render::EvalCtx {
|
||||
project: &s.project,
|
||||
comp_id: cid,
|
||||
comp: &comp,
|
||||
time: t,
|
||||
expr: s.expr.as_deref(),
|
||||
footage: Some(s.footage.as_ref()),
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
};
|
||||
keys.push(Keyframe::new(layer.layer_time(t), ctx.value(&layer, &pr)));
|
||||
t += fd;
|
||||
}
|
||||
|
||||
@@ -38,7 +38,8 @@ pub fn puppet_eval(s: &Session, cid: ItemId, lid: LayerId, fx_uid: Uid, t: Tick)
|
||||
let fx = layer.effects()?;
|
||||
let index = fx.groups().position(|g| g.uid == fx_uid)?;
|
||||
let g = fx.groups().nth(index)?;
|
||||
let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None };
|
||||
let ctx =
|
||||
EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None, property_overrides: &[], media_overrides: &[] };
|
||||
let mut r = Renderer::new(&s.project, &*s.footage, RenderOpts::default());
|
||||
r.expr = s.expr.as_deref();
|
||||
r.cache = Some(&s.layer_cache);
|
||||
|
||||
@@ -619,7 +619,7 @@ fn set_output_module(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (w, h) = item.settings.output_size(comp);
|
||||
let mut crop_only = om.clone();
|
||||
crop_only.resize.enabled = false;
|
||||
let (cw, ch) = crop_only.frame_size(w, h, item.settings.resolution.clamp(0.01, 4.0));
|
||||
let (cw, ch) = crop_only.frame_size(w, h, item.settings.effective_scale(comp));
|
||||
om.resize.width = ((om.resize.height as f64 * cw as f64 / ch as f64).round() as u32).clamp(1, 30_000);
|
||||
}
|
||||
let post = match str_p(p, "postRenderAction") {
|
||||
|
||||
@@ -48,7 +48,16 @@ impl Env {
|
||||
Env { time: s.time_of(cid), expr: s.expr.clone(), footage: s.footage.clone() }
|
||||
}
|
||||
fn ctx<'a>(&'a self, proj: &'a Project, cid: ItemId, comp: &'a Comp) -> EvalCtx<'a> {
|
||||
EvalCtx { project: proj, comp_id: cid, comp, time: self.time, expr: self.expr.as_deref(), footage: Some(self.footage.as_ref()) }
|
||||
EvalCtx {
|
||||
project: proj,
|
||||
comp_id: cid,
|
||||
comp,
|
||||
time: self.time,
|
||||
expr: self.expr.as_deref(),
|
||||
footage: Some(self.footage.as_ref()),
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
//! settings on the desktop, so dropping `.jsx` files in those folders works too. File ▸ Scripts
|
||||
//! lists them with the bundled sample scripts and extensions ([`BUNDLED`]) and runs them by name; ScriptUI
|
||||
//! panels appear at the bottom of the Window menu and open as dockable panels
|
||||
//! (`window.scriptPanel`), running with `this` = the panel as in After Effects.
|
||||
//! (`window.scriptPanel`), running with `this` = the panel as in After Effects. A subfolder of
|
||||
//! either folder (one level) is a submenu named after it; its scripts are named `Folder/File.jsx`.
|
||||
//! * **ScriptUI windows** (`scriptui.*`): list the open script windows, read their control
|
||||
//! trees, click buttons and set values like a user, close them (see [`crate::scriptui`]).
|
||||
//! * **Script settings** (`script.settings.*`): After Effects' `app.settings`, string values by
|
||||
@@ -22,6 +23,11 @@ use crate::{EngineError, Event, Result, Session, cmd, scriptui};
|
||||
/// The settings-store folders of installed scripts.
|
||||
pub const SCRIPTS_DIR: &str = "Scripts";
|
||||
pub const PANELS_DIR: &str = "Scripts/ScriptUI Panels";
|
||||
/// [`PANELS_DIR`]'s name in [`SCRIPTS_DIR`]: it holds the panels, so it is not a folder of scripts.
|
||||
const PANELS_FOLDER: &str = "ScriptUI Panels";
|
||||
/// Subfolders of Scripts that hold scripts the app runs itself (panels; launch and quit, as in
|
||||
/// After Effects): never listed as File ▸ Scripts submenus.
|
||||
const RESERVED_FOLDERS: [&str; 3] = [PANELS_FOLDER, "Startup", "Shutdown"];
|
||||
/// The ScriptUI panels open when EffectCraft last quit (settings store), reopened at launch.
|
||||
pub const OPEN_PANELS_FILE: &str = "scriptui_panels.json";
|
||||
/// Scripts' `app.settings` in the settings store: `{section: {key: value}}`, as After Effects
|
||||
@@ -111,7 +117,7 @@ pub const BUNDLED: &[(&str, bool, &str, &str)] = &[
|
||||
/// A script File ▸ Scripts (or the Window menu, for panels) offers.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
pub struct ScriptEntry {
|
||||
/// File name (`Rename Layers.jsx`): what the menus show and commands take.
|
||||
/// File name (`Rename Layers.jsx`), or `Folder/File.jsx` in a subfolder: what commands take.
|
||||
pub name: String,
|
||||
/// In the ScriptUI Panels folder: opens as a dockable panel.
|
||||
pub panel: bool,
|
||||
@@ -119,16 +125,23 @@ pub struct ScriptEntry {
|
||||
pub source: &'static str,
|
||||
}
|
||||
|
||||
/// Whether `name` ends in `.jsx` or `.js` (any letter case).
|
||||
fn is_script(name: &str) -> bool {
|
||||
let l = name.to_ascii_lowercase();
|
||||
l.ends_with(".jsx") || l.ends_with(".js")
|
||||
}
|
||||
|
||||
/// Whether `name` can name an installed script: a bare file name, never a path (names are joined
|
||||
/// onto the Scripts folders of the settings store, which takes them as relative paths), and on
|
||||
/// Windows none of the names [`windows_unsafe_name`] lists.
|
||||
/// Whether `name` can name an installed script: a file name, or `Folder/File` for one in a
|
||||
/// subfolder of a Scripts folder (one level, and not one of [`RESERVED_FOLDERS`]: `ScriptUI
|
||||
/// Panels/…`, `Startup/…`, `Shutdown/…`). Each part is a bare name, never a path (names are joined onto the Scripts folders of
|
||||
/// the settings store, which takes them as relative paths), and on Windows none of the names
|
||||
/// [`windows_unsafe_name`] lists.
|
||||
fn is_script_name(name: &str) -> bool {
|
||||
is_bare_name(name) && !(cfg!(windows) && windows_unsafe_name(name))
|
||||
let part = |n: &str| is_bare_name(n) && !(cfg!(windows) && windows_unsafe_name(n));
|
||||
match name.split_once('/') {
|
||||
Some((folder, file)) => part(folder) && part(file) && !RESERVED_FOLDERS.iter().any(|r| folder.eq_ignore_ascii_case(r)),
|
||||
None => part(name),
|
||||
}
|
||||
}
|
||||
|
||||
/// One path component on every platform: not empty, `.` or `..`, and without `/`, `\` or NUL.
|
||||
@@ -155,8 +168,13 @@ pub fn scripts(s: &Session) -> Vec<ScriptEntry> {
|
||||
let mut v: Vec<ScriptEntry> = vec![];
|
||||
if let Some(cfg) = &s.config {
|
||||
for (dir, panel) in [(SCRIPTS_DIR, false), (PANELS_DIR, true)] {
|
||||
for n in cfg.list(dir).into_iter().filter(|n| is_script(n) && is_script_name(n)) {
|
||||
v.push(ScriptEntry { name: n, panel, source: "installed" });
|
||||
// The folder itself, then each subfolder, whose scripts are named `Folder/File.jsx`.
|
||||
let subfolders = cfg.list_dirs(dir).into_iter().map(|f| (format!("{dir}/{f}"), format!("{f}/")));
|
||||
for (folder, prefix) in std::iter::once((dir.to_string(), String::new())).chain(subfolders) {
|
||||
let names = cfg.list(&folder).into_iter().map(|n| format!("{prefix}{n}"));
|
||||
for n in names.filter(|n| is_script(n) && is_script_name(n)) {
|
||||
v.push(ScriptEntry { name: n, panel, source: "installed" });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -216,12 +234,19 @@ fn list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!(scripts(s)))
|
||||
}
|
||||
|
||||
/// `file.uninstallScript {name}`: delete an installed script or panel at the top of the Scripts
|
||||
/// folders.
|
||||
fn uninstall(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "file.uninstallScript";
|
||||
let name = str_p(p, "name").ok_or_else(|| bad(c, "missing `name`"))?;
|
||||
if !is_script(name) || !is_script_name(name) {
|
||||
return Err(bad(c, format!("`{name}` is not a script file name")));
|
||||
}
|
||||
// A subfolder can be a link to files outside the settings folder (an extension's own
|
||||
// folder), so only what Install puts at the top is removed, never a file through a folder.
|
||||
if name.contains('/') {
|
||||
return Err(bad(c, format!("`{name}` is in a subfolder: delete it there yourself (only scripts at the top of the Scripts folders are uninstalled)")));
|
||||
}
|
||||
let cfg = s.config.clone().ok_or_else(|| bad(c, "no settings folder"))?;
|
||||
let mut removed = false;
|
||||
for dir in [SCRIPTS_DIR, PANELS_DIR] {
|
||||
@@ -316,13 +341,15 @@ fn restore_panels(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
fn open_panel(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "window.scriptPanel";
|
||||
let name = str_p(p, "name").ok_or_else(|| bad(c, "missing `name` (a script in the ScriptUI Panels folder)"))?.to_string();
|
||||
let title = name.trim_end_matches(".jsx").trim_end_matches(".js").to_string();
|
||||
let file = name.rsplit_once('/').map_or(name.as_str(), |(_, f)| f);
|
||||
let title = file.trim_end_matches(".jsx").trim_end_matches(".js").to_string();
|
||||
if let Some(w) = s.script_ui.windows.iter().find(|w| w.kind == scriptui::WindowKind::Panel && w.script == name) {
|
||||
let id = w.id;
|
||||
s.events.push(Event::Frontend { command: c.into(), params: json!({"window": id}) });
|
||||
return Ok(json!({"window": id, "open": true}));
|
||||
}
|
||||
let (_, code) = script_code(s, &name, Some(true)).or_else(|| script_code(s, &name, None)).ok_or_else(|| bad(c, format!("no ScriptUI panel `{name}`")))?;
|
||||
// Only panels: a script opened as one would be remembered, then forgotten at the next launch.
|
||||
let (_, code) = script_code(s, &name, Some(true)).ok_or_else(|| bad(c, format!("no ScriptUI panel `{name}`")))?;
|
||||
let out = super::file_more::run_js_named(s, &panel_wrapper(&title, &code), &name)?;
|
||||
let id = s.script_ui.windows.iter().rev().find(|w| w.kind == scriptui::WindowKind::Panel && w.script == name).map(|w| w.id);
|
||||
if let Some(id) = id {
|
||||
@@ -338,6 +365,7 @@ fn open_panel(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"window": id, "result": out}))
|
||||
}
|
||||
|
||||
/// The commands of File ▸ Scripts, ScriptUI panels and windows, and script settings.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
@@ -358,14 +386,22 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
always,
|
||||
|s, p| install(s, p, true)
|
||||
),
|
||||
cmd!("file.uninstallScript", "Uninstall Script", [], None, "{name}", always, uninstall),
|
||||
crate::query!("file.scripts.list", "List Scripts", "{} → [{name, panel, source: installed|sample|extension}]", list),
|
||||
cmd!(
|
||||
"file.uninstallScript",
|
||||
"Uninstall Script",
|
||||
[],
|
||||
None,
|
||||
"{name (a script at the top of a Scripts folder, not `Folder/File.jsx`)}",
|
||||
always,
|
||||
uninstall
|
||||
),
|
||||
crate::query!("file.scripts.list", "List Scripts", "{} → [{name (`Folder/File.jsx` in a subfolder), panel, source: installed|sample|extension}]", list),
|
||||
cmd!(
|
||||
"window.scriptPanel",
|
||||
"ScriptUI Panel",
|
||||
[],
|
||||
None,
|
||||
"{name (a script in the ScriptUI Panels folder, e.g. `Layer Tools.jsx`)} → opens it as a dockable panel",
|
||||
"{name (a script in the ScriptUI Panels folder, e.g. `Layer Tools.jsx`, or `Folder/Panel.jsx` in a subfolder)} → opens it as a dockable panel",
|
||||
always,
|
||||
open_panel
|
||||
),
|
||||
@@ -504,13 +540,15 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Bundled and ordinary script file names are accepted; paths are not.
|
||||
#[test]
|
||||
fn script_names_are_bare_file_names() {
|
||||
let more = ["..jsx", "a..b.js", "Äpfel ✓.jsx", "console.jsx", "Concert.jsx", "NULL.jsx", "COM10.jsx", "LPT.jsx"];
|
||||
for name in BUNDLED.iter().map(|b| b.0).chain(more) {
|
||||
assert!(is_script(name) && is_script_name(name), "{name}");
|
||||
}
|
||||
for name in PATHS {
|
||||
// `a/b.jsx` is a script in a folder (see below).
|
||||
for name in PATHS.into_iter().filter(|n| *n != "a/b.jsx") {
|
||||
assert!(!is_script_name(name), "{name:?}");
|
||||
}
|
||||
for name in WINDOWS_ONLY {
|
||||
@@ -528,6 +566,141 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// `Folder/File.jsx` is accepted; deeper, upward, absolute and `\` names are not.
|
||||
#[test]
|
||||
fn script_names_may_have_one_folder() {
|
||||
for name in ["Motion Kit/Animate.jsx", "a/b.jsx", "Äpfel/✓.js", "..jsx/x.jsx"] {
|
||||
assert!(is_script_name(name), "{name:?}");
|
||||
}
|
||||
// Deeper, up, absolute, an empty part, a `\` path, and the panels' own folder in `Scripts`.
|
||||
let refused = ["a/b/c.jsx", "../x.jsx", "a/../x.jsx", "./x.jsx", "a/..", "/x.jsx", "a//x.jsx", "a/", "a\\b.jsx", "a/b\\c.jsx", "a\0/x.jsx"];
|
||||
for name in refused.into_iter().chain(["ScriptUI Panels/x.jsx", "scriptui panels/x.jsx"]) {
|
||||
assert!(!is_script_name(name), "{name:?}");
|
||||
}
|
||||
for name in ["In:Out/x.jsx", "con/x.jsx", "x/COM1.jsx", "x./y.jsx"] {
|
||||
assert_eq!(is_script_name(name), !cfg!(windows), "{name:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes `code` to `rel` in the settings folder of [`session_in_tmp`]'s `root`.
|
||||
fn put(root: &std::path::Path, rel: &str, code: &str) {
|
||||
let p = root.join("settings").join(rel);
|
||||
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
|
||||
std::fs::write(p, code).unwrap();
|
||||
}
|
||||
|
||||
/// Every script command takes `Folder/File.jsx` names and refuses names outside one folder level.
|
||||
#[test]
|
||||
fn scripts_in_a_subfolder_are_listed_run_and_opened_as_folder_and_file() {
|
||||
let (mut s, root) = session_in_tmp("folders");
|
||||
put(&root, "Scripts/Tools/Hello.jsx", "hello");
|
||||
put(&root, "Scripts/ScriptUI Panels/Motion Kit/Animate.jsx", "animate");
|
||||
put(&root, "Scripts/ScriptUI Panels/Top.jsx", "top");
|
||||
// Two folders deep: neither listed nor run.
|
||||
put(&root, "Scripts/a/b/c.jsx", "too deep");
|
||||
put(&root, "Scripts/ScriptUI Panels/Motion Kit/sub/Deep.jsx", "too deep");
|
||||
let list = s.execute("file.scripts.list", json!({})).unwrap();
|
||||
let installed: Vec<(&str, bool)> =
|
||||
list.as_array().unwrap().iter().filter(|e| e["source"] == "installed").map(|e| (e["name"].as_str().unwrap(), e["panel"] == true)).collect();
|
||||
assert_eq!(installed, [("Tools/Hello.jsx", false), ("Motion Kit/Animate.jsx", true), ("Top.jsx", true)]);
|
||||
assert_eq!(s.execute("file.runScript", json!({"name": "Tools/Hello.jsx"})).unwrap()["ran"], "hello");
|
||||
// A panel in a folder is titled after its file.
|
||||
let r = s.execute("window.scriptPanel", json!({"name": "Motion Kit/Animate.jsx"})).unwrap();
|
||||
let ran = r["result"]["ran"].as_str().unwrap_or_default();
|
||||
assert!(ran.contains("animate") && ran.contains(r#"__uiDockPanel("Animate")"#), "{r}");
|
||||
assert_eq!(script_code(&s, "Motion Kit/Animate.jsx", Some(true)), Some((true, "animate".to_string())));
|
||||
// A script that is not a panel doesn't open as one.
|
||||
for name in ["Tools/Hello.jsx", "Rename Layers.jsx"] {
|
||||
assert!(bad_params(&s.execute("window.scriptPanel", json!({"name": name}))), "{name}");
|
||||
}
|
||||
for name in [
|
||||
"../x.jsx",
|
||||
"a/b/c.jsx",
|
||||
"a\\b.jsx",
|
||||
"Motion Kit/sub/Deep.jsx",
|
||||
"ScriptUI Panels/Top.jsx",
|
||||
"Startup/Run.jsx",
|
||||
"shutdown/Run.jsx",
|
||||
"Tools/../../../outside.jsx",
|
||||
] {
|
||||
assert_eq!(script_code(&s, name, None), None, "{name:?}");
|
||||
for c in ["file.runScript", "window.scriptPanel", "file.uninstallScript"] {
|
||||
let r = s.execute(c, json!({"name": name}));
|
||||
assert!(bad_params(&r), "{c} `{name}`: {r:?}");
|
||||
}
|
||||
}
|
||||
assert!(["Scripts/a/b/c.jsx", "Scripts/ScriptUI Panels/Top.jsx"].iter().all(|p| root.join("settings").join(p).is_file()));
|
||||
assert!(root.join("outside.jsx").is_file());
|
||||
// Uninstall removes only scripts at the top; one in a folder is deleted there by hand.
|
||||
for name in ["Tools/Hello.jsx", "Motion Kit/Animate.jsx"] {
|
||||
assert!(bad_params(&s.execute("file.uninstallScript", json!({"name": name}))), "{name}");
|
||||
}
|
||||
assert!(["Scripts/Tools/Hello.jsx", "Scripts/ScriptUI Panels/Motion Kit/Animate.jsx"].iter().all(|p| root.join("settings").join(p).is_file()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// A subfolder can be a link to an extension's own folder, outside the settings folder: its
|
||||
/// scripts are listed and run from there, but uninstall never deletes through it.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn uninstall_never_deletes_through_a_linked_subfolder() {
|
||||
let (mut s, root) = session_in_tmp("linked");
|
||||
std::fs::create_dir_all(root.join("extension")).unwrap();
|
||||
std::fs::write(root.join("extension").join("Animate.jsx"), "animate").unwrap();
|
||||
std::os::unix::fs::symlink(root.join("extension"), root.join("settings").join(PANELS_DIR).join("Motion Kit")).unwrap();
|
||||
let list = s.execute("file.scripts.list", json!({})).unwrap();
|
||||
assert!(list.as_array().unwrap().iter().any(|e| e["name"] == "Motion Kit/Animate.jsx" && e["panel"] == true), "{list}");
|
||||
assert_eq!(script_code(&s, "Motion Kit/Animate.jsx", Some(true)), Some((true, "animate".to_string())));
|
||||
let r = s.execute("file.uninstallScript", json!({"name": "Motion Kit/Animate.jsx"}));
|
||||
assert!(bad_params(&r), "{r:?}");
|
||||
assert!(root.join("extension").join("Animate.jsx").is_file(), "the extension's own file was deleted");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// Window ▸ Motion Kit ▸ Animate.jsx: a subfolder is a submenu named after it, where the folder
|
||||
/// sorts; the entries outside folders stay as they were.
|
||||
#[test]
|
||||
fn a_subfolder_is_a_submenu() {
|
||||
use crate::menus::{MenuNode, dynamic, dynamic_nodes};
|
||||
let (s, root) = session_in_tmp("submenus");
|
||||
put(&root, "Scripts/ScriptUI Panels/Motion Kit/Animate.jsx", "");
|
||||
put(&root, "Scripts/ScriptUI Panels/Motion Kit/Rig.jsx", "");
|
||||
put(&root, "Scripts/ScriptUI Panels/Top.jsx", "");
|
||||
put(&root, "Scripts/Tools/Hello.jsx", "");
|
||||
let cx = Default::default();
|
||||
let item =
|
||||
|label: &str, command: &str, name: &str| json!({"type": "item", "label": label, "command": command, "params": {"name": name}, "shortcut": null});
|
||||
let menus = [
|
||||
(
|
||||
"scriptPanels",
|
||||
json!({"type": "submenu", "label": "Motion Kit", "children": [
|
||||
item("Animate.jsx", "window.scriptPanel", "Motion Kit/Animate.jsx"),
|
||||
item("Rig.jsx", "window.scriptPanel", "Motion Kit/Rig.jsx"),
|
||||
]}),
|
||||
),
|
||||
("scripts", json!({"type": "submenu", "label": "Tools", "children": [item("Hello.jsx", "file.runScript", "Tools/Hello.jsx")]})),
|
||||
];
|
||||
for (menu, folder) in menus {
|
||||
let (nodes, _) = dynamic_nodes(&s, menu, &cx);
|
||||
let (entries, _) = dynamic(&s, menu, &cx);
|
||||
let items: Vec<_> = nodes.iter().filter_map(|n| if let MenuNode::Item(e) = n { Some(e) } else { None }).collect();
|
||||
assert_eq!(items, entries.iter().filter(|e| !e.label.contains('/')).collect::<Vec<_>>(), "{menu}");
|
||||
let folders: Vec<Value> = nodes.iter().filter(|n| matches!(n, MenuNode::Submenu { .. })).map(|n| serde_json::to_value(n).unwrap()).collect();
|
||||
assert_eq!(folders, [folder], "{menu}");
|
||||
}
|
||||
let (panels, _) = dynamic_nodes(&s, "scriptPanels", &cx);
|
||||
let labels: Vec<&str> = panels
|
||||
.iter()
|
||||
.filter_map(|n| match n {
|
||||
MenuNode::Item(e) => Some(e.label.as_str()),
|
||||
MenuNode::Submenu { label, .. } => Some(label),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(labels, ["Ease Presets.jsx", "Layer Tools.jsx", "Motion Kit", "Top.jsx"]);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uninstall_takes_a_script_name_not_a_path() {
|
||||
let (mut s, root) = session_in_tmp("uninstall");
|
||||
|
||||
@@ -180,16 +180,17 @@ fn rgba(c: [f32; 3]) -> [f64; 4] {
|
||||
[c[0] as f64, c[1] as f64, c[2] as f64, 1.0]
|
||||
}
|
||||
|
||||
/// The Rounded Rectangle tool's corner roundness for `size`.
|
||||
fn roundness(size: [f64; 2]) -> f64 {
|
||||
size[0].min(size[1]) * 0.15
|
||||
/// The Rounded Rectangle tool's corner roundness: a fixed default, as in After Effects
|
||||
/// (it does not grow with the drawn size).
|
||||
fn roundness() -> f64 {
|
||||
20.0
|
||||
}
|
||||
|
||||
/// The Polygon and Star tools' Polystar for `size`: (star, points, outer radius, inner radius).
|
||||
fn polystar_of(kind: &str, size: [f64; 2]) -> Option<(bool, f64, f64, f64)> {
|
||||
match kind {
|
||||
"star" => Some((true, 5.0, size[0] / 2.0, size[0] / 4.0)),
|
||||
"polygon" => Some((false, 6.0, size[0] / 2.0, 0.0)),
|
||||
"polygon" => Some((false, 5.0, size[0] / 2.0, 0.0)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -199,7 +200,7 @@ fn polystar_of(kind: &str, size: [f64; 2]) -> Option<(bool, f64, f64, f64)> {
|
||||
pub(crate) fn shape_group(ids: &mut Ids, kind: &str, size: [f64; 2], paint: &ShapeTool) -> Option<PropGroup> {
|
||||
let (path, gname) = match kind {
|
||||
"rect" | "rectangle" => (build::shape_rect(ids, size, [0.0, 0.0], 0.0), "Rectangle 1"),
|
||||
"rounded" | "roundedRect" => (build::shape_rect(ids, size, [0.0, 0.0], roundness(size)), "Rectangle 1"),
|
||||
"rounded" | "roundedRect" => (build::shape_rect(ids, size, [0.0, 0.0], roundness()), "Rectangle 1"),
|
||||
"ellipse" => (build::shape_ellipse(ids, size, [0.0, 0.0]), "Ellipse 1"),
|
||||
k => {
|
||||
let (star, points, outer, inner) = polystar_of(k, size)?;
|
||||
@@ -218,7 +219,7 @@ pub fn mask_path(kind: &str, centre: [f64; 2], size: [f64; 2]) -> Option<ShapePa
|
||||
let bez = match kind {
|
||||
"rect" | "rectangle" => return Some(ShapePath::rect(centre, size[0], size[1])),
|
||||
"ellipse" => return Some(ShapePath::ellipse(centre, size[0], size[1])),
|
||||
"rounded" | "roundedRect" => effectcraft_path::rect(size, centre, roundness(size)),
|
||||
"rounded" | "roundedRect" => effectcraft_path::rect(size, centre, roundness()),
|
||||
k => {
|
||||
let (star, points, outer, inner) = polystar_of(k, size)?;
|
||||
effectcraft_path::polystar(effectcraft_path::Polystar {
|
||||
|
||||
@@ -142,6 +142,7 @@ fn add_animator_prop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let layer = s.project.comp(cid).and_then(|c| c.layer(lid)).cloned().ok_or_else(|| bad(C, "no layer"))?;
|
||||
let auid = animator_uid(s, &layer, p, C)?;
|
||||
let mut index = 0;
|
||||
s.edit("Add Text Animator Property", None, |proj, _| {
|
||||
let mut next = proj.next_id;
|
||||
let mut ids = Ids(&mut next);
|
||||
@@ -151,10 +152,13 @@ fn add_animator_prop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for k in &kinds {
|
||||
add_props(&mut ids, anim, k, three);
|
||||
}
|
||||
// The requested property's 1-based position in the Properties group. A companion (such as
|
||||
// Tracking Type) is added before it, so the first new child is not always the one asked for.
|
||||
index = kinds.first().and_then(|k| anim.sub("properties")?.children.iter().position(|n| n.match_id() == k.as_str())).map_or(0, |i| i + 1);
|
||||
proj.next_id = next;
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"animator": auid}))
|
||||
Ok(json!({"animator": auid, "index": index}))
|
||||
}
|
||||
|
||||
pub(crate) fn add_selector(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -71,7 +71,8 @@ pub struct TextEdit {
|
||||
/// Character panel changes made with only a caret: the style the next typed text takes.
|
||||
#[serde(default)]
|
||||
pub pending: Option<CharStyle>,
|
||||
/// The layer was made by a Type tool click; leaving it empty deletes it.
|
||||
/// The layer was made by a Type tool click. Kept when left empty, as After Effects does, so this
|
||||
/// only records how the edit began.
|
||||
#[serde(default)]
|
||||
pub created: bool,
|
||||
/// Line position kept by repeated up / down moves (layout space).
|
||||
@@ -372,18 +373,14 @@ fn edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(state_json(s))
|
||||
}
|
||||
|
||||
/// `text.endEdit`: stop editing (a Type-tool layer left empty is deleted).
|
||||
/// `text.endEdit`: stop editing.
|
||||
///
|
||||
/// A Type-tool layer the user clicked away from without typing is **kept**: After Effects leaves it in
|
||||
/// the timeline as `<空白のテキストレイヤー>`, and the layer came from the user's click rather than from a
|
||||
/// mistake to clean up. Deleting it took away something they could still type into.
|
||||
pub(crate) fn end_edit(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let Some(e) = s.state.text_edit.take() else { return Ok(Value::Null) };
|
||||
if e.created && layer_doc(s, e.layer).is_some_and(|d| d.text.is_empty()) {
|
||||
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
|
||||
s.edit("Delete Empty Text Layer", None, |proj, st| {
|
||||
if let Some(c) = proj.comp_mut(cid) {
|
||||
c.layers.retain(|l| l.id != e.layer);
|
||||
}
|
||||
st.selected_layers.retain(|l| *l != e.layer);
|
||||
Ok(())
|
||||
})?;
|
||||
if s.state.text_edit.take().is_none() {
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
s.bump();
|
||||
Ok(Value::Null)
|
||||
|
||||
@@ -413,7 +413,8 @@ fn get_3d(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (w, h) = (comp.width as f64, comp.height as f64);
|
||||
let vs = s.state.views3d.get(&cid).cloned().unwrap_or_default();
|
||||
let t = s.state.times.get(&cid).copied().unwrap_or(Tick::ZERO);
|
||||
let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None };
|
||||
let ctx =
|
||||
EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None, property_overrides: &[], media_overrides: &[] };
|
||||
let cam = vs.override_camera(w, h).unwrap_or_else(|| camera::active_camera(&ctx));
|
||||
let f = cam.forward();
|
||||
let lights: Vec<Value> = comp
|
||||
@@ -555,7 +556,8 @@ fn auto_orient(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// through each layer's world matrix; cameras and lights contribute their position).
|
||||
fn layer_points(s: &Session, cid: ItemId, ids: &[LayerId], t: Tick) -> Vec<Vec3> {
|
||||
let Some(comp) = s.project.comp(cid) else { return vec![] };
|
||||
let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None };
|
||||
let ctx =
|
||||
EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None, property_overrides: &[], media_overrides: &[] };
|
||||
let mut out = vec![];
|
||||
for l in comp.layers.iter().filter(|l| ids.contains(&l.id)) {
|
||||
let m = ctx.world_matrix(l);
|
||||
|
||||
@@ -86,6 +86,10 @@ pub struct EditorState {
|
||||
/// Paste sets the same properties of the selected layers.
|
||||
#[serde(skip)]
|
||||
pub value_clipboard: Vec<(String, effectcraft_keyframe::Value)>,
|
||||
/// The copied keyframes and values as After Effects keyframe text (one layer's Transform
|
||||
/// properties), for the system clipboard.
|
||||
#[serde(skip)]
|
||||
pub keyframe_text: Option<String>,
|
||||
/// Copied effect instances, with comp-time key offsets from the earliest copied key.
|
||||
/// Paste adds them to the selected layers at the destination comp's CTI.
|
||||
#[serde(skip)]
|
||||
|
||||
@@ -81,6 +81,8 @@ fn refreshed(s: &Session, f: &Footage, probed: Footage) -> Footage {
|
||||
nf.codec = probed.codec;
|
||||
nf.has_video = probed.has_video;
|
||||
nf.has_audio = probed.has_audio;
|
||||
// The markers the file carries now (a WAV's cue points), for Update Markers From Source.
|
||||
nf.markers = probed.markers;
|
||||
if matches!(f.kind, FootageKind::Video | FootageKind::Audio) {
|
||||
nf.duration = probed.duration;
|
||||
// Interpret Footage ▸ Conform to frame rate stays; the file's own rate is refreshed.
|
||||
|
||||
@@ -45,14 +45,13 @@ impl SequenceOptions {
|
||||
/// `file.import`'s options. Dropped files (`drag: true`) import as stills, as in After
|
||||
/// Effects, while a folder's numbered stills import as sequences unless `sequence: false`
|
||||
/// (Alt-drag of a folder). The Import dialog passes its `"<format> Sequence"` checkbox (off
|
||||
/// unless ticked) as `sequence`. Without `sequence` or `drag` (scripts, agents) a numbered
|
||||
/// still brings its run, as it always has.
|
||||
/// unless ticked) as `sequence`. Without `sequence` (scripts, agents) a numbered still imports
|
||||
/// as itself, as `ImportOptions.sequence` defaults to false in After Effects (#2018).
|
||||
pub fn for_import(p: &serde_json::Value) -> SequenceOptions {
|
||||
let flag = |k: &str| p.get(k).and_then(serde_json::Value::as_bool);
|
||||
let (sequence, alphabetical) = (flag("sequence"), flag("alphabetical").unwrap_or(false));
|
||||
let dropped = flag("drag").unwrap_or(false);
|
||||
// Force Alphabetical Order is part of the Sequence option.
|
||||
SequenceOptions { sequence: sequence.unwrap_or(alphabetical || !dropped), folders: sequence.unwrap_or(true), alphabetical }
|
||||
SequenceOptions { sequence: sequence.unwrap_or(alphabetical), folders: sequence.unwrap_or(true), alphabetical }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
pub(crate) mod keyframe_text;
|
||||
pub(crate) mod keyframe_text_effects;
|
||||
pub mod psd_import;
|
||||
pub mod vector;
|
||||
pub mod xml_project;
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
//! Copied keyframes as After Effects keyframe text, for the system clipboard (#1908). Other
|
||||
//! applications read this tab-separated format (AE2Blend brings it into Blender). Like After
|
||||
//! Effects it covers one layer: its copied properties, keyframed ones as one row per key (frame
|
||||
//! number in comp time, then the value) and static ones as a single row with no frame. Easing
|
||||
//! and tangents aren't part of the format; EffectCraft's own paste keeps them.
|
||||
|
||||
use effectcraft_project::{AutoOrient, Comp, GroupKind, ItemKind, Layer, LayerId, LayerSource, Node, ParamUi, Project, PropGroup, Property, Uid};
|
||||
|
||||
use crate::{KeyRef, keyframe_text_effects};
|
||||
|
||||
/// Transform properties with the unit their columns are labelled with and, for vectors, the
|
||||
/// third component a 2D layer shows. X / Y / Z Position (Separate Dimensions) have no unit.
|
||||
const TRANSFORM: &[(&str, &str, Option<f64>)] = &[
|
||||
("poi", "pixels", Some(0.0)),
|
||||
("anchor", "pixels", Some(0.0)),
|
||||
("position", "pixels", Some(0.0)),
|
||||
("positionX", "", None),
|
||||
("positionY", "", None),
|
||||
("positionZ", "", None),
|
||||
("scale", "percent", Some(100.0)),
|
||||
("orientation", "degrees", None),
|
||||
("rotationX", "degrees", None),
|
||||
("rotationY", "degrees", None),
|
||||
("rotation", "degrees", None),
|
||||
("opacity", "percent", None),
|
||||
];
|
||||
|
||||
/// How a copied property is written: its block's header line, column labels and values.
|
||||
struct Channel {
|
||||
header: String,
|
||||
labels: Vec<String>,
|
||||
values: Values,
|
||||
}
|
||||
|
||||
/// How a property's value becomes the numbers in a row.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Values {
|
||||
Plain,
|
||||
/// A 2D layer's Transform vector: the third component is the one the layer shows (it may
|
||||
/// keep another from pasted 3D keys).
|
||||
Flat(f64),
|
||||
/// RGBA 0–1 as alpha, red, green, blue 0–255.
|
||||
Argb,
|
||||
/// A popup's 0-based choice as After Effects' 1-based one.
|
||||
OneBased,
|
||||
Scale(f64),
|
||||
}
|
||||
|
||||
impl Values {
|
||||
fn row(self, mut v: Vec<f64>) -> Vec<f64> {
|
||||
match (self, v.as_slice()) {
|
||||
(Values::Flat(z), _) => {
|
||||
if let Some(c) = v.get_mut(2) {
|
||||
*c = z;
|
||||
}
|
||||
v
|
||||
}
|
||||
(Values::Argb, [r, g, b, a]) => [a, r, g, b].map(|c| c * 255.0).to_vec(),
|
||||
(Values::OneBased, _) => v.into_iter().map(|c| c + 1.0).collect(),
|
||||
(Values::Scale(s), _) => v.into_iter().map(|c| c * s).collect(),
|
||||
_ => v,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The text for copied keys (layer times) and unanimated properties. `None` unless they all come
|
||||
/// from one layer and at least one property can be written.
|
||||
pub(crate) fn text(project: &Project, comp: &Comp, keys: &[KeyRef], statics: &[(LayerId, Uid)]) -> Option<String> {
|
||||
let lid = keys.first().map(|k| k.layer).or_else(|| statics.first().map(|s| s.0))?;
|
||||
if keys.iter().any(|k| k.layer != lid) || statics.iter().any(|s| s.0 != lid) {
|
||||
return None;
|
||||
}
|
||||
let layer = comp.layer(lid)?;
|
||||
let fps = comp.frame_rate.as_f64();
|
||||
let mut blocks = String::new();
|
||||
layer.props.walk("", &mut |_, pr| {
|
||||
let mut times: Vec<_> = keys.iter().filter(|k| k.prop == pr.uid).map(|k| k.time).collect();
|
||||
times.sort();
|
||||
times.dedup();
|
||||
let rows: Vec<(String, Vec<f64>)> = if !times.is_empty() {
|
||||
let frame = |t| num(layer.comp_time(t).seconds() * fps);
|
||||
times.into_iter().filter_map(|t| Some((frame(t), pr.keys.iter().find(|k| k.time == t)?.value.components()))).collect()
|
||||
} else if statics.iter().any(|s| s.1 == pr.uid) {
|
||||
vec![(String::new(), pr.value.components())]
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
let Some(ch) = channel(layer, pr).filter(|ch| !ch.labels.is_empty()) else { return };
|
||||
if rows.iter().any(|(_, v)| v.is_empty()) {
|
||||
return;
|
||||
}
|
||||
blocks.push_str(&format!("{}\n\tFrame\t{}\t\n", ch.header, ch.labels.join("\t")));
|
||||
for (frame, values) in rows {
|
||||
let values: Vec<String> = ch.values.row(values).into_iter().map(num).collect();
|
||||
blocks.push_str(&format!("\t{frame}\t{}\t\n", values.join("\t")));
|
||||
}
|
||||
blocks.push('\n');
|
||||
});
|
||||
if blocks.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// After Effects writes a text layer's source as 1 × 1. Other layers without a source of
|
||||
// their own (shapes, cameras) take the comp's size, and all of them its pixel aspect.
|
||||
let (w, h) = match (&layer.source, effectcraft_render::eval::source_size(project, layer)) {
|
||||
(LayerSource::Text, _) => (1, 1),
|
||||
(_, (0, 0)) => (comp.width, comp.height),
|
||||
(_, size) => size,
|
||||
};
|
||||
let par = match layer.source {
|
||||
LayerSource::Footage { item } | LayerSource::Solid { item } | LayerSource::Comp { item } => match project.item(item).map(|i| &i.kind) {
|
||||
Some(ItemKind::Footage(f)) => f.pixel_aspect,
|
||||
Some(ItemKind::Solid(s)) => s.pixel_aspect,
|
||||
Some(ItemKind::Comp(c)) => c.pixel_aspect,
|
||||
_ => comp.pixel_aspect,
|
||||
},
|
||||
_ => comp.pixel_aspect,
|
||||
};
|
||||
// The layout of current After Effects: a `Layer` line before the layer's properties (parsers
|
||||
// such as ae-keyframes reject properties without one), and a tab after every column.
|
||||
Some(format!(
|
||||
"Adobe After Effects 9.0 Keyframe Data\n\n\tUnits Per Second\t{}\n\tSource Width\t{w}\n\tSource Height\t{h}\n\tSource Pixel Aspect Ratio\t{}\n\tComp Pixel Aspect Ratio\t{}\n\nLayer\n{blocks}\nEnd of Keyframe Data\n",
|
||||
num(fps),
|
||||
num(par),
|
||||
num(comp.pixel_aspect)
|
||||
))
|
||||
}
|
||||
|
||||
/// How the layer's property `pr` is written, by where it sits in the layer: `None` for
|
||||
/// properties the text doesn't carry.
|
||||
fn channel(layer: &Layer, pr: &Property) -> Option<Channel> {
|
||||
let chain = layer.props.node_chain(pr.uid)?;
|
||||
match chain.first().and_then(|n| n.as_group()).map(|g| g.match_id.as_str()) {
|
||||
None if pr.match_id == "timeRemap" => Some(Channel { header: "Time Remap".into(), labels: labels("seconds", 1), values: Values::Plain }),
|
||||
Some("transform") => transform(layer, pr),
|
||||
Some("effects") => effect(&chain, pr),
|
||||
Some("masks" | "text" | "contents" | "cameraOptions" | "lightOptions" | "materialOptions") => nested(&chain, pr),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Indexed groups by match id, with the kind After Effects names them by: it writes them by kind
|
||||
/// and place (`Mask #2`, `Group #1`), not by name.
|
||||
const INSTANCES: &[(&str, &str)] = &[
|
||||
("mask", "Mask"),
|
||||
("animator", "Animator"),
|
||||
("rangeSelector", "Range Selector"),
|
||||
("wigglySelector", "Wiggly Selector"),
|
||||
("expressionSelector", "Expression Selector"),
|
||||
("group", "Group"),
|
||||
("rect", "Rectangle Path"),
|
||||
("ellipse", "Ellipse Path"),
|
||||
("star", "Polystar Path"),
|
||||
("path", "Path"),
|
||||
("fill", "Fill"),
|
||||
("stroke", "Stroke"),
|
||||
("gfill", "Gradient Fill"),
|
||||
("gstroke", "Gradient Stroke"),
|
||||
("trim", "Trim Paths"),
|
||||
("repeater", "Repeater"),
|
||||
("round", "Round Corners"),
|
||||
("offset", "Offset Paths"),
|
||||
("pucker", "Pucker & Bloat"),
|
||||
("twist", "Twist"),
|
||||
("zigzag", "Zig Zag"),
|
||||
("wiggle", "Wiggle Paths"),
|
||||
("merge", "Merge Paths"),
|
||||
];
|
||||
|
||||
/// A property under Masks, Text, Contents or Camera / Light / Material Options: the group's
|
||||
/// name, then each group down to the property. Instances go by kind and place, and a text
|
||||
/// selector's own properties carry their place too (`Start #1`).
|
||||
fn nested(chain: &[&Node], pr: &Property) -> Option<Channel> {
|
||||
let groups: Vec<&PropGroup> = chain.iter().filter_map(|n| n.as_group()).collect();
|
||||
let parent = groups.last()?;
|
||||
let (labels, values) = columns(&parent.match_id, &pr.match_id, pr, false)?;
|
||||
let mut header = groups.first()?.name.clone();
|
||||
for pair in groups.windows(2) {
|
||||
let [outer, g] = pair else { continue };
|
||||
match INSTANCES.iter().find(|(id, _)| *id == g.match_id).filter(|_| g.is_instance()) {
|
||||
Some((_, kind)) => header.push_str(&format!("\t{kind} #{}", place(outer, g.uid)?)),
|
||||
None => header.push_str(&format!("\t{}", g.name)),
|
||||
}
|
||||
}
|
||||
if parent.match_id.ends_with("Selector") {
|
||||
header.push_str(&format!("\t{} #{}", pr.name, place(parent, pr.uid)?));
|
||||
} else {
|
||||
header.push_str(&format!("\t{}", pr.name));
|
||||
}
|
||||
Some(Channel { header, labels, values })
|
||||
}
|
||||
|
||||
/// An effect property: `Effects`, the effect's name (not a custom one) and place on the layer,
|
||||
/// then the property's name and After Effects index. Properties in twirl-down groups are
|
||||
/// written without their group, as After Effects does.
|
||||
fn effect(chain: &[&Node], pr: &Property) -> Option<Channel> {
|
||||
let [Node::Group(effects), Node::Group(fx), ..] = chain else { return None };
|
||||
let GroupKind::Effect { effect } = &fx.kind else { return None };
|
||||
let name = effectcraft_effects::find(effect).map_or(fx.name.as_str(), |spec| spec.name);
|
||||
let path: String = chain.iter().skip(2).filter_map(|n| n.as_group()).map(|g| format!("{}/", g.match_id)).chain([pr.match_id.clone()]).collect();
|
||||
let (labels, values) = columns(effect, &path, pr, true)?;
|
||||
let header = format!("Effects\t{name} #{}\t{} #{}", place(effects, fx.uid)?, pr.name, effect_index(effect, fx, pr.uid)?);
|
||||
Some(Channel { header, labels, values })
|
||||
}
|
||||
|
||||
/// After Effects' index of an effect property: the effect's hidden input layer is #1, and a
|
||||
/// twirl-down group takes an index where it opens and another where it closes. The hidden
|
||||
/// source choice EffectCraft keeps beside a layer parameter has none. Where After Effects
|
||||
/// numbers a parameter otherwise ([`keyframe_text_effects::INDICES`]), counting goes on from it.
|
||||
fn effect_index(effect: &str, fx: &PropGroup, uid: Uid) -> Option<usize> {
|
||||
fn count(effect: &str, g: &PropGroup, prefix: &str, uid: Uid, n: &mut usize) -> bool {
|
||||
for c in &g.children {
|
||||
match c {
|
||||
Node::Prop(p) if is_layer_source(g, p) => {}
|
||||
Node::Prop(p) => {
|
||||
let path = format!("{prefix}{}", p.match_id);
|
||||
*n = keyframe_text_effects::INDICES.iter().find(|(e, q, _)| *e == effect && *q == path).map_or(*n + 1, |a| a.2);
|
||||
if p.uid == uid {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Node::Group(sub) => {
|
||||
*n += 1;
|
||||
if count(effect, sub, &format!("{prefix}{}/", sub.match_id), uid, n) {
|
||||
return true;
|
||||
}
|
||||
*n += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
let mut n = 1;
|
||||
count(effect, fx, "", uid, &mut n).then_some(n)
|
||||
}
|
||||
|
||||
fn is_layer_source(g: &PropGroup, p: &Property) -> bool {
|
||||
p.ui == ParamUi::Hidden && g.props().any(|l| l.ui == ParamUi::Layer && effectcraft_effects::layer_source_id(&l.match_id) == p.match_id)
|
||||
}
|
||||
|
||||
/// 1-based position of a child in its group.
|
||||
fn place(group: &PropGroup, uid: Uid) -> Option<usize> {
|
||||
group.children.iter().position(|c| c.uid() == uid).map(|i| i + 1)
|
||||
}
|
||||
|
||||
/// How After Effects writes a property differently from what its editor suggests ([`by_ui`]).
|
||||
#[derive(Clone, Copy)]
|
||||
enum Exception {
|
||||
/// Not written.
|
||||
Skip,
|
||||
Unit(&'static str),
|
||||
Values(Values),
|
||||
}
|
||||
|
||||
/// Properties After Effects writes differently from what their editor suggests, by the effect
|
||||
/// or group they're directly in and their match id.
|
||||
const EXCEPTIONS: &[(&str, &str, Exception)] = &[
|
||||
// Path Options' Path picks a mask, which After Effects doesn't write.
|
||||
("pathOptions", "path", Exception::Skip),
|
||||
("mask", "feather", Exception::Unit("pixels")),
|
||||
("mask", "expansion", Exception::Unit("pixels")),
|
||||
("cameraOptions", "zoom", Exception::Unit("pixels")),
|
||||
("cameraOptions", "focusDistance", Exception::Unit("pixels")),
|
||||
("cameraOptions", "aperture", Exception::Unit("pixels")),
|
||||
("cameraOptions", "highlightGain", Exception::Unit("")),
|
||||
("cameraOptions", "highlightSaturation", Exception::Unit("")),
|
||||
// After Effects keeps the threshold as 0–1.
|
||||
("cameraOptions", "highlightThreshold", Exception::Values(Values::Scale(1.0 / 255.0))),
|
||||
("lightOptions", "shadowDiffusion", Exception::Unit("pixels")),
|
||||
// Off, On, Only: written from 0.
|
||||
("materialOptions", "castsShadows", Exception::Values(Values::Plain)),
|
||||
("materialOptions", "acceptsShadows", Exception::Values(Values::Plain)),
|
||||
];
|
||||
|
||||
/// Column labels and values for a property in `scope`, the effect (by id) or group (by match
|
||||
/// id) it's directly in, at `path` there (an effect property's groups, then its match id): [`by_ui`]
|
||||
/// unless [`EXCEPTIONS`] or, in effects, [`keyframe_text_effects::UNITS`] say otherwise.
|
||||
fn columns(scope: &str, path: &str, pr: &Property, in_effect: bool) -> Option<(Vec<String>, Values)> {
|
||||
let (by_editor, values) = by_ui(pr, in_effect)?;
|
||||
let exception = EXCEPTIONS.iter().find(|(s, id, _)| *s == scope && *id == path).map(|e| e.2);
|
||||
let effect_unit = || keyframe_text_effects::UNITS.iter().find(|(e, p, _)| in_effect && *e == scope && *p == path).map(|u| Exception::Unit(u.2));
|
||||
Some(match exception.or_else(effect_unit) {
|
||||
None => (by_editor, values),
|
||||
Some(Exception::Skip) => return None,
|
||||
Some(Exception::Unit(unit)) => (labels(unit, pr.value.dims()), values),
|
||||
Some(Exception::Values(values)) => (by_editor, values),
|
||||
})
|
||||
}
|
||||
|
||||
/// Column labels and values for a property, by how it's edited. After Effects labels most 0–100
|
||||
/// sliders (opacities) as percent, and ±100 ones too outside effects (text selector amounts),
|
||||
/// and other sliders, checkboxes and popups with no unit. [`keyframe_text_effects::UNITS`] lists
|
||||
/// the effect parameters After Effects labels otherwise.
|
||||
fn by_ui(pr: &Property, in_effect: bool) -> Option<(Vec<String>, Values)> {
|
||||
let dims = pr.value.dims();
|
||||
Some(match &pr.ui {
|
||||
ParamUi::Color => (["alpha ", "red ", "green ", "blue "].map(String::from).to_vec(), Values::Argb),
|
||||
ParamUi::Popup { .. } => (labels("", dims), Values::OneBased),
|
||||
ParamUi::Slider { min, max, .. } if *max == 100.0 && (*min == 0.0 || (*min == -100.0 && !in_effect)) => (labels("percent", dims), Values::Plain),
|
||||
ParamUi::Percent => (labels("percent", dims), Values::Plain),
|
||||
ParamUi::Angle => (labels("degrees", dims), Values::Plain),
|
||||
ParamUi::Point | ParamUi::Point3 => (labels("pixels", dims), Values::Plain),
|
||||
ParamUi::Number | ParamUi::Slider { .. } | ParamUi::Pixels | ParamUi::Checkbox => (labels("", dims), Values::Plain),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn transform(layer: &Layer, pr: &Property) -> Option<Channel> {
|
||||
let (id, unit, z) = TRANSFORM.iter().find(|(id, ..)| *id == pr.match_id)?;
|
||||
if !active(layer, id) || (pr.three_d_only && !layer.is_3d()) {
|
||||
return None;
|
||||
}
|
||||
// After Effects labels only the first of Orientation's three columns, and names Z Rotation
|
||||
// plain Rotation (as AE2Blend expects).
|
||||
let labels = if *id == "orientation" { vec![format!("X {unit}")] } else { labels(unit, pr.value.dims()) };
|
||||
let values = match z {
|
||||
Some(z) if !layer.is_3d() => Values::Flat(*z),
|
||||
_ => Values::Plain,
|
||||
};
|
||||
Some(Channel { header: format!("Transform\t{}", pr.name), labels, values })
|
||||
}
|
||||
|
||||
/// Column labels: the unit, or one per axis (`X pixels`, `Y pixels`; `X ` with no unit, as After
|
||||
/// Effects writes a shape's Size).
|
||||
fn labels(unit: &str, dims: usize) -> Vec<String> {
|
||||
match dims {
|
||||
0 => vec![],
|
||||
1 => vec![unit.to_string()],
|
||||
_ => ["X", "Y", "Z"].iter().take(dims).map(|axis| format!("{axis} {unit}")).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the layer uses this Transform property: one-node cameras (and lights not aimed at
|
||||
/// it) have no Point of Interest, and Separate Dimensions replaces Position with X / Y / Z
|
||||
/// Position.
|
||||
fn active(layer: &Layer, id: &str) -> bool {
|
||||
match id {
|
||||
"poi" => !(layer.is_camera() || layer.is_light()) || layer.auto_orient == AutoOrient::TowardsPointOfInterest,
|
||||
"position" => layer.transform().is_none_or(|tr| tr.get("positionX").is_none()),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// A number with at most three decimals and no trailing zeros (`23.976`, `960`, `-0.5`).
|
||||
fn num(v: f64) -> String {
|
||||
let s = format!("{:.3}", if v.is_finite() { v } else { 0.0 });
|
||||
let s = s.trim_end_matches('0').trim_end_matches('.');
|
||||
if s == "-0" { "0".into() } else { s.into() }
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
//! After Effects' index and unit for effect parameters where they differ from what
|
||||
//! [`crate::keyframe_text`]'s rules give, as After Effects 2019 writes them in its keyframe text.
|
||||
//! After Effects keeps an effect's parameter indices for good (projects store them), so they
|
||||
//! hold for later versions. Parameters After Effects lacks, or names differently and keeps
|
||||
//! elsewhere, aren't listed.
|
||||
|
||||
/// Effect id, parameter path in the effect (its groups' match ids, then its own) and After
|
||||
/// Effects' index for it; the parameters after it count on from there. After Effects numbers
|
||||
/// legacy parameters it hides, so its indices skip ahead.
|
||||
pub(crate) const INDICES: &[(&str, &str, usize)] = &[
|
||||
("ec.3d.channelextract", "clampOutput", 6),
|
||||
("ec.3d.channelextract", "antialias", 5),
|
||||
("ec.3d.extractor", "blackPoint", 4),
|
||||
("ec.audio.parametriceq", "band1Enable", 3),
|
||||
("ec.blur.radial", "amount", 3),
|
||||
("ec.color.cccolorneutralizer", "midtonesUnbalance", 8),
|
||||
("ec.color.cccolorneutralizer", "highlightsUnbalance", 14),
|
||||
("ec.color.cccolorneutralizer", "pinning", 20),
|
||||
("ec.color.cckernel", "k1", 3),
|
||||
("ec.color.cckernel", "k4", 8),
|
||||
("ec.color.cckernel", "k7", 13),
|
||||
("ec.color.cckernel", "scale", 17),
|
||||
("ec.color.cckernel", "absolute", 18),
|
||||
("ec.color.colorstabilizer", "stabilize", 2),
|
||||
("ec.color.huesaturation", "hue", 4),
|
||||
("ec.color.huesaturation", "colorize", 7),
|
||||
("ec.color.levels", "inBlack", 4),
|
||||
("ec.color.levelsic", "rgb/rgbInBlack", 5),
|
||||
("ec.color.lumetri", "highDynamicRange", 3),
|
||||
("ec.color.lumetri", "basicCorrection/inputLut", 8),
|
||||
("ec.color.lumetri", "basicCorrection/whiteBalance/temperature", 12),
|
||||
("ec.color.lumetri", "basicCorrection/tone/hdrSpecular", 22),
|
||||
("ec.color.lumetri", "basicCorrection/saturation", 26),
|
||||
("ec.color.lumetri", "creative/look", 32),
|
||||
("ec.color.lumetri", "creative/lookIntensity", 33),
|
||||
("ec.color.lumetri", "creative/adjustments/tintBalance", 40),
|
||||
("ec.color.lumetri", "hslSecondary/key/showMask", 83),
|
||||
("ec.color.lumetri", "hslSecondary/refine/denoise", 90),
|
||||
("ec.color.lumetri", "hslSecondary/correction/temperature", 96),
|
||||
("ec.color.lumetri", "vignette/vignetteAmount", 105),
|
||||
("ec.color.selectivecolor", "details/reds/redsCyan", 10),
|
||||
("ec.color.videolimiter", "compressionBeforeClipping", 3),
|
||||
("ec.distort.ccflomotion", "knot1", 3),
|
||||
("ec.distort.ccpowerpin", "expandTop", 9),
|
||||
("ec.distort.upscale", "scale", 4),
|
||||
("ec.distort.puppet", "refinement", 3),
|
||||
("ec.control.stereo3d", "configuration", 3),
|
||||
("ec.control.stereo3d", "convergenceOf", 8),
|
||||
("ec.generate.advancedlightning", "turbulence", 17),
|
||||
("ec.generate.advancedlightning", "alphaObstacle", 16),
|
||||
("ec.generate.advancedlightning", "compositeOnOriginal", 21),
|
||||
("ec.generate.ccgluegun", "style", 9),
|
||||
("ec.generate.cclightburst", "setColor", 7),
|
||||
("ec.generate.cclightrays", "colorFromSource", 8),
|
||||
("ec.generate.cclightrays", "color", 10),
|
||||
("ec.generate.circle", "feather/feather", 7),
|
||||
("ec.generate.paintbucket", "color", 9),
|
||||
("ec.vr.blur", "frameLayout", 3),
|
||||
("ec.vr.blur", "blurriness", 6),
|
||||
("ec.vr.chromaticaberrations", "frameLayout", 3),
|
||||
("ec.vr.chromaticaberrations", "aberrationRed", 7),
|
||||
("ec.vr.colorgradients", "frameLayout", 3),
|
||||
("ec.vr.colorgradients", "point1", 10),
|
||||
("ec.vr.colorgradients", "point2", 12),
|
||||
("ec.vr.colorgradients", "point3", 14),
|
||||
("ec.vr.colorgradients", "point4", 16),
|
||||
("ec.vr.colorgradients", "point5", 18),
|
||||
("ec.vr.colorgradients", "blend", 7),
|
||||
("ec.vr.colorgradients", "opacity", 27),
|
||||
("ec.vr.denoise", "frameLayout", 3),
|
||||
("ec.vr.denoise", "noiseType", 6),
|
||||
("ec.vr.digitalglitch", "frameLayout", 3),
|
||||
("ec.vr.digitalglitch", "masterAmplitude", 9),
|
||||
("ec.vr.digitalglitch", "distortionRate", 16),
|
||||
("ec.vr.digitalglitch", "distortionComplexity", 15),
|
||||
("ec.vr.digitalglitch", "distortionEvolution", 17),
|
||||
("ec.vr.digitalglitch", "colorDistortion", 11),
|
||||
("ec.vr.digitalglitch", "randomSeed", 39),
|
||||
("ec.vr.fractalnoise", "frameLayout", 3),
|
||||
("ec.vr.fractalnoise", "fractalType", 6),
|
||||
("ec.vr.fractalnoise", "randomSeed", 25),
|
||||
("ec.vr.glow", "frameLayout", 3),
|
||||
("ec.vr.glow", "luminanceThreshold", 6),
|
||||
("ec.vr.planetosphere", "frameLayout", 3),
|
||||
("ec.vr.planetosphere", "scale", 6),
|
||||
("ec.vr.planetosphere", "feather", 8),
|
||||
("ec.vr.planetosphere", "tilt", 15),
|
||||
("ec.vr.rotatesphere", "frameLayout", 3),
|
||||
("ec.vr.rotatesphere", "invertRotation", 9),
|
||||
("ec.vr.rotatesphere", "tilt", 6),
|
||||
("ec.vr.sharpen", "frameLayout", 3),
|
||||
("ec.vr.sharpen", "sharpenAmount", 6),
|
||||
("ec.key.colordifference", "view", 3),
|
||||
("ec.key.colorrange", "fuzziness", 3),
|
||||
("ec.key.extract", "channel", 3),
|
||||
("ec.key.linearcolor", "view", 3),
|
||||
("ec.matte.refinehard", "feather", 2),
|
||||
("ec.matte.refinehard", "reduceChatter", 5),
|
||||
("ec.matte.refinehard", "decontaminationAmount", 14),
|
||||
("ec.matte.refinesoft", "reduceChatter", 10),
|
||||
("ec.matte.rotobrush", "rotoBrushPropagation/searchRadius", 3),
|
||||
("ec.matte.rotobrush", "rotoBrushMatte/feather", 13),
|
||||
("ec.matte.rotobrush", "refineEdgeMatte/smooth", 22),
|
||||
("ec.matte.rotobrush", "refineEdgeMatte/reduceChatter", 27),
|
||||
("ec.matte.rotobrush", "refineEdgeMatte/useMotionBlur", 29),
|
||||
("ec.noise.noisealpha", "noiseOptions/cycleNoise", 8),
|
||||
("ec.obsolete.basictext", "position", 2),
|
||||
("ec.key.spill", "suppression", 3),
|
||||
("ec.perspective.cameratracker", "shotType", 4),
|
||||
("ec.perspective.cameratracker", "advanced/solveMethod", 12),
|
||||
("ec.perspective.cameratracker", "advanced/detailedAnalysis", 17),
|
||||
("ec.perspective.cameratracker", "advanced/autoDeletePoints", 18),
|
||||
("ec.perspective.cccylinder", "rotationX", 9),
|
||||
("ec.perspective.cccylinder", "render", 13),
|
||||
("ec.perspective.cccylinder", "lightIntensity", 15),
|
||||
("ec.perspective.cccylinder", "ambient", 21),
|
||||
("ec.sim.ccdrizzle", "light/lightIntensity", 10),
|
||||
("ec.sim.ccdrizzle", "light/lightHeight", 13),
|
||||
("ec.sim.ccdrizzle", "light/lightDirection", 15),
|
||||
("ec.sim.ccmrmercury", "blobBirthSize", 15),
|
||||
("ec.sim.ccmrmercury", "light/lightIntensity", 19),
|
||||
("ec.sim.ccmrmercury", "light/lightHeight", 22),
|
||||
("ec.sim.ccmrmercury", "light/lightDirection", 24),
|
||||
("ec.sim.ccparticlesystems2", "particle/maxOpacity", 24),
|
||||
("ec.sim.ccparticlesystems2", "particle/birthColor", 27),
|
||||
("ec.sim.ccrainfall", "transferMode", 16),
|
||||
("ec.sim.ccrainfall", "extras/randomSeed", 23),
|
||||
("ec.sim.ccsnowfall", "color", 19),
|
||||
("ec.sim.ccsnowfall", "transferMode", 26),
|
||||
("ec.sim.ccsnowfall", "extras/randomSeed", 32),
|
||||
("ec.stylize.ccblockload", "startCleared", 4),
|
||||
("ec.stylize.cchextile", "rotate", 6),
|
||||
("ec.stylize.glow", "colorLooping", 9),
|
||||
("ec.text.timecode", "timeUnits", 5),
|
||||
("ec.text.timecode", "textPosition", 9),
|
||||
("ec.utility.facemeasurements", "headOrientationX", 9),
|
||||
("ec.utility.facemeasurements", "rightEyebrowRaise", 20),
|
||||
("ec.utility.facemeasurements", "mouthOffsetX", 26),
|
||||
("ec.utility.facetrackpoints", "leftEyebrowInner", 10),
|
||||
("ec.utility.facetrackpoints", "leftEyebrowMiddle", 12),
|
||||
("ec.utility.facetrackpoints", "leftEyebrowOuter", 14),
|
||||
("ec.utility.facetrackpoints", "rightEyebrowInner", 24),
|
||||
("ec.utility.facetrackpoints", "rightEyebrowMiddle", 26),
|
||||
("ec.utility.facetrackpoints", "rightEyebrowOuter", 28),
|
||||
("ec.utility.facetrackpoints", "leftEyeInner", 15),
|
||||
("ec.utility.facetrackpoints", "leftEyeOuter", 21),
|
||||
("ec.utility.facetrackpoints", "leftPupil", 20),
|
||||
("ec.utility.facetrackpoints", "rightEyeInner", 29),
|
||||
("ec.utility.facetrackpoints", "rightEyeOuter", 35),
|
||||
("ec.utility.facetrackpoints", "rightPupil", 34),
|
||||
("ec.utility.facetrackpoints", "noseTip", 41),
|
||||
("ec.utility.facetrackpoints", "leftNostril", 44),
|
||||
("ec.utility.facetrackpoints", "rightNostril", 46),
|
||||
("ec.utility.facetrackpoints", "mouthLeft", 49),
|
||||
("ec.utility.facetrackpoints", "mouthRight", 55),
|
||||
("ec.utility.facetrackpoints", "mouthTop", 52),
|
||||
("ec.utility.facetrackpoints", "mouthBottom", 58),
|
||||
("ec.utility.facetrackpoints", "chin", 84),
|
||||
];
|
||||
|
||||
/// Effect id, parameter path and the unit After Effects labels the parameter's columns with.
|
||||
pub(crate) const UNITS: &[(&str, &str, &str)] = &[
|
||||
("ec.3d.depthoffield", "focalBias", ""),
|
||||
("ec.3d.idmatte", "feather", ""),
|
||||
("ec.audio.flangechorus", "voiceSeparationTime", ""),
|
||||
("ec.audio.flangechorus", "voicePhaseChange", ""),
|
||||
("ec.audio.modulator", "modulationRate", ""),
|
||||
("ec.audio.parametriceq", "band1BoostCut", "percent"),
|
||||
("ec.audio.parametriceq", "band2BoostCut", "percent"),
|
||||
("ec.audio.parametriceq", "band3BoostCut", "percent"),
|
||||
("ec.audio.stereomixer", "leftLevel", "percent"),
|
||||
("ec.audio.stereomixer", "rightLevel", "percent"),
|
||||
("ec.audio.stereomixer", "leftPan", "percent"),
|
||||
("ec.audio.stereomixer", "rightPan", "percent"),
|
||||
("ec.blur.bilateral", "threshold", ""),
|
||||
("ec.blur.ccradial", "amount", "pixels"),
|
||||
("ec.blur.ccradialfast", "amount", ""),
|
||||
("ec.blur.camerashakedeblur", "strength", "percent"),
|
||||
("ec.blur.radial", "amount", ""),
|
||||
("ec.blur.smart", "radius", ""),
|
||||
("ec.blur.smart", "threshold", ""),
|
||||
("ec.color.autocolor", "blackClip", "percent"),
|
||||
("ec.color.autocolor", "whiteClip", "percent"),
|
||||
("ec.color.autocontrast", "blackClip", "percent"),
|
||||
("ec.color.autocontrast", "whiteClip", "percent"),
|
||||
("ec.color.autolevels", "blackClip", "percent"),
|
||||
("ec.color.autolevels", "whiteClip", "percent"),
|
||||
("ec.color.brightnesscontrast", "contrast", "pixels"),
|
||||
("ec.color.cccolorneutralizer", "pinning", ""),
|
||||
("ec.color.cccolorneutralizer", "blendWOriginal", ""),
|
||||
("ec.color.changecolor", "lightnessTransform", "pixels"),
|
||||
("ec.color.changecolor", "saturationTransform", "pixels"),
|
||||
("ec.color.colorbalance", "shadowRed", "pixels"),
|
||||
("ec.color.colorbalance", "shadowGreen", "pixels"),
|
||||
("ec.color.colorbalance", "shadowBlue", "pixels"),
|
||||
("ec.color.colorbalance", "midRed", "pixels"),
|
||||
("ec.color.colorbalance", "midGreen", "pixels"),
|
||||
("ec.color.colorbalance", "midBlue", "pixels"),
|
||||
("ec.color.colorbalance", "hiRed", "pixels"),
|
||||
("ec.color.colorbalance", "hiGreen", "pixels"),
|
||||
("ec.color.colorbalance", "hiBlue", "pixels"),
|
||||
("ec.color.colorbalancehls", "lightness", "pixels"),
|
||||
("ec.color.colorbalancehls", "saturation", "pixels"),
|
||||
("ec.color.colorstabilizer", "sampleSize", "percent"),
|
||||
("ec.color.gammapedestalgain", "blackStretch", "percent"),
|
||||
("ec.color.huesaturation", "saturation", "pixels"),
|
||||
("ec.color.huesaturation", "lightness", "pixels"),
|
||||
("ec.color.huesaturation", "colorizeSaturation", ""),
|
||||
("ec.color.huesaturation", "colorizeLightness", "pixels"),
|
||||
("ec.color.lumetri", "creative/adjustments/fadedFilm", ""),
|
||||
("ec.color.lumetri", "hslSecondary/refine/denoise", ""),
|
||||
("ec.color.lumetri", "hslSecondary/refine/blur", ""),
|
||||
("ec.color.lumetri", "vignette/vignetteMidpoint", ""),
|
||||
("ec.color.lumetri", "vignette/vignetteFeather", ""),
|
||||
("ec.color.psarbitrarymap", "phase", "degrees"),
|
||||
("ec.color.selectivecolor", "details/reds/redsCyan", "percent"),
|
||||
("ec.color.selectivecolor", "details/reds/redsMagenta", "percent"),
|
||||
("ec.color.selectivecolor", "details/reds/redsYellow", "percent"),
|
||||
("ec.color.selectivecolor", "details/reds/redsBlack", "percent"),
|
||||
("ec.color.selectivecolor", "details/yellows/yellowsCyan", "percent"),
|
||||
("ec.color.selectivecolor", "details/yellows/yellowsMagenta", "percent"),
|
||||
("ec.color.selectivecolor", "details/yellows/yellowsYellow", "percent"),
|
||||
("ec.color.selectivecolor", "details/yellows/yellowsBlack", "percent"),
|
||||
("ec.color.selectivecolor", "details/greens/greensCyan", "percent"),
|
||||
("ec.color.selectivecolor", "details/greens/greensMagenta", "percent"),
|
||||
("ec.color.selectivecolor", "details/greens/greensYellow", "percent"),
|
||||
("ec.color.selectivecolor", "details/greens/greensBlack", "percent"),
|
||||
("ec.color.selectivecolor", "details/cyans/cyansCyan", "percent"),
|
||||
("ec.color.selectivecolor", "details/cyans/cyansMagenta", "percent"),
|
||||
("ec.color.selectivecolor", "details/cyans/cyansYellow", "percent"),
|
||||
("ec.color.selectivecolor", "details/cyans/cyansBlack", "percent"),
|
||||
("ec.color.selectivecolor", "details/blues/bluesCyan", "percent"),
|
||||
("ec.color.selectivecolor", "details/blues/bluesMagenta", "percent"),
|
||||
("ec.color.selectivecolor", "details/blues/bluesYellow", "percent"),
|
||||
("ec.color.selectivecolor", "details/blues/bluesBlack", "percent"),
|
||||
("ec.color.selectivecolor", "details/magentas/magentasCyan", "percent"),
|
||||
("ec.color.selectivecolor", "details/magentas/magentasMagenta", "percent"),
|
||||
("ec.color.selectivecolor", "details/magentas/magentasYellow", "percent"),
|
||||
("ec.color.selectivecolor", "details/magentas/magentasBlack", "percent"),
|
||||
("ec.color.selectivecolor", "details/whites/whitesCyan", "percent"),
|
||||
("ec.color.selectivecolor", "details/whites/whitesMagenta", "percent"),
|
||||
("ec.color.selectivecolor", "details/whites/whitesYellow", "percent"),
|
||||
("ec.color.selectivecolor", "details/whites/whitesBlack", "percent"),
|
||||
("ec.color.selectivecolor", "details/neutrals/neutralsCyan", "percent"),
|
||||
("ec.color.selectivecolor", "details/neutrals/neutralsMagenta", "percent"),
|
||||
("ec.color.selectivecolor", "details/neutrals/neutralsYellow", "percent"),
|
||||
("ec.color.selectivecolor", "details/neutrals/neutralsBlack", "percent"),
|
||||
("ec.color.selectivecolor", "details/blacks/blacksCyan", "percent"),
|
||||
("ec.color.selectivecolor", "details/blacks/blacksMagenta", "percent"),
|
||||
("ec.color.selectivecolor", "details/blacks/blacksYellow", "percent"),
|
||||
("ec.color.selectivecolor", "details/blacks/blacksBlack", "percent"),
|
||||
("ec.color.shadowhighlight", "shadowAmount", ""),
|
||||
("ec.color.shadowhighlight", "highlightAmount", ""),
|
||||
("ec.color.shadowhighlight", "moreOptions/shadowTonalWidth", ""),
|
||||
("ec.color.shadowhighlight", "moreOptions/highlightTonalWidth", ""),
|
||||
("ec.color.shadowhighlight", "moreOptions/blackClip", "percent"),
|
||||
("ec.color.shadowhighlight", "moreOptions/whiteClip", "percent"),
|
||||
("ec.distort.bulge", "height", "pixels"),
|
||||
("ec.distort.bulge", "taperRadius", ""),
|
||||
("ec.distort.ccbendit", "bend", "pixels"),
|
||||
("ec.distort.ccbender", "amount", "pixels"),
|
||||
("ec.distort.ccgriddler", "tileSize", "percent"),
|
||||
("ec.distort.cctiler", "scale", "percent"),
|
||||
("ec.distort.upscale", "scale", "percent"),
|
||||
("ec.distort.magnify", "magnification", "pixels"),
|
||||
("ec.distort.magnify", "size", "percent"),
|
||||
("ec.distort.puppet", "refinement", ""),
|
||||
("ec.distort.ripple", "radius", ""),
|
||||
("ec.distort.ripple", "speed", "percent"),
|
||||
("ec.distort.transform", "opacity", ""),
|
||||
("ec.distort.turbulentdisplace", "complexity", "percent"),
|
||||
("ec.distort.twirl", "radius", ""),
|
||||
("ec.distort.wavewarp", "width", "percent"),
|
||||
("ec.distort.wavewarp", "speed", "pixels"),
|
||||
("ec.generate.fourcolor", "blend", "percent"),
|
||||
("ec.generate.advancedlightning", "coreSettings/coreRadius", ""),
|
||||
("ec.generate.advancedlightning", "glowSettings/glowRadius", "percent"),
|
||||
("ec.generate.advancedlightning", "alphaObstacle", "pixels"),
|
||||
("ec.generate.ccgluegun", "reflection", ""),
|
||||
("ec.generate.cclightburst", "rayLength", ""),
|
||||
("ec.generate.cclightsweep", "edgeThickness", ""),
|
||||
("ec.generate.ccthreads", "coverage", ""),
|
||||
("ec.generate.ccthreads", "shadowing", ""),
|
||||
("ec.generate.ccthreads", "texture", ""),
|
||||
("ec.generate.checkerboard", "width", "percent"),
|
||||
("ec.generate.checkerboard", "height", "percent"),
|
||||
("ec.generate.grid", "width", "percent"),
|
||||
("ec.generate.grid", "height", "percent"),
|
||||
("ec.generate.lensflare", "flareBrightness", "percent"),
|
||||
("ec.vr.chromaticaberrations", "falloff", ""),
|
||||
("ec.vr.denoise", "noiseLevel", ""),
|
||||
("ec.vr.digitalglitch", "colorDistortion", ""),
|
||||
("ec.vr.fractalnoise", "subSettings/subInfluence", ""),
|
||||
("ec.vr.glow", "luminanceThreshold", ""),
|
||||
("ec.vr.planetosphere", "feather", ""),
|
||||
("ec.vr.sharpen", "sharpenAmount", ""),
|
||||
("ec.key.advancedspill", "suppression", "percent"),
|
||||
("ec.key.advancedspill", "ultraSettings/tolerance", ""),
|
||||
("ec.key.advancedspill", "ultraSettings/desaturate", ""),
|
||||
("ec.key.advancedspill", "ultraSettings/spillRange", ""),
|
||||
("ec.key.advancedspill", "ultraSettings/spillColorCorrection", ""),
|
||||
("ec.key.advancedspill", "ultraSettings/lumaCorrection", ""),
|
||||
("ec.key.ccsimplewireremoval", "thickness", ""),
|
||||
("ec.key.keycleaner", "additionalEdgeRadius", ""),
|
||||
("ec.key.keycleaner", "alphaContrast", "percent"),
|
||||
("ec.matte.refinehard", "feather", ""),
|
||||
("ec.matte.refinehard", "increaseDecontaminationRadius", ""),
|
||||
("ec.matte.refinesoft", "smooth", ""),
|
||||
("ec.matte.refinesoft", "feather", ""),
|
||||
("ec.matte.refinesoft", "shiftEdge", "percent"),
|
||||
("ec.matte.refinesoft", "decontamination/increaseDecontaminationRadius", ""),
|
||||
("ec.matte.rotobrush", "rotoBrushPropagation/searchRadius", ""),
|
||||
("ec.matte.rotobrush", "rotoBrushMatte/feather", ""),
|
||||
("ec.matte.rotobrush", "rotoBrushMatte/shiftEdge", "percent"),
|
||||
("ec.matte.rotobrush", "refineEdgeMatte/smooth", ""),
|
||||
("ec.matte.rotobrush", "refineEdgeMatte/feather", ""),
|
||||
("ec.matte.rotobrush", "refineEdgeMatte/shiftEdge", "percent"),
|
||||
("ec.matte.rotobrush", "refineEdgeMatte/decontamination/increaseDecontaminationRadius", ""),
|
||||
("ec.matte.simplechoker", "chokeMatte", "pixels"),
|
||||
("ec.noise.fractal", "transform/scale", "percent"),
|
||||
("ec.noise.fractal", "transform/scaleWidth", "percent"),
|
||||
("ec.noise.fractal", "transform/scaleHeight", "percent"),
|
||||
("ec.noise.fractal", "complexity", "percent"),
|
||||
("ec.noise.fractal", "subSettings/subInfluence", ""),
|
||||
("ec.noise.turbulent", "transform/scale", "percent"),
|
||||
("ec.noise.turbulent", "transform/scaleWidth", "percent"),
|
||||
("ec.noise.turbulent", "transform/scaleHeight", "percent"),
|
||||
("ec.noise.turbulent", "complexity", "percent"),
|
||||
("ec.noise.turbulent", "subSettings/subInfluence", ""),
|
||||
("ec.key.colorkey", "edgeFeather", ""),
|
||||
("ec.obsolete.lightning", "branchAngle", ""),
|
||||
("ec.key.luma", "edgeFeather", ""),
|
||||
("ec.blur.reduceflicker", "softness", ""),
|
||||
("ec.perspective.cameratracker", "trackPointSize", "percent"),
|
||||
("ec.perspective.cameratracker", "targetSize", "percent"),
|
||||
("ec.perspective.cccylinder", "lightHeight", "pixels"),
|
||||
("ec.sim.ccbubbles", "bubbleSize", "percent"),
|
||||
("ec.sim.ccdrizzle", "displacement", ""),
|
||||
("ec.sim.ccdrizzle", "light/lightHeight", "pixels"),
|
||||
("ec.sim.ccmrmercury", "longevity", ""),
|
||||
("ec.sim.ccmrmercury", "blobInfluence", "percent"),
|
||||
("ec.sim.ccmrmercury", "light/lightHeight", "pixels"),
|
||||
("ec.sim.ccmrmercury", "shading/metal", ""),
|
||||
("ec.sim.ccparticlesystems2", "birthRate", ""),
|
||||
("ec.sim.ccparticlesystems2", "longevity", ""),
|
||||
("ec.sim.ccparticlesystems2", "physics/gravity", "pixels"),
|
||||
("ec.sim.ccrainfall", "windVariation", ""),
|
||||
("ec.sim.ccrainfall", "opacity", ""),
|
||||
("ec.sim.ccsnowfall", "sizeVariation", ""),
|
||||
("ec.sim.ccsnowfall", "speedVariation", ""),
|
||||
("ec.sim.ccsnowfall", "windVariation", ""),
|
||||
("ec.sim.ccsnowfall", "wiggle/wiggleAmount", ""),
|
||||
("ec.sim.ccsnowfall", "wiggle/wiggleAmountVariation", ""),
|
||||
("ec.sim.ccsnowfall", "wiggle/wiggleFrequencyVariation", ""),
|
||||
("ec.sim.ccsnowfall", "opacity", ""),
|
||||
("ec.sim.ccstarburst", "speed", "percent"),
|
||||
("ec.sim.ccstarburst", "size", "pixels"),
|
||||
("ec.stylize.ccblockload", "completion", ""),
|
||||
("ec.stylize.ccburnfilm", "burn", ""),
|
||||
("ec.stylize.cchextile", "smearing", ""),
|
||||
("ec.stylize.cckaleida", "size", "percent"),
|
||||
("ec.stylize.ccvignette", "pinHighlights", ""),
|
||||
("ec.stylize.cartoon", "detailThreshold", ""),
|
||||
("ec.stylize.cartoon", "fill/shadingSmoothness", ""),
|
||||
("ec.stylize.cartoon", "edge/edgeSoftness", ""),
|
||||
("ec.stylize.cartoon", "advanced/edgeBlackLevel", ""),
|
||||
("ec.stylize.glow", "colorLoops", "percent"),
|
||||
("ec.stylize.roughenedges", "stretch", "pixels"),
|
||||
("ec.time.pixelmotionblur", "vectorDetail", ""),
|
||||
("ec.transition.blockdissolve", "blockWidth", "percent"),
|
||||
("ec.transition.blockdissolve", "blockHeight", "percent"),
|
||||
("ec.transition.ccgridwipe", "border", "pixels"),
|
||||
("ec.transition.ccgridwipe", "tiles", "percent"),
|
||||
("ec.transition.cclinesweep", "completion", ""),
|
||||
("ec.transition.ccscalewipe", "stretch", "pixels"),
|
||||
("ec.transition.venetian", "width", "percent"),
|
||||
("ec.transition.venetian", "feather", ""),
|
||||
("ec.utility.cineon", "highlightRolloff", ""),
|
||||
("ec.utility.facemeasurements", "headOrientationX", "degrees"),
|
||||
("ec.utility.facemeasurements", "headOrientationY", "degrees"),
|
||||
("ec.utility.facemeasurements", "headOrientationZ", "degrees"),
|
||||
("ec.utility.facemeasurements", "leftEyebrowRaise", "percent"),
|
||||
("ec.utility.facemeasurements", "rightEyebrowRaise", "percent"),
|
||||
("ec.utility.facemeasurements", "mouthOffsetX", "percent"),
|
||||
("ec.utility.facemeasurements", "mouthOffsetY", "percent"),
|
||||
];
|
||||
@@ -70,6 +70,7 @@ pub use home::templates;
|
||||
pub use interchange::psd_import;
|
||||
pub use interchange::vector;
|
||||
pub use interchange::xml_project;
|
||||
use interchange::{keyframe_text, keyframe_text_effects};
|
||||
pub use render_queue::{ExportJob, ExportResult, Exporter, JobState};
|
||||
pub use settings::autosave;
|
||||
pub use settings::config;
|
||||
|
||||
@@ -82,7 +82,8 @@ fn holder_of(s: &Session, keys: &str) -> Option<String> {
|
||||
/// Recent Script Files), `history` (undo steps, newest
|
||||
/// first), `view3dShortcuts`, `workspaceShortcuts`, `savedWorkspaces` (Window ▸ Workspace) and
|
||||
/// `openViewers` (open compositions other than the active one), `scripts` (File ▸ Scripts) and
|
||||
/// `scriptPanels` (ScriptUI panels).
|
||||
/// `scriptPanels` (ScriptUI panels; a script in a subfolder is labelled `Folder/File.jsx` here,
|
||||
/// and [`dynamic_nodes`] puts it in the folder's submenu).
|
||||
pub fn dynamic(s: &Session, name: &str, cx: &DynCtx) -> (Vec<MenuEntry>, Option<&'static str>) {
|
||||
use serde_json::json;
|
||||
let indexed = |list: &[String], cmd: &str, stem: bool| -> Vec<MenuEntry> {
|
||||
@@ -167,6 +168,31 @@ pub fn dynamic(s: &Session, name: &str, cx: &DynCtx) -> (Vec<MenuEntry>, Option<
|
||||
}
|
||||
}
|
||||
|
||||
/// [`dynamic`] as menu nodes, which is what frontends draw: scripts and panels in a subfolder
|
||||
/// (`Motion Kit/Animate.jsx`) go into a submenu named after the folder (`Motion Kit ▸
|
||||
/// Animate.jsx`), placed where the folder sorts among the other entries.
|
||||
pub fn dynamic_nodes(s: &Session, name: &str, cx: &DynCtx) -> (Vec<MenuNode>, Option<&'static str>) {
|
||||
let (entries, empty) = dynamic(s, name, cx);
|
||||
let scripts = matches!(name, "scripts" | "scriptPanels");
|
||||
let mut out: Vec<MenuNode> = vec![];
|
||||
for mut e in entries {
|
||||
let Some((folder, file)) = e.label.split_once('/').filter(|_| scripts).map(|(d, f)| (d.to_string(), f.to_string())) else {
|
||||
out.push(MenuNode::Item(e));
|
||||
continue;
|
||||
};
|
||||
e.label = file;
|
||||
let open = out.iter_mut().find_map(|n| match n {
|
||||
MenuNode::Submenu { label, children } if *label == folder => Some(children),
|
||||
_ => None,
|
||||
});
|
||||
match open {
|
||||
Some(children) => children.push(MenuNode::Item(e)),
|
||||
None => out.push(MenuNode::Submenu { label: folder, children: vec![MenuNode::Item(e)] }),
|
||||
}
|
||||
}
|
||||
(out, empty)
|
||||
}
|
||||
|
||||
/// Display label of a submenu (the "Assign Shortcut to …" submenus name the current view or
|
||||
/// workspace, like After Effects).
|
||||
pub fn submenu_label(s: &Session, label: &str, cx: &DynCtx) -> String {
|
||||
|
||||
@@ -50,8 +50,20 @@ pub trait Services: Send + Sync {
|
||||
pub struct FsServices;
|
||||
|
||||
impl Services for FsServices {
|
||||
/// Refuses what isn't a regular file: a project can name `/dev/zero` or a FIFO, and reading
|
||||
/// those never ends (#2134).
|
||||
fn read_file(&self, path: &str) -> std::io::Result<Vec<u8>> {
|
||||
std::fs::read(path)
|
||||
use std::io::Read;
|
||||
let mut file = std::fs::File::open(path)?;
|
||||
let meta = file.metadata()?;
|
||||
if !meta.is_file() {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("{path} is not a file")));
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
// A sparse file can claim far more than memory: an error, not an abort.
|
||||
bytes.try_reserve_exact(usize::try_from(meta.len()).unwrap_or(usize::MAX)).map_err(|e| std::io::Error::new(std::io::ErrorKind::OutOfMemory, e))?;
|
||||
file.read_to_end(&mut bytes)?;
|
||||
Ok(bytes)
|
||||
}
|
||||
fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> {
|
||||
config::atomic_write(std::path::Path::new(path), data)
|
||||
@@ -70,3 +82,21 @@ pub trait Importer: Send + Sync {
|
||||
/// media layer reads from memory (the browser) register them; file-based ones need nothing.
|
||||
fn add_frames(&self, _paths: &[String]) {}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{FsServices, Services};
|
||||
|
||||
/// #2134: a project can name a folder, `/dev/zero` or a FIFO; reading those is an error.
|
||||
#[test]
|
||||
fn fs_services_read_only_regular_files() {
|
||||
let dir = std::env::temp_dir();
|
||||
assert!(FsServices.read_file(&dir.to_string_lossy()).is_err());
|
||||
#[cfg(unix)]
|
||||
assert!(FsServices.read_file("/dev/zero").is_err());
|
||||
let f = dir.join(format!("ec-services-read-{}.txt", std::process::id()));
|
||||
std::fs::write(&f, b"data").unwrap();
|
||||
assert_eq!(FsServices.read_file(&f.to_string_lossy()).unwrap(), b"data");
|
||||
let _ = std::fs::remove_file(f);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,9 @@ pub trait ConfigStore: Send + Sync {
|
||||
}
|
||||
Ok(text)
|
||||
}
|
||||
/// Write entry `name`, replacing any earlier text.
|
||||
fn write(&self, name: &str, data: &str) -> std::io::Result<()>;
|
||||
/// Remove entry `name`; a missing entry is no error.
|
||||
fn remove(&self, name: &str) -> std::io::Result<()>;
|
||||
/// Names of the entries directly in `dir` (a `/`-separated name prefix such as `Scripts`),
|
||||
/// sorted. Stores that can't list return nothing.
|
||||
@@ -32,6 +34,11 @@ pub trait ConfigStore: Send + Sync {
|
||||
let _ = dir;
|
||||
vec![]
|
||||
}
|
||||
/// Names of the folders directly in `dir`, sorted, like [`ConfigStore::list`].
|
||||
fn list_dirs(&self, dir: &str) -> Vec<String> {
|
||||
let _ = dir;
|
||||
vec![]
|
||||
}
|
||||
/// The folder next to the configuration (default Auto-Save folder for untitled projects),
|
||||
/// in the namespace of [`ConfigStore::files`]; `None` when there is none.
|
||||
fn dir(&self) -> Option<PathBuf> {
|
||||
@@ -107,6 +114,15 @@ impl ConfigStore for MemoryConfig {
|
||||
let Ok(m) = self.files.lock() else { return vec![] };
|
||||
m.keys().filter_map(|k| k.strip_prefix(&pre)).filter(|n| !n.is_empty() && !n.contains('/')).map(str::to_string).collect()
|
||||
}
|
||||
/// The first name part of the keys under `dir/`.
|
||||
fn list_dirs(&self, dir: &str) -> Vec<String> {
|
||||
let pre = format!("{}/", dir.trim_end_matches('/'));
|
||||
let Ok(m) = self.files.lock() else { return vec![] };
|
||||
let mut v: Vec<String> = m.keys().filter_map(|k| k.strip_prefix(&pre)?.split_once('/')).map(|(d, _)| d.to_string()).filter(|d| !d.is_empty()).collect();
|
||||
v.sort();
|
||||
v.dedup();
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
/// Files in one directory, written atomically.
|
||||
@@ -153,6 +169,13 @@ impl ConfigStore for DirConfig {
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
/// The folders in `dir`, following links like [`ConfigStore::list`] does for files.
|
||||
fn list_dirs(&self, dir: &str) -> Vec<String> {
|
||||
let Ok(rd) = std::fs::read_dir(self.dir.join(dir)) else { return vec![] };
|
||||
let mut v: Vec<String> = rd.flatten().filter(|e| e.path().is_dir()).map(|e| e.file_name().to_string_lossy().to_string()).collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
fn remove(&self, name: &str) -> std::io::Result<()> {
|
||||
match std::fs::remove_file(self.dir.join(name)) {
|
||||
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e),
|
||||
|
||||
@@ -122,6 +122,11 @@ pub fn pages() -> Vec<Page> {
|
||||
Section("New Project"),
|
||||
s("project.useTemplate", "New Project Loads Template", B, true),
|
||||
s("project.templatePath", "Template Project", Kind::Path, true),
|
||||
Section("Project Files"),
|
||||
s("project.compactProjectFiles", "Save Compact Project Files", B, true),
|
||||
Note(
|
||||
"Saves .ecproj files as compact JSON on one line: smaller and faster to save and open, but hard to read or diff in version control. Off, they are indented, one value per line.",
|
||||
),
|
||||
Section("Auto-Save"),
|
||||
s("autoSave.enabled", "Automatically Save Projects", B, true),
|
||||
s("autoSave.intervalMinutes", "Save Every", Kind::Int(1, 240, "Minutes"), true),
|
||||
|
||||
@@ -62,6 +62,9 @@ page!(ProjectPrefs {
|
||||
/// Open a template project for File ▸ New ▸ New Project.
|
||||
use_template: bool = false,
|
||||
template_path: String = String::new(),
|
||||
/// Write `.ecproj` files as compact JSON on one line (smaller, faster to save and open). Off:
|
||||
/// indented, one value per line, so they read and diff cleanly in version control.
|
||||
compact_project_files: bool = false,
|
||||
});
|
||||
|
||||
page!(AutoSave {
|
||||
|
||||
@@ -15,8 +15,10 @@ mod frame_export;
|
||||
mod fuzz_open;
|
||||
mod history;
|
||||
mod import_project;
|
||||
mod instance3d;
|
||||
mod key_metadata_order;
|
||||
mod key_time;
|
||||
mod keyframe_text;
|
||||
mod keylight;
|
||||
mod limits;
|
||||
mod lottie;
|
||||
|
||||
@@ -78,7 +78,7 @@ fn transition_preset_by_name_and_bad_input() {
|
||||
#[test]
|
||||
fn preset_catalogue_is_unique_and_categorised() {
|
||||
let all = crate::animation_presets();
|
||||
let known = ["Transitions", "Backgrounds"];
|
||||
let known = ["Transitions", "Backgrounds", "Behaviors"];
|
||||
let mut ids = std::collections::HashSet::new();
|
||||
let mut names = std::collections::HashSet::new();
|
||||
for (id, name, cat) in &all {
|
||||
@@ -90,7 +90,7 @@ fn preset_catalogue_is_unique_and_categorised() {
|
||||
assert!(!ids.contains(&id.to_lowercase()) && !names.contains(&name.to_lowercase()), "text preset {id} clashes");
|
||||
}
|
||||
let n = |c: &str| all.iter().filter(|(_, _, k)| k == c).count();
|
||||
assert!(n("Backgrounds") >= 15 && n("Transitions") >= 20);
|
||||
assert!(n("Backgrounds") >= 15 && n("Transitions") >= 20 && n("Behaviors") >= 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -126,3 +126,34 @@ fn preset_expressions_and_point_fractions() {
|
||||
assert_eq!(ramp.prop("end").unwrap().value, effectcraft_keyframe::Value::Vec2([80.0, 90.0]));
|
||||
assert_eq!(ramp.prop("startColor").unwrap().keys.len(), 2);
|
||||
}
|
||||
|
||||
/// #1510: Behaviors presets add no effects; each puts an enabled expression on a transform
|
||||
/// property, and undo removes it again.
|
||||
#[test]
|
||||
fn every_behavior_preset_applies_and_undoes() {
|
||||
let (mut s, solid) = session();
|
||||
let list = s.execute("anim.builtinPresets", json!({"category": "Behaviors"})).unwrap();
|
||||
let list = list.as_array().unwrap().clone();
|
||||
assert!(list.len() >= 20, "{}", list.len());
|
||||
let driven = |s: &Session| {
|
||||
let l = &s.active_comp().unwrap().layers[0];
|
||||
["transform/position", "transform/scale", "transform/rotation", "transform/opacity"]
|
||||
.iter()
|
||||
.filter(|p| l.props.prop(p).is_some_and(|pr| pr.has_expression()))
|
||||
.copied()
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
for p in &list {
|
||||
let id = p["id"].as_str().unwrap();
|
||||
let r = s.execute("anim.applyBuiltinPreset", json!({"layers": [solid], "preset": id})).unwrap();
|
||||
assert_eq!(r["skipped"], json!([]), "{id}: every path and expression applies");
|
||||
assert_eq!(r["effects"], json!([]), "{id}: behaviors add no effects");
|
||||
let applied = driven(&s);
|
||||
assert_eq!(applied.len(), 1, "{id}: exactly one property is driven");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(driven(&s).is_empty(), "{id}: undo removes the expression");
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(driven(&s), applied, "{id}: redo restores the expression");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,7 +190,16 @@ fn rendered_matte_of_pick(s: &mut Session, x: f64, y: f64, scale: f64) -> (Strin
|
||||
s.execute_checked("prop.set", json!({"path": format!("effects/#{fx}/matteOnly"), "value": true})).unwrap();
|
||||
let (cid, comp) = (s.active_comp_id().unwrap(), s.active_comp().unwrap());
|
||||
let layer = &comp.layers[0];
|
||||
let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: Tick::ZERO, expr: None, footage: None };
|
||||
let ctx = effectcraft_render::EvalCtx {
|
||||
project: &s.project,
|
||||
comp_id: cid,
|
||||
comp,
|
||||
time: Tick::ZERO,
|
||||
expr: None,
|
||||
footage: None,
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
};
|
||||
let buf = s.renderer(effectcraft_render::RenderOpts { scale, ..Default::default() }).content_buf(&ctx, layer).unwrap();
|
||||
let (px, py) = buf.to_px([x, y]);
|
||||
let m = buf.img.get(px.floor() as i64, py.floor() as i64)[0];
|
||||
|
||||
@@ -136,3 +136,62 @@ fn footage_changes_change_the_salt() {
|
||||
assert_ne!(k3, k2);
|
||||
assert_ne!(set_proxy(&mut s, false), k3);
|
||||
}
|
||||
|
||||
/// Marker edits keep the disk key (the sound of a playing preview must not wait for frames to
|
||||
/// render again, #1083); an expression that reads markers makes them count.
|
||||
#[test]
|
||||
fn markers_keep_the_content_key_unless_an_expression_reads_them() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "M", "width": 8, "height": 8, "frameRate": 30, "duration": 2})).unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let l = s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let key = |s: &Session| effectcraft_render::disk_cache::comp_content_key(&s.project, cid);
|
||||
let k0 = key(&s);
|
||||
s.execute("comp.addMarker", json!({"time": 0.5, "comment": "beat"})).unwrap();
|
||||
s.execute("markers.set", json!({"layer": l, "new": true, "time": 0.25})).unwrap();
|
||||
assert_eq!(key(&s), k0, "markers");
|
||||
s.execute("prop.setExpression", json!({"layer": l, "path": "transform/opacity", "expression": "thisComp.marker.numKeys"})).unwrap();
|
||||
let k1 = key(&s);
|
||||
assert_ne!(k1, k0);
|
||||
s.execute("comp.addMarker", json!({"time": 1.0})).unwrap();
|
||||
assert_ne!(key(&s), k1, "a marker read by an expression");
|
||||
}
|
||||
|
||||
/// A comp marker alone (no layer marker beside it) keeps the key too.
|
||||
#[test]
|
||||
fn a_lone_comp_marker_keeps_the_content_key() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "M", "width": 8, "height": 8, "frameRate": 30, "duration": 2})).unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap();
|
||||
let key = |s: &Session| effectcraft_render::disk_cache::comp_content_key(&s.project, cid);
|
||||
let k0 = key(&s);
|
||||
s.execute("comp.addMarker", json!({"time": 0.5, "comment": "beat"})).unwrap();
|
||||
assert_eq!(key(&s), k0);
|
||||
}
|
||||
|
||||
/// A protected marker with a duration retimes a stretched precomp layer (Responsive Design), so
|
||||
/// changing it changes the key of the comp that nests it; a plain marker beside it does not.
|
||||
#[test]
|
||||
fn protected_markers_with_a_duration_change_the_content_key() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Inner", "width": 8, "height": 8, "frameRate": 30, "duration": 4})).unwrap();
|
||||
let inner = s.active_comp_id().unwrap();
|
||||
s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("comp.new", json!({"name": "Outer", "width": 8, "height": 8, "frameRate": 30, "duration": 10})).unwrap();
|
||||
let outer = s.active_comp_id().unwrap();
|
||||
let l = s.execute("layer.addItem", json!({"item": inner.0})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.select", json!({"layers": [l]})).unwrap();
|
||||
s.execute("layer.timeStretch", json!({"percent": 200})).unwrap();
|
||||
let key = |s: &Session| effectcraft_render::disk_cache::comp_content_key(&s.project, outer);
|
||||
let k0 = key(&s);
|
||||
s.execute("comp.addMarker", json!({"comp": inner.0, "time": 2.0, "comment": "beat"})).unwrap();
|
||||
assert_eq!(key(&s), k0, "a plain marker");
|
||||
s.execute("comp.addMarker", json!({"comp": inner.0, "time": 1.0})).unwrap();
|
||||
let index = s.project.comp(inner).unwrap().markers.iter().position(|m| m.time.seconds() == 1.0).unwrap();
|
||||
s.execute("markers.set", json!({"comp": inner.0, "index": index, "duration": 1.0, "protected": true})).unwrap();
|
||||
let k1 = key(&s);
|
||||
assert_ne!(k1, k0, "a protected marker with a duration");
|
||||
s.execute("markers.set", json!({"comp": inner.0, "index": index, "duration": 2.0})).unwrap();
|
||||
assert_ne!(key(&s), k1, "its duration");
|
||||
}
|
||||
|
||||
@@ -803,6 +803,101 @@ fn applying_an_old_preset_upgrades_its_effects_and_property_paths() {
|
||||
assert_eq!(hue, KV::Scalar(30.0));
|
||||
}
|
||||
|
||||
/// Save the selected effects and properties as a preset, then apply it to `target` at `at`.
|
||||
fn save_and_apply_preset(s: &mut Session, name: &str, selected: Vec<(LayerId, u64)>, target: u64, at: f64) -> serde_json::Value {
|
||||
let path = std::env::temp_dir().join(format!("ec-1971-{name}-{}.ecpreset", std::process::id())).to_string_lossy().to_string();
|
||||
s.state.selected_props = selected;
|
||||
s.execute("anim.savePreset", json!({"path": path})).unwrap();
|
||||
s.execute("time.set", json!({"time": at})).unwrap();
|
||||
let r = s.execute("anim.applyPreset", json!({"path": path, "layers": [target]}));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
r.unwrap()
|
||||
}
|
||||
|
||||
/// The comp times of the keys at `path` on `lid`'s `n`th effect (0-based), or on the layer when
|
||||
/// `n` is `None`.
|
||||
fn key_times(s: &Session, lid: u64, n: Option<usize>, path: &str) -> Vec<f64> {
|
||||
let l = layer(s, lid);
|
||||
let group = match n {
|
||||
Some(n) => l.effects().unwrap().groups().nth(n).unwrap().clone(),
|
||||
None => l.props.clone(),
|
||||
};
|
||||
group.prop(path).unwrap().keys.iter().map(|k| l.comp_time(k.time).seconds()).collect()
|
||||
}
|
||||
|
||||
/// #1971: a saved effect's keys start at the playhead of the layer it's applied to, as a saved
|
||||
/// property's do, nested controls included, an effect without keys still applies, and the whole
|
||||
/// apply is one undo step.
|
||||
#[test]
|
||||
fn preset_effect_keys_start_at_the_playhead() {
|
||||
let (mut s, a, b) = comp_with_two_solids();
|
||||
let blur = apply(&mut s, a, "Gaussian Blur");
|
||||
let fractal = apply(&mut s, a, "Fractal");
|
||||
let slider = apply(&mut s, a, "Slider Control");
|
||||
animate_effect(&mut s, a, blur, "blurriness", &[(5.0, 10.0), (5.5, 30.0), (6.0, 10.0)]);
|
||||
animate_effect(&mut s, a, fractal, "mandelbrot/mandelbrotX", &[(5.5, 1.25), (7.0, 2.5)]);
|
||||
let before = layer(&s, b);
|
||||
let undo = s.history.undo.len();
|
||||
let r = save_and_apply_preset(&mut s, "effects", vec![(LayerId(a), blur), (LayerId(a), fractal), (LayerId(a), slider)], b, 0.5);
|
||||
assert_eq!(r["applied"], 3, "{r}");
|
||||
assert_eq!(key_times(&s, b, Some(0), "blurriness"), vec![0.5, 1.0, 1.5]);
|
||||
assert_eq!(key_times(&s, b, Some(1), "mandelbrot/mandelbrotX"), vec![1.0, 2.5]);
|
||||
assert_eq!(s.history.undo.len(), undo + 1, "the whole apply is one undo step");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(layer(&s, b), before);
|
||||
}
|
||||
|
||||
/// #1971: effects and properties saved together keep their relative timing: the earliest key of
|
||||
/// either starts at the playhead.
|
||||
#[test]
|
||||
fn preset_effects_and_properties_share_one_anchor() {
|
||||
let (mut s, a, b) = comp_with_two_solids();
|
||||
let blur = apply(&mut s, a, "Gaussian Blur");
|
||||
animate_effect(&mut s, a, blur, "blurriness", &[(5.0, 10.0), (6.0, 30.0)]);
|
||||
for (time, value) in [(4.0, -12.0), (7.0, 12.0)] {
|
||||
s.execute("prop.addKey", json!({"layer": a, "path": "transform/rotation", "time": time, "timeBase": "comp", "value": value})).unwrap();
|
||||
}
|
||||
let rotation = layer(&s, a).props.prop("transform/rotation").unwrap().uid;
|
||||
save_and_apply_preset(&mut s, "mixed", vec![(LayerId(a), blur), (LayerId(a), rotation)], b, 1.0);
|
||||
assert_eq!(key_times(&s, b, None, "transform/rotation"), vec![1.0, 4.0]);
|
||||
assert_eq!(key_times(&s, b, Some(0), "blurriness"), vec![2.0, 3.0]);
|
||||
}
|
||||
|
||||
/// #1971: on a stretched layer a saved effect's keys land where a saved property's keys do.
|
||||
#[test]
|
||||
fn preset_effect_keys_follow_a_stretched_layer_like_properties() {
|
||||
let (mut s, a, b) = comp_with_two_solids();
|
||||
let blur = apply(&mut s, a, "Gaussian Blur");
|
||||
animate_effect(&mut s, a, blur, "blurriness", &[(5.0, 10.0), (6.0, 30.0)]);
|
||||
for (time, value) in [(5.0, -12.0), (6.0, 12.0)] {
|
||||
s.execute("prop.addKey", json!({"layer": a, "path": "transform/rotation", "time": time, "timeBase": "comp", "value": value})).unwrap();
|
||||
}
|
||||
let rotation = layer(&s, a).props.prop("transform/rotation").unwrap().uid;
|
||||
set_layer_clock(&mut s, b, 1.0, 200.0);
|
||||
save_and_apply_preset(&mut s, "stretched", vec![(LayerId(a), blur), (LayerId(a), rotation)], b, 2.0);
|
||||
let property = key_times(&s, b, None, "transform/rotation");
|
||||
assert_eq!(property.first(), Some(&2.0), "the property's keys start at the playhead");
|
||||
assert_eq!(key_times(&s, b, Some(0), "blurriness"), property, "and the effect's keys land with them");
|
||||
}
|
||||
|
||||
/// #1971: a preset saved before effect keys were stored relative to the earliest key (version 1)
|
||||
/// has its effects' keys at the source layer's times; they also start at the playhead.
|
||||
#[test]
|
||||
fn old_preset_effect_keys_start_at_the_playhead() {
|
||||
let (mut s, a, b) = comp_with_two_solids();
|
||||
let blur = apply(&mut s, a, "Gaussian Blur");
|
||||
animate_effect(&mut s, a, blur, "blurriness", &[(5.0, 10.0), (6.0, 30.0)]);
|
||||
let old = json!({
|
||||
"effectcraftAnimationPreset": 1,
|
||||
"name": "shake",
|
||||
"properties": [],
|
||||
"effects": [serde_json::to_value(effect_group(&s, a, blur)).unwrap()],
|
||||
});
|
||||
s.execute("time.set", json!({"time": 0.5})).unwrap();
|
||||
s.execute("anim.applyPreset", json!({"preset": old, "layers": [b]})).unwrap();
|
||||
assert_eq!(key_times(&s, b, Some(0), "blurriness"), vec![0.5, 1.5]);
|
||||
}
|
||||
|
||||
/// #2064: `prop.addKey` accepts the `effects/<match name>[#n]/<property>` path that
|
||||
/// `essential.list` reports.
|
||||
#[test]
|
||||
|
||||
@@ -367,6 +367,128 @@ fn media_replacement_swaps_footage_per_instance() {
|
||||
assert!(center(&s, main)[0] > 0.99);
|
||||
}
|
||||
|
||||
/// #1747: a collapsed precomp with an Essential Property override must keep drawing its nested
|
||||
/// layers past the precomp's frame instead of falling back to a flattened, clipped frame.
|
||||
#[test]
|
||||
fn collapsed_precomp_applies_property_overrides_without_clipping() {
|
||||
struct AddY;
|
||||
impl effectcraft_render::ExprHost for AddY {
|
||||
fn eval(
|
||||
&self,
|
||||
_: &effectcraft_render::EvalCtx,
|
||||
_: &effectcraft_project::Layer,
|
||||
_: &effectcraft_project::Property,
|
||||
value: &effectcraft_keyframe::Value,
|
||||
) -> Result<effectcraft_keyframe::Value, String> {
|
||||
Ok(match value {
|
||||
effectcraft_keyframe::Value::Vec3([x, y, z]) => effectcraft_keyframe::Value::Vec3([*x, y + 100.0, *z]),
|
||||
_ => value.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Inner", "width": 400, "height": 300, "frameRate": 30, "duration": 2})).unwrap();
|
||||
let inner = s.active_comp_id().unwrap();
|
||||
let box_ = s.execute("layer.newSolid", json!({"name": "Box", "color": "#ffffff", "width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("prop.set", json!({"layer": box_, "path": "transform/position", "value": [200, 50, 0]})).unwrap();
|
||||
// The source property's expression still runs after the instance override.
|
||||
s.execute("prop.setExpression", json!({"layer": box_, "path": "transform/position", "expression": "value + [0, 100, 0]"})).unwrap();
|
||||
let position = s.execute("essential.addProperty", json!({"layer": box_, "path": "transform/position"})).unwrap()["controls"][0].as_u64().unwrap();
|
||||
s.execute("comp.new", json!({"name": "Outer", "width": 400, "height": 600, "frameRate": 30, "duration": 2})).unwrap();
|
||||
let outer = s.active_comp_id().unwrap();
|
||||
let instance = s.execute("layer.addItem", json!({"item": inner.0})).unwrap()["layer"].as_u64().unwrap();
|
||||
let other = s.execute("layer.addItem", json!({"item": inner.0})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [instance, other], "switch": "collapse", "value": true})).unwrap();
|
||||
s.expr = Some(std::sync::Arc::new(AddY));
|
||||
s.execute("layer.select", json!({"layers": [instance]})).unwrap();
|
||||
s.execute("essential.set", json!({"layer": instance, "control": position, "value": [200, -120, 0]})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [other]})).unwrap();
|
||||
s.execute("essential.set", json!({"layer": other, "control": position, "value": [200, 220, 0]})).unwrap();
|
||||
let img = s.render(outer, Tick::ZERO, RenderOpts::default());
|
||||
// The expression adds 100 px: the overrides give inner y = -20 and 320. The first maps to
|
||||
// outer y = 130 (the precomp is centred at 300) and its 100 px box spans 80..179, 70 px above
|
||||
// the instance's frame (150..449), where a flattened precomp would stop. The second maps to
|
||||
// 470 and spans 420..519, past the bottom.
|
||||
assert!(img.get(200, 100)[3] > 0.99, "the override draws above the nested frame: {:?}", img.get(200, 100));
|
||||
assert!(img.get(200, 90)[3] > 0.99, "the box reaches the top of its overridden bounds");
|
||||
assert!(img.get(200, 500)[3] > 0.99, "the other instance keeps its own override: {:?}", img.get(200, 500));
|
||||
assert!(img.get(200, 200)[3] < 0.01, "nothing is left between the instances");
|
||||
}
|
||||
|
||||
/// #1747: two collapsed instances of the same comp keep their own source-affecting property
|
||||
/// overrides in the layer cache (the nested layers' ids and static values are identical).
|
||||
#[test]
|
||||
fn collapsed_instances_keep_distinct_property_overrides_in_the_cache() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Card", "width": 64, "height": 64, "frameRate": 30, "duration": 2})).unwrap();
|
||||
let card = s.active_comp_id().unwrap();
|
||||
let solid = s.execute("layer.newSolid", json!({"name": "BG", "color": "#ffffff", "width": 64, "height": 64})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("effect.apply", json!({"layer": solid, "effect": "Fill"})).unwrap();
|
||||
let color = s.execute("essential.addProperty", json!({"layer": solid, "path": "effects/#1/color"})).unwrap()["controls"][0].as_u64().unwrap();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 200, "height": 100, "frameRate": 30, "duration": 2})).unwrap();
|
||||
let main = s.active_comp_id().unwrap();
|
||||
let a = s.execute("layer.addItem", json!({"item": card.0})).unwrap()["layer"].as_u64().unwrap();
|
||||
let b = s.execute("layer.addItem", json!({"item": card.0})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [a, b], "switch": "collapse", "value": true})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": a, "path": "transform/position", "value": [60, 50, 0]})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": b, "path": "transform/position", "value": [140, 50, 0]})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [a]})).unwrap();
|
||||
s.execute("essential.set", json!({"layer": a, "control": color, "value": [1.0, 0.0, 0.0, 1.0]})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [b]})).unwrap();
|
||||
s.execute("essential.set", json!({"layer": b, "control": color, "value": [0.0, 0.0, 1.0, 1.0]})).unwrap();
|
||||
let img = s.render(main, Tick::ZERO, RenderOpts::default());
|
||||
let left = img.get(60, 50);
|
||||
let right = img.get(140, 50);
|
||||
assert!(left[0] > 0.99 && left[2] < 0.01, "left instance is red: {left:?}");
|
||||
assert!(right[2] > 0.99 && right[0] < 0.01, "right instance is blue: {right:?}");
|
||||
}
|
||||
|
||||
/// #1747 with Media Replacement: the replacement footage and the position override both apply
|
||||
/// while the precomp stays collapsed, so the box is blue and still draws above the frame.
|
||||
#[test]
|
||||
fn collapsed_precomp_applies_media_replacement_without_clipping() {
|
||||
struct Colors;
|
||||
impl crate::render::FootageSource for Colors {
|
||||
fn frame(&self, _: ItemId, f: &effectcraft_project::Footage, _: Tick) -> Option<std::sync::Arc<effectcraft_raster::Image>> {
|
||||
let c = if f.path.contains("blue") { [0.0, 0.0, 1.0, 1.0] } else { [1.0, 0.0, 0.0, 1.0] };
|
||||
Some(std::sync::Arc::new(effectcraft_raster::Image::filled(100, 100, c)))
|
||||
}
|
||||
}
|
||||
let mut s = Session { footage: std::sync::Arc::new(Colors), ..Default::default() };
|
||||
let footage = |path: &str| effectcraft_project::Footage {
|
||||
path: path.into(),
|
||||
kind: effectcraft_project::FootageKind::Still,
|
||||
width: 100,
|
||||
height: 100,
|
||||
has_video: true,
|
||||
..Default::default()
|
||||
};
|
||||
let (red, blue) = s
|
||||
.edit("test", None, |proj, _| {
|
||||
let red = proj.add_item("red.png", Default::default(), None, effectcraft_project::ItemKind::Footage(footage("red.png")));
|
||||
let blue = proj.add_item("blue.png", Default::default(), None, effectcraft_project::ItemKind::Footage(footage("blue.png")));
|
||||
Ok((red, blue))
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("comp.new", json!({"name": "Inner", "width": 400, "height": 300, "frameRate": 30, "duration": 2})).unwrap();
|
||||
let inner = s.active_comp_id().unwrap();
|
||||
let clip = s.execute("layer.addItem", json!({"item": red.0})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("prop.set", json!({"layer": clip, "path": "transform/position", "value": [200, 50, 0]})).unwrap();
|
||||
let media = s.execute("essential.addMedia", json!({"layer": clip})).unwrap()["control"].as_u64().unwrap();
|
||||
let position = s.execute("essential.addProperty", json!({"layer": clip, "path": "transform/position"})).unwrap()["controls"][0].as_u64().unwrap();
|
||||
s.execute("comp.new", json!({"name": "Outer", "width": 400, "height": 600, "frameRate": 30, "duration": 2})).unwrap();
|
||||
let outer = s.active_comp_id().unwrap();
|
||||
let instance = s.execute("layer.addItem", json!({"item": inner.0})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [instance], "switch": "collapse", "value": true})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [instance]})).unwrap();
|
||||
s.execute("essential.set", json!({"layer": instance, "control": position, "value": [200, -20, 0]})).unwrap();
|
||||
s.execute("essential.set", json!({"layer": instance, "control": media, "item": blue.0})).unwrap();
|
||||
let img = s.render(outer, Tick::ZERO, RenderOpts::default());
|
||||
let above = img.get(200, 100);
|
||||
assert!(above[2] > 0.99 && above[0] < 0.01, "the replacement media is blue above the frame: {above:?}");
|
||||
assert!(img.get(200, 200)[3] < 0.01, "the collapsed box is not clipped at the nested frame");
|
||||
}
|
||||
|
||||
/// Responsive Design — Time: stretching a precomp keeps its protected regions at their
|
||||
/// original speed; the unprotected parts absorb the stretch.
|
||||
#[test]
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use effectcraft_keyframe::Value as KV;
|
||||
use effectcraft_project::essential::{self, ControlType};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{Event, Session};
|
||||
@@ -102,3 +103,33 @@ fn open_in_essential_graphics_sets_the_primary_comp() {
|
||||
);
|
||||
assert_eq!(s.execute("essential.list", json!({})).unwrap()["name"], "A");
|
||||
}
|
||||
|
||||
/// #1747: through a collapsed precomp, a Text override and a Font override of the same Source
|
||||
/// Text both apply: the Font merges into the overridden text, as `essential::with_overrides`
|
||||
/// does on the flattened path, instead of one entry replacing the other.
|
||||
#[test]
|
||||
fn collapsed_instance_merges_text_and_font_overrides() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Title", "width": 64, "height": 64, "frameRate": 30, "duration": 1})).unwrap();
|
||||
let title = s.active_comp_id().unwrap();
|
||||
let text = s.execute("layer.newText", json!({"text": "Hello"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let body = s.execute("essential.addProperty", json!({"layer": text, "path": "text/sourceText"})).unwrap()["controls"][0].as_u64().unwrap();
|
||||
let font = s.execute("essential.addProperty", json!({"layer": text, "path": "text/sourceText", "as": "font"})).unwrap()["controls"][0].as_u64().unwrap();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 64, "frameRate": 30, "duration": 1})).unwrap();
|
||||
let main = s.active_comp_id().unwrap();
|
||||
let inst = s.execute("layer.addItem", json!({"item": title.0})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [inst], "switch": "collapse", "value": true})).unwrap();
|
||||
s.execute("essential.set", json!({"layer": inst, "control": body, "value": "Bye"})).unwrap();
|
||||
s.execute("essential.set", json!({"layer": inst, "control": font, "value": {"font": "Serif Test", "size": 40}})).unwrap();
|
||||
// Evaluate the nested text in the collapsed instance's context, as the renderer does.
|
||||
let r = effectcraft_render::Renderer::new(&s.project, &*s.footage, crate::render::RenderOpts::default());
|
||||
let ctx = effectcraft_render::EvalCtx::new(&s.project, main, s.project.comp(main).unwrap(), Tick::ZERO);
|
||||
let layer = ctx.comp.layer(effectcraft_project::LayerId(inst)).unwrap();
|
||||
let collapsed = r.collapsed(&ctx, layer).expect("the instance collapses");
|
||||
let (_, nctx) = r.collapse_into(&ctx, layer, &collapsed, 1.0).expect("a nested context");
|
||||
let src = nctx.comp.layer(effectcraft_project::LayerId(text)).unwrap();
|
||||
match nctx.value(src, src.props.prop("text/sourceText").unwrap()) {
|
||||
KV::Text(d) => assert_eq!((d.text.as_str(), d.font.as_str(), d.size), ("Bye", "Serif Test", 40.0)),
|
||||
o => panic!("{o:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,3 +164,69 @@ fn copy_frame_hands_the_rendered_frame_to_the_frontend() {
|
||||
assert!(Session::default().execute("comp.copyFrame", json!({})).is_err());
|
||||
assert!(crate::menus::entries().iter().any(|(path, e)| e.command == "comp.copyFrame" && path.first().map(String::as_str) == Some("Composition")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_frame_as_png_preserves_alpha_channel() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "AlphaComp", "width": 32, "height": 32, "frameRate": 24, "duration": 1})).unwrap();
|
||||
let red = s.execute("layer.newSolid", json!({"name": "Red", "color": "#ff0000", "width": 16, "height": 16})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("prop.set", json!({"layer": red, "path": "transform/position", "value": [8, 8, 0]})).unwrap();
|
||||
let dir = out_dir("frame-png-alpha");
|
||||
let path = dir.join("alpha.png").to_string_lossy().to_string();
|
||||
|
||||
s.execute("comp.saveFrameAs", json!({"path": path})).unwrap();
|
||||
let img = image::load_from_memory(&std::fs::read(&path).unwrap()).unwrap().to_rgba8();
|
||||
assert_eq!((img.width(), img.height()), (32, 32));
|
||||
let solid_px = img.get_pixel(8, 8);
|
||||
assert_eq!(solid_px[3], 255, "solid region must be opaque: {solid_px:?}");
|
||||
assert_eq!(solid_px[0], 255);
|
||||
let empty_px = img.get_pixel(24, 24);
|
||||
assert_eq!(empty_px[3], 0, "outside solid region must have alpha 0: {empty_px:?}");
|
||||
|
||||
let flat_path = dir.join("flat.png").to_string_lossy().to_string();
|
||||
s.execute_checked("comp.saveFrameAs", json!({"path": flat_path, "transparent": false})).unwrap();
|
||||
let flat_img = image::load_from_memory(&std::fs::read(&flat_path).unwrap()).unwrap().to_rgba8();
|
||||
let flat_empty_px = flat_img.get_pixel(24, 24);
|
||||
assert_eq!(flat_empty_px[3], 255, "transparent: false must composite over background: {flat_empty_px:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_frame_as_queued_respects_frame_default_template() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Comp", "width": 32, "height": 32, "frameRate": 24, "duration": 1})).unwrap();
|
||||
|
||||
// Default Still template is "PNG Sequence" (Channels::Rgb)
|
||||
let r = s.execute_checked("comp.saveFrameAs", json!({"queue": true})).unwrap();
|
||||
assert_eq!(r["queued"], true);
|
||||
let it = s.project.render_queue.last().unwrap();
|
||||
assert_eq!(it.output.name, "PNG Sequence");
|
||||
assert_eq!(it.output.channels, effectcraft_project::render_queue::Channels::Rgb);
|
||||
|
||||
// Test undo and redo of queuing a frame
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(s.project.render_queue.is_empty());
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
let it = s.project.render_queue.last().unwrap();
|
||||
assert_eq!(it.output.name, "PNG Sequence");
|
||||
|
||||
// Explicit transparent: false forces Channels::Rgb
|
||||
s.execute_checked("comp.saveFrameAs", json!({"queue": true, "transparent": false})).unwrap();
|
||||
let it = s.project.render_queue.last().unwrap();
|
||||
assert_eq!(it.output.channels, effectcraft_project::render_queue::Channels::Rgb);
|
||||
|
||||
// When Frame Default is set to an RGBA template, it is respected
|
||||
s.execute_checked("renderQueue.setTemplateDefault", json!({"kind": "outputModule", "slot": "still", "name": "PNG Sequence with Alpha"})).unwrap();
|
||||
s.execute_checked("comp.saveFrameAs", json!({"queue": true})).unwrap();
|
||||
let it = s.project.render_queue.last().unwrap();
|
||||
assert_eq!(it.output.name, "PNG Sequence with Alpha");
|
||||
assert_eq!(it.output.channels, effectcraft_project::render_queue::Channels::Rgba);
|
||||
|
||||
// Passing transparent: false forces Channels::Rgb even with an RGBA template
|
||||
s.execute_checked("comp.saveFrameAs", json!({"queue": true, "transparent": false})).unwrap();
|
||||
let it = s.project.render_queue.last().unwrap();
|
||||
assert_eq!(it.output.channels, effectcraft_project::render_queue::Channels::Rgb);
|
||||
|
||||
// With JPEG Sequence as Frame Default (non-alpha format), queue succeeds without error
|
||||
s.execute_checked("renderQueue.setTemplateDefault", json!({"kind": "outputModule", "slot": "still", "name": "JPEG Sequence"})).unwrap();
|
||||
assert!(s.execute_checked("comp.saveFrameAs", json!({"queue": true})).is_ok());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
//! Create 3D Layer Instance (Layer ▸ Create): a comp-sized solid at the top of the layer stack
|
||||
//! with a 3D Layer Instance effect referencing the selected 3D model layer — a 2D copy of its
|
||||
//! render that 2D effects can be applied to (behaviour, pixels, undo/redo, serde).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_keyframe::Value as KV;
|
||||
use effectcraft_project::{Footage, FootageKind, GroupKind, ItemId, Layer, LayerId, Project};
|
||||
use effectcraft_render::{FootageSource, RenderOpts};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{Importer, Session};
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session { footage: Arc::new(DiskModels), importer: Some(Arc::new(ModelImporter)), ..Default::default() };
|
||||
s.execute("comp.new", json!({"name": "Scene", "width": 640, "height": 360, "frameRate": 30, "duration": 2})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn cid(s: &Session) -> ItemId {
|
||||
s.active_comp_id().unwrap()
|
||||
}
|
||||
|
||||
fn layer_in(s: &Session, comp: ItemId, id: u64) -> Layer {
|
||||
s.project.comp(comp).unwrap().layer(LayerId(id)).unwrap().clone()
|
||||
}
|
||||
|
||||
fn px(img: &effectcraft_raster::Image, x: u32, y: u32) -> [f32; 4] {
|
||||
img.data[(y * img.width + x) as usize]
|
||||
}
|
||||
|
||||
fn close(a: &effectcraft_raster::Image, b: &effectcraft_raster::Image, tol: f32) -> bool {
|
||||
a.data.iter().zip(b.data.iter()).all(|(p, q)| p.iter().zip(q.iter()).all(|(x, y)| (x - y).abs() <= tol))
|
||||
}
|
||||
|
||||
struct DiskModels;
|
||||
|
||||
impl FootageSource for DiskModels {
|
||||
fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<effectcraft_raster::Image>> {
|
||||
None
|
||||
}
|
||||
fn model(&self, _: ItemId, f: &Footage) -> Option<Arc<effectcraft_model::Model>> {
|
||||
let bytes = std::fs::read(&f.path).ok()?;
|
||||
effectcraft_model::load(&f.path, &bytes, &|_| None).ok().map(Arc::new)
|
||||
}
|
||||
}
|
||||
|
||||
struct ModelImporter;
|
||||
|
||||
impl Importer for ModelImporter {
|
||||
fn probe(&self, path: &str) -> Result<Footage, String> {
|
||||
effectcraft_model::format_of(path).ok_or("not a model")?;
|
||||
Ok(Footage { path: path.into(), kind: FootageKind::Model, frame_rate: FrameRate::FPS_30, codec: "glTF".into(), ..Default::default() })
|
||||
}
|
||||
}
|
||||
|
||||
/// A red triangle in the Z = 0 plane (buffer embedded as a data URI).
|
||||
const TRIANGLE: &str = r#"{
|
||||
"asset": {"version": "2.0"},
|
||||
"scenes": [{"nodes": [0]}],
|
||||
"nodes": [{"mesh": 0}],
|
||||
"meshes": [{"primitives": [{"attributes": {"POSITION": 0, "NORMAL": 1}, "material": 0}]}],
|
||||
"materials": [{"pbrMetallicRoughness": {"baseColorFactor": [1.0, 0.0, 0.0, 1.0]}}],
|
||||
"buffers": [{"byteLength": 72, "uri": "data:application/octet-stream;base64,AACAvwAAgL8AAAAAAACAPwAAgL8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/"}],
|
||||
"bufferViews": [
|
||||
{"buffer": 0, "byteOffset": 0, "byteLength": 36},
|
||||
{"buffer": 0, "byteOffset": 36, "byteLength": 36}
|
||||
],
|
||||
"accessors": [
|
||||
{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3, "min": [-1.0, -1.0, 0.0], "max": [1.0, 1.0, 0.0]},
|
||||
{"bufferView": 1, "componentType": 5126, "type": "VEC3", "count": 3}
|
||||
]
|
||||
}"#;
|
||||
|
||||
fn triangle_model(s: &mut Session) -> u64 {
|
||||
let dir = std::env::temp_dir().join(format!("ec-instance3d-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("Triangle.gltf");
|
||||
std::fs::write(&path, TRIANGLE).unwrap();
|
||||
s.execute("layer.newModel", json!({"path": path.to_string_lossy(), "name": "Triangle"})).unwrap()["layer"].as_u64().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance3d_creates_a_2d_copy_of_the_model() {
|
||||
let mut s = session();
|
||||
let model = triangle_model(&mut s);
|
||||
// The model renders through the comp's camera.
|
||||
let before = s.render(cid(&s), Tick::ZERO, RenderOpts::default());
|
||||
assert!(px(&before, 320, 180)[3] > 0.9, "the model covers the centre: {:?}", px(&before, 320, 180));
|
||||
assert!(px(&before, 4, 4)[3] < 0.1, "the comp is transparent around it: {:?}", px(&before, 4, 4));
|
||||
// Create 3D Layer Instance.
|
||||
s.execute("layer.select", json!({"layers": [model]})).unwrap();
|
||||
let r = s.execute("layer.create", json!({"op": "instance3d"})).unwrap();
|
||||
let solid = r["layers"][0].as_u64().unwrap();
|
||||
let comp = cid(&s);
|
||||
let l = layer_in(&s, comp, solid);
|
||||
assert_eq!(l.name, "Triangle Instance");
|
||||
assert_eq!(s.active_comp().unwrap().layers[0].id.0, solid, "at the top of the layer stack");
|
||||
// A 3D Layer Instance effect referencing the 3D layer.
|
||||
let g = l.effects().unwrap().groups().next().unwrap().clone();
|
||||
assert!(matches!(&g.kind, GroupKind::Effect { effect } if effect == "ec.3d.instance3d"), "{:?}", g.kind);
|
||||
assert_eq!(g.get("layer").unwrap().value, KV::Layer(Some(model)));
|
||||
// The model is switched off, the copy selected.
|
||||
assert!(!layer_in(&s, comp, model).switches.video, "the referenced 3D layer is off, as After Effects does");
|
||||
assert_eq!(s.state.selected_layers, vec![LayerId(solid)]);
|
||||
// The comp renders the same through the copy: a 2D copy of the 3D layer, transparent
|
||||
// around it.
|
||||
let after = s.render(comp, Tick::ZERO, RenderOpts::default());
|
||||
assert!(close(&before, &after, 2e-2), "the copy shows the model:\n{:?}\n{:?}", px(&before, 320, 180), px(&after, 320, 180));
|
||||
assert!(px(&after, 320, 180)[3] > 0.9 && px(&after, 4, 4)[3] < 0.1, "{:?} {:?}", px(&after, 320, 180), px(&after, 4, 4));
|
||||
// Undo removes the copy and switches the model back on; redo returns it.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(s.active_comp().unwrap().layer(LayerId(solid)).is_none());
|
||||
assert!(layer_in(&s, comp, model).switches.video);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert!(s.active_comp().unwrap().layer(LayerId(solid)).is_some());
|
||||
assert!(!layer_in(&s, comp, model).switches.video);
|
||||
// Serde round-trips the layer parameter.
|
||||
let j = serde_json::to_string(&*s.project).unwrap();
|
||||
let back: Project = serde_json::from_str(&j).unwrap();
|
||||
assert_eq!(back, *s.project);
|
||||
// 2D effects can be applied to the copy (the model layer itself takes none): Fill in blue
|
||||
// recolours the triangle.
|
||||
s.execute("effect.apply", json!({"layer": solid, "effect": "ec.generate.fill"})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": solid, "path": "effects/#2/color", "value": [0.0, 0.0, 1.0, 1.0]})).unwrap();
|
||||
let with_fx = s.render(comp, Tick::ZERO, RenderOpts::default());
|
||||
let p = px(&with_fx, 320, 180);
|
||||
assert!(p[2] > 0.9 && p[0] < 0.1, "the fill recolours the copy: {p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance3d_also_takes_3d_primitives() {
|
||||
let mut s = session();
|
||||
let prim = s.execute("layer.new3dPrimitive", json!({"kind": "cube", "width": 200, "height": 200, "depth": 200})).unwrap()["layer"].as_u64().unwrap();
|
||||
let before = s.render(cid(&s), Tick::ZERO, RenderOpts::default());
|
||||
let r = s.execute("layer.create", json!({"op": "instance3d", "layers": [prim]})).unwrap();
|
||||
let solid = r["layers"][0].as_u64().unwrap();
|
||||
let l = layer_in(&s, cid(&s), solid);
|
||||
assert_eq!(l.name, "Cube Instance");
|
||||
assert!(!layer_in(&s, cid(&s), prim).switches.video);
|
||||
let after = s.render(cid(&s), Tick::ZERO, RenderOpts::default());
|
||||
assert!(close(&before, &after, 2e-2), "the copy shows the cube");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance3d_needs_a_3d_layer() {
|
||||
let mut s = session();
|
||||
let solid = s.execute("layer.newSolid", json!({"color": "#808080", "width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap();
|
||||
let err = s.execute_checked("layer.create", json!({"op": "instance3d", "layers": [solid]}));
|
||||
assert!(err.is_err());
|
||||
let text = s.execute("layer.newText", json!({"text": "hi"})).unwrap()["layer"].as_u64().unwrap();
|
||||
assert!(s.execute_checked("layer.create", json!({"op": "instance3d", "layers": [text]})).is_err());
|
||||
// A 3D-switched text layer is not a model layer either (it has 2D pixels of its own).
|
||||
s.execute("layer.setSwitch", json!({"layers": [text], "switch": "threeD", "value": true})).unwrap();
|
||||
assert!(s.execute_checked("layer.create", json!({"op": "instance3d", "layers": [text]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance3d_creates_every_selected_copy_in_one_undo() {
|
||||
let mut s = session();
|
||||
let a = s.execute("layer.new3dPrimitive", json!({"kind": "cube", "width": 120, "height": 120, "depth": 120})).unwrap()["layer"].as_u64().unwrap();
|
||||
let b = s.execute("layer.new3dPrimitive", json!({"kind": "cube", "width": 120, "height": 120, "depth": 120})).unwrap()["layer"].as_u64().unwrap();
|
||||
let r = s.execute("layer.create", json!({"op": "instance3d", "layers": [a, b]})).unwrap();
|
||||
let copies = r["layers"].as_array().unwrap();
|
||||
assert_eq!(copies.len(), 2);
|
||||
let (ia, ib) = (copies[0].as_u64().unwrap(), copies[1].as_u64().unwrap());
|
||||
let comp = cid(&s);
|
||||
assert_ne!(ia, ib);
|
||||
assert_ne!(layer_in(&s, comp, ia).name, layer_in(&s, comp, ib).name, "each copy has a name of its own");
|
||||
// Both sources are off, both copies selected, above their sources in the stack.
|
||||
assert!(!layer_in(&s, comp, a).switches.video && !layer_in(&s, comp, b).switches.video);
|
||||
let mut sel = s.state.selected_layers.clone();
|
||||
let mut want = vec![LayerId(ia), LayerId(ib)];
|
||||
sel.sort_by_key(|l| l.0);
|
||||
want.sort_by_key(|l| l.0);
|
||||
assert_eq!(sel, want);
|
||||
let c = s.active_comp().unwrap();
|
||||
let pos = |id: u64| c.layers.iter().position(|l| l.id.0 == id).unwrap();
|
||||
assert!(pos(ia) < pos(a) && pos(ib) < pos(b), "the copies sit above their sources");
|
||||
// One undo removes both copies and switches both models back on; one redo returns them.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
let c = s.active_comp().unwrap();
|
||||
assert!(c.layer(LayerId(ia)).is_none() && c.layer(LayerId(ib)).is_none(), "one undo removes every instance");
|
||||
assert!(layer_in(&s, comp, a).switches.video && layer_in(&s, comp, b).switches.video);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
let c = s.active_comp().unwrap();
|
||||
assert!(c.layer(LayerId(ia)).is_some() && c.layer(LayerId(ib)).is_some(), "one redo returns every instance");
|
||||
assert!(!layer_in(&s, comp, a).switches.video && !layer_in(&s, comp, b).switches.video);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance3d_in_a_mixed_selection_only_takes_the_models() {
|
||||
let mut s = session();
|
||||
let model = triangle_model(&mut s);
|
||||
let solid = s.execute("layer.newSolid", json!({"color": "#808080", "width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap();
|
||||
let r = s.execute("layer.create", json!({"op": "instance3d", "layers": [model, solid]})).unwrap();
|
||||
let copies = r["layers"].as_array().unwrap();
|
||||
assert_eq!(copies.len(), 1, "only the model gets a copy: {r}");
|
||||
let copy = copies[0].as_u64().unwrap();
|
||||
let comp = cid(&s);
|
||||
assert!(!layer_in(&s, comp, model).switches.video, "the model is switched off");
|
||||
assert!(layer_in(&s, comp, solid).switches.video, "the plain solid is untouched");
|
||||
let skipped = r["skipped"].as_array().unwrap();
|
||||
assert_eq!(skipped.len(), 1);
|
||||
assert!(skipped[0].as_str().unwrap().contains("not a 3D model layer"), "{skipped:?}");
|
||||
// The copy references the model, not the solid.
|
||||
let l = layer_in(&s, comp, copy);
|
||||
let g = l.effects().unwrap().groups().next().unwrap().clone();
|
||||
assert_eq!(g.get("layer").unwrap().value, KV::Layer(Some(model)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance3d_shows_nothing_outside_the_source_layer_range() {
|
||||
let mut s = session();
|
||||
let model = triangle_model(&mut s);
|
||||
// The model's range ends at 1 s; the comp runs to 2 s.
|
||||
let comp = cid(&s);
|
||||
s.edit("trim", None, |p, _| {
|
||||
p.comp_mut(comp).unwrap().layer_mut(LayerId(model)).unwrap().out_point = Tick::from_seconds_f64(1.0);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("layer.select", json!({"layers": [model]})).unwrap();
|
||||
let r = s.execute("layer.create", json!({"op": "instance3d"})).unwrap();
|
||||
let _copy = r["layers"][0].as_u64().unwrap();
|
||||
// Inside the model's range the copy shows it, transparent around it.
|
||||
let inside = s.render(comp, Tick::from_seconds_f64(0.5), RenderOpts::default());
|
||||
assert!(px(&inside, 320, 180)[3] > 0.9, "the copy shows the model: {:?}", px(&inside, 320, 180));
|
||||
// After its out point the source has nothing to read: the copy is transparent.
|
||||
let outside = s.render(comp, Tick::from_seconds_f64(1.5), RenderOpts::default());
|
||||
assert!(outside.data.iter().all(|p| p[3] < 1e-3), "transparent after the source's out point: {:?}", px(&outside, 320, 180));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance3d_keeps_the_copy_visible_when_the_source_is_soloed() {
|
||||
let mut s = session();
|
||||
let model = triangle_model(&mut s);
|
||||
let comp = cid(&s);
|
||||
// Solo the model, then create the instance: any_solo hides non-soloed layers and the
|
||||
// source's video goes off, so the copy has to take the Solo over.
|
||||
s.edit("solo", None, |p, _| {
|
||||
p.comp_mut(comp).unwrap().layer_mut(LayerId(model)).unwrap().switches.solo = true;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let before = s.render(comp, Tick::ZERO, RenderOpts::default());
|
||||
assert!(px(&before, 320, 180)[3] > 0.9, "the soloed model shows: {:?}", px(&before, 320, 180));
|
||||
s.execute("layer.select", json!({"layers": [model]})).unwrap();
|
||||
let r = s.execute("layer.create", json!({"op": "instance3d"})).unwrap();
|
||||
let solid = r["layers"][0].as_u64().unwrap();
|
||||
assert!(layer_in(&s, comp, solid).switches.solo, "the copy takes the source's Solo switch");
|
||||
assert!(!layer_in(&s, comp, model).switches.video, "the source keeps its video off");
|
||||
let after = s.render(comp, Tick::ZERO, RenderOpts::default());
|
||||
assert!(px(&after, 320, 180)[3] > 0.9, "the soloed copy shows the model: {:?}", px(&after, 320, 180));
|
||||
assert!(close(&before, &after, 2e-2), "the comp renders the same through the copy:\n{:?}\n{:?}", px(&before, 320, 180), px(&after, 320, 180));
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
//! Copied keyframes as After Effects keyframe text for other applications (#1908).
|
||||
|
||||
use effectcraft_project::{LayerId, Uid};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{KeyRef, Session};
|
||||
|
||||
/// A 1920×1080 23.976 fps comp with a 200×100 solid.
|
||||
fn session() -> (Session, LayerId) {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 1920, "height": 1080, "frameRate": 23.976, "duration": 10})).unwrap();
|
||||
let id = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 200, "height": 100})).unwrap()["layer"].as_u64().unwrap();
|
||||
(s, LayerId(id))
|
||||
}
|
||||
|
||||
fn frame(s: &Session, n: i64) -> Tick {
|
||||
Tick(s.active_comp().unwrap().frame_rate.frame_duration().0 * n)
|
||||
}
|
||||
|
||||
fn uid(s: &Session, lid: LayerId, path: &str) -> Uid {
|
||||
s.active_comp().unwrap().layer(lid).unwrap().props.prop(path).unwrap().uid
|
||||
}
|
||||
|
||||
fn edit_layer(s: &mut Session, lid: LayerId, f: impl FnOnce(&mut effectcraft_project::Layer)) {
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
f(std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(lid).unwrap());
|
||||
}
|
||||
|
||||
fn add_key(s: &mut Session, lid: LayerId, path: &str, time: Tick, value: serde_json::Value) {
|
||||
s.execute("prop.addKey", json!({"layer": lid.0, "path": path, "time": time.seconds(), "value": value})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copied_keys_are_after_effects_text_in_comp_frames() {
|
||||
let (mut s, l) = session();
|
||||
// The layer starts 10 frames in: its keys at layer frames 2, 5 and 9 are comp frames 12, 15 and 19.
|
||||
let start = frame(&s, 10);
|
||||
edit_layer(&mut s, l, |l| l.start_time = start);
|
||||
for (n, v) in [(2, [960.0, 540.0]), (5, [1000.5, 520.25]), (9, [1100.0, 500.0])] {
|
||||
let t = frame(&s, n);
|
||||
add_key(&mut s, l, "transform/position", t, json!(v));
|
||||
}
|
||||
let t = frame(&s, 5);
|
||||
add_key(&mut s, l, "transform/rotation", t, json!(-45));
|
||||
let (position, rotation) = (uid(&s, l, "transform/position"), uid(&s, l, "transform/rotation"));
|
||||
// A subset: two of the three Position keys and the Rotation key.
|
||||
s.state.selected_keys = [(position, 2), (position, 9), (rotation, 5)].map(|(prop, n)| KeyRef { layer: l, prop, time: frame(&s, n) }).to_vec();
|
||||
s.execute("keys.copy", json!({})).unwrap();
|
||||
assert_eq!(
|
||||
s.state.keyframe_text.as_deref(),
|
||||
Some(
|
||||
"Adobe After Effects 9.0 Keyframe Data\n\n\tUnits Per Second\t23.976\n\tSource Width\t200\n\tSource Height\t100\n\tSource Pixel Aspect Ratio\t1\n\tComp Pixel Aspect Ratio\t1\n\n\
|
||||
Layer\n\
|
||||
Transform\tPosition\n\tFrame\tX pixels\tY pixels\tZ pixels\t\n\t12\t960\t540\t0\t\n\t19\t1100\t500\t0\t\n\n\
|
||||
Transform\tRotation\n\tFrame\tdegrees\t\n\t15\t-45\t\n\n\
|
||||
\n\
|
||||
End of Keyframe Data\n"
|
||||
)
|
||||
);
|
||||
// The internal clipboard still has the keys themselves, relative to the earliest.
|
||||
let times: Vec<Vec<Tick>> = s.state.key_clipboard.iter().map(|c| c.keys.iter().map(|k| k.time).collect()).collect();
|
||||
assert_eq!(times, [vec![Tick::ZERO, frame(&s, 7)], vec![frame(&s, 3)]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stretched_layer_writes_the_frames_its_keys_show_at() {
|
||||
let (mut s, l) = session();
|
||||
edit_layer(&mut s, l, |l| l.stretch = 200.0);
|
||||
let t = frame(&s, 6);
|
||||
add_key(&mut s, l, "transform/opacity", Tick::ZERO, json!(0));
|
||||
add_key(&mut s, l, "transform/opacity", t, json!(100));
|
||||
s.execute("prop.select", json!({"layer": l.0, "path": "transform/opacity"})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
let text = s.state.keyframe_text.clone().unwrap();
|
||||
assert!(text.contains("Transform\tOpacity\n\tFrame\tpercent\t\n\t0\t0\t\n\t12\t100\t\n\n"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copied_static_values_have_no_frame() {
|
||||
let (mut s, l) = session();
|
||||
s.execute("prop.set", json!({"layer": l.0, "path": "transform/position", "value": [100, 200]})).unwrap();
|
||||
s.execute("prop.select", json!({"layer": l.0, "path": "transform/position"})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
let text = s.state.keyframe_text.clone().unwrap();
|
||||
assert!(text.contains("Transform\tPosition\n\tFrame\tX pixels\tY pixels\tZ pixels\t\n\t\t100\t200\t0\t\n\n"), "{text}");
|
||||
assert_eq!(s.state.value_clipboard.len(), 1, "the internal clipboard keeps the value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copied_transform_group_lists_what_the_layer_shows() {
|
||||
let (mut s, l) = session();
|
||||
let t = frame(&s, 24);
|
||||
add_key(&mut s, l, "transform/scale", Tick::ZERO, json!([50, 50, 100]));
|
||||
add_key(&mut s, l, "transform/scale", t, json!([150, 150, 100]));
|
||||
let transform = s.active_comp().unwrap().layer(l).unwrap().props.sub("transform").unwrap().uid;
|
||||
s.execute("prop.select", json!({"layer": l.0, "prop": transform, "selectKeys": false})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
let text = s.state.keyframe_text.clone().unwrap();
|
||||
let names: Vec<&str> = text.lines().filter_map(|line| line.strip_prefix("Transform\t")).collect();
|
||||
assert_eq!(names, ["Anchor Point", "Position", "Scale", "Rotation", "Opacity"], "a 2D layer has no Orientation or X / Y Rotation");
|
||||
assert!(text.contains("\tFrame\tX percent\tY percent\tZ percent\t\n\t0\t50\t50\t100\t\n\t24\t150\t150\t100\t\n"), "{text}");
|
||||
// 3D: Orientation and X / Y Rotation. Z Rotation stays Rotation, and Orientation's three
|
||||
// values sit under one label, as After Effects writes them.
|
||||
edit_layer(&mut s, l, |l| l.switches.three_d = true);
|
||||
s.execute("prop.select", json!({"layer": l.0, "prop": transform, "selectKeys": false})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
let text = s.state.keyframe_text.clone().unwrap();
|
||||
let names: Vec<&str> = text.lines().filter_map(|line| line.strip_prefix("Transform\t")).collect();
|
||||
assert_eq!(names, ["Anchor Point", "Position", "Scale", "Orientation", "X Rotation", "Y Rotation", "Rotation", "Opacity"]);
|
||||
assert!(text.contains("Transform\tOrientation\n\tFrame\tX degrees\t\n\t\t0\t0\t0\t\n"), "{text}");
|
||||
}
|
||||
|
||||
fn copy_transform(s: &mut Session, lid: LayerId) -> String {
|
||||
let transform = s.active_comp().unwrap().layer(lid).unwrap().props.sub("transform").unwrap().uid;
|
||||
s.execute("prop.select", json!({"layer": lid.0, "prop": transform, "selectKeys": false})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
s.state.keyframe_text.clone().unwrap()
|
||||
}
|
||||
|
||||
fn names(text: &str) -> Vec<&str> {
|
||||
text.lines().filter_map(|line| line.strip_prefix("Transform\t")).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn properties_the_layer_does_not_use_are_left_out() {
|
||||
// Separate Dimensions: the old Position no longer moves the layer.
|
||||
let (mut s, l) = session();
|
||||
s.execute("prop.set", json!({"layer": l.0, "path": "transform/position", "value": [100, 200]})).unwrap();
|
||||
s.execute("prop.separateDimensions", json!({"layer": l.0, "value": true})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": l.0, "path": "transform/positionX", "value": 321})).unwrap();
|
||||
let text = copy_transform(&mut s, l);
|
||||
assert!(!names(&text).contains(&"Position"), "{text}");
|
||||
// A one-node camera has no Point of Interest; a two-node one does.
|
||||
let cam = LayerId(s.execute("layer.newCamera", json!({"type": "oneNode"})).unwrap()["layer"].as_u64().unwrap());
|
||||
let text = copy_transform(&mut s, cam);
|
||||
assert!(!names(&text).contains(&"Point of Interest"), "{text}");
|
||||
s.execute("layer.cameraSettings", json!({"layer": cam.0, "type": "twoNode"})).unwrap();
|
||||
let text = copy_transform(&mut s, cam);
|
||||
assert!(names(&text).contains(&"Point of Interest"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separated_position_writes_x_y_and_z_position() {
|
||||
let (mut s, l) = session();
|
||||
s.execute("prop.separateDimensions", json!({"layer": l.0, "value": true})).unwrap();
|
||||
let t = frame(&s, 12);
|
||||
add_key(&mut s, l, "transform/positionX", Tick::ZERO, json!(100));
|
||||
add_key(&mut s, l, "transform/positionX", t, json!(321));
|
||||
s.execute("prop.set", json!({"layer": l.0, "path": "transform/positionY", "value": 200})).unwrap();
|
||||
let text = copy_transform(&mut s, l);
|
||||
// After Effects labels their single column with no unit.
|
||||
assert!(text.contains("Transform\tX Position\n\tFrame\t\t\n\t0\t100\t\n\t12\t321\t\n\nTransform\tY Position\n\tFrame\t\t\n\t\t200\t\n\n"), "{text}");
|
||||
assert!(!names(&text).contains(&"Z Position"), "a 2D layer has no Z Position: {text}");
|
||||
edit_layer(&mut s, l, |l| l.switches.three_d = true);
|
||||
let text = copy_transform(&mut s, l);
|
||||
assert!(text.contains("Transform\tZ Position\n\tFrame\t\t\n\t\t0\t\n"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_remap_writes_source_seconds_at_comp_frames() {
|
||||
let (mut s, l) = session();
|
||||
let r = s.execute("layer.precompose", json!({"layers": [l.0], "name": "Inner"})).unwrap();
|
||||
let l = LayerId(r["layer"].as_u64().unwrap());
|
||||
s.execute("layer.select", json!({"layers": [l.0]})).unwrap();
|
||||
s.execute("layer.enableTimeRemap", json!({})).unwrap();
|
||||
let start = frame(&s, 10);
|
||||
edit_layer(&mut s, l, |l| l.start_time = start);
|
||||
let t = frame(&s, 6);
|
||||
add_key(&mut s, l, "timeRemap", t, json!(2.5));
|
||||
s.execute("prop.select", json!({"layer": l.0, "path": "timeRemap"})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
let text = s.state.keyframe_text.clone().unwrap();
|
||||
// As After Effects writes it: the key at layer frame 6 of a layer starting at frame 10 is at 16.
|
||||
assert!(text.contains("Layer\nTime Remap\n\tFrame\tseconds\t\n\t10\t0\t\n\t16\t2.5\t\n"), "{text}");
|
||||
}
|
||||
|
||||
/// Selects the layer's properties at `paths` and copies them.
|
||||
fn copy_props(s: &mut Session, lid: LayerId, paths: &[&str]) -> String {
|
||||
for (i, path) in paths.iter().enumerate() {
|
||||
s.execute("prop.select", json!({"layer": lid.0, "path": path, "add": i > 0, "selectKeys": false})).unwrap();
|
||||
}
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
s.state.keyframe_text.clone().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effect_properties_are_written_as_after_effects_does() {
|
||||
// The blocks After Effects 2019 wrote for the same effects and values (2019 has no Dropdown
|
||||
// Menu Control: that block follows its other popups, written 1-based with no unit).
|
||||
let (mut s, l) = session();
|
||||
for fx in ["Fill", "Checkbox Control", "Dropdown Menu Control", "Slider Control", "Point Control"] {
|
||||
s.execute("effect.apply", json!({"layer": l.0, "effect": fx})).unwrap();
|
||||
}
|
||||
let t = frame(&s, 12);
|
||||
add_key(&mut s, l, "effects/#1/color", Tick::ZERO, json!([1, 0.5, 0.25, 1]));
|
||||
add_key(&mut s, l, "effects/#1/color", t, json!([0, 0, 1, 1]));
|
||||
add_key(&mut s, l, "effects/#2/checkbox", Tick::ZERO, json!(true));
|
||||
add_key(&mut s, l, "effects/#2/checkbox", t, json!(false));
|
||||
s.execute("prop.set", json!({"layer": l.0, "path": "effects/#3/menu", "value": 1})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": l.0, "path": "effects/#4/slider", "value": 20.5})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": l.0, "path": "effects/#5/point", "value": [10, 20]})).unwrap();
|
||||
// After Effects writes a renamed effect under the effect's own name.
|
||||
let slider = s.active_comp().unwrap().layer(l).unwrap().props.group("effects/#4").unwrap().uid;
|
||||
s.execute("prop.renameGroup", json!({"layer": l.0, "prop": slider, "name": "My Slider"})).unwrap();
|
||||
let text = copy_props(&mut s, l, &["effects/#1/color", "effects/#2/checkbox", "effects/#3/menu", "effects/#4/slider", "effects/#5/point"]);
|
||||
assert!(
|
||||
text.contains(
|
||||
"Layer\n\
|
||||
Effects\tFill #1\tColor #4\n\tFrame\talpha \tred \tgreen \tblue \t\n\t0\t255\t255\t127.5\t63.75\t\n\t12\t255\t0\t0\t255\t\n\n\
|
||||
Effects\tCheckbox Control #2\tCheckbox #2\n\tFrame\t\t\n\t0\t1\t\n\t12\t0\t\n\n\
|
||||
Effects\tDropdown Menu Control #3\tMenu #2\n\tFrame\t\t\n\t\t2\t\n\n\
|
||||
Effects\tSlider Control #4\tSlider #2\n\tFrame\t\t\n\t\t20.5\t\n\n\
|
||||
Effects\tPoint Control #5\tPoint #2\n\tFrame\tX pixels\tY pixels\t\n\t\t10\t20\t\n\n"
|
||||
),
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effect_properties_have_after_effects_indices() {
|
||||
// After Effects 2019 numbered Fractal Noise's Rotation (in the Transform group) #9,
|
||||
// Complexity (after it) #17, Evolution #25, and Opacity (after Evolution Options) #31: a
|
||||
// group takes an index where it opens and closes, and its properties are written without
|
||||
// it. Set Matte's Use For Matte is #3: the layer parameter before it has no extra index for
|
||||
// its source choice.
|
||||
let (mut s, l) = session();
|
||||
for fx in ["Fractal Noise", "Set Matte"] {
|
||||
s.execute("effect.apply", json!({"layer": l.0, "effect": fx})).unwrap();
|
||||
}
|
||||
let text = copy_props(
|
||||
&mut s,
|
||||
l,
|
||||
&[
|
||||
"effects/#1/transform/rotation",
|
||||
"effects/#1/complexity",
|
||||
"effects/#1/evolution",
|
||||
"effects/#1/opacity",
|
||||
"effects/#1/blendingMode",
|
||||
"effects/#2/takeMatteFrom",
|
||||
],
|
||||
);
|
||||
for header in [
|
||||
"Effects\tFractal Noise #1\tRotation #9\n",
|
||||
"Effects\tFractal Noise #1\tComplexity #17\n",
|
||||
"Effects\tFractal Noise #1\tEvolution #25\n",
|
||||
"Effects\tFractal Noise #1\tOpacity #31\n",
|
||||
"Effects\tFractal Noise #1\tBlending Mode #32\n",
|
||||
"Effects\tSet Matte #2\tUse For Matte #3\n",
|
||||
] {
|
||||
assert!(text.contains(header), "{header}in\n{text}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effect_parameters_after_effects_numbers_or_labels_otherwise_follow_it() {
|
||||
// After Effects 2019: Radial Blur counts a hidden parameter before Amount, which has no
|
||||
// unit; 3D Channel Extract lists Anti-alias before Clamp Output; Selective Color's per-color
|
||||
// sliders start at #10 and are percent.
|
||||
let (mut s, l) = session();
|
||||
for fx in ["Radial Blur", "3D Channel Extract", "Selective Color"] {
|
||||
s.execute("effect.apply", json!({"layer": l.0, "effect": fx})).unwrap();
|
||||
}
|
||||
let text = copy_props(
|
||||
&mut s,
|
||||
l,
|
||||
&["effects/#1/amount", "effects/#1/center", "effects/#2/antialias", "effects/#2/invertDepthMap", "effects/#3/details/reds/redsCyan"],
|
||||
);
|
||||
for block in [
|
||||
"Effects\tRadial Blur #1\tAmount #3\n\tFrame\t\t\n",
|
||||
"Effects\tRadial Blur #1\tCenter #4\n\tFrame\tX pixels\tY pixels\t\n",
|
||||
"Effects\t3D Channel Extract #2\tAnti-alias #5\n\tFrame\t\t\n",
|
||||
"Effects\t3D Channel Extract #2\tInvert Depth Map #7\n\tFrame\t\t\n",
|
||||
"Effects\tSelective Color #3\tReds Cyan #10\n\tFrame\tpercent\t\n",
|
||||
] {
|
||||
assert!(text.contains(block), "{block}in\n{text}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_properties_are_written_as_after_effects_does() {
|
||||
// The blocks After Effects 2019 wrote for the same masks and values: masks go by place, not
|
||||
// name, and Mask Path is left out.
|
||||
let (mut s, l) = session();
|
||||
for _ in 0..2 {
|
||||
s.execute("mask.new", json!({"layer": l.0, "vertices": [[0, 0], [100, 0], [100, 100]], "closed": true})).unwrap();
|
||||
}
|
||||
let t = frame(&s, 12);
|
||||
for (prop, a, b) in [("feather", json!([5, 5]), json!([10, 20])), ("opacity", json!(50), json!(75)), ("expansion", json!(3), json!(-4))] {
|
||||
add_key(&mut s, l, &format!("masks/#1/{prop}"), Tick::ZERO, a);
|
||||
add_key(&mut s, l, &format!("masks/#1/{prop}"), t, b);
|
||||
}
|
||||
s.execute("prop.set", json!({"layer": l.0, "path": "masks/#2/feather", "value": [7, 8]})).unwrap();
|
||||
let second = s.active_comp().unwrap().layer(l).unwrap().props.group("masks/#2").unwrap().uid;
|
||||
s.execute("prop.renameGroup", json!({"layer": l.0, "prop": second, "name": "Renamed Mask"})).unwrap();
|
||||
let text = copy_props(&mut s, l, &["masks/#1/path", "masks/#1/feather", "masks/#1/opacity", "masks/#1/expansion", "masks/#2/feather"]);
|
||||
assert!(
|
||||
text.contains(
|
||||
"Layer\n\
|
||||
Masks\tMask #1\tMask Feather\n\tFrame\tX pixels\tY pixels\t\n\t0\t5\t5\t\n\t12\t10\t20\t\n\n\
|
||||
Masks\tMask #1\tMask Opacity\n\tFrame\tpercent\t\n\t0\t50\t\n\t12\t75\t\n\n\
|
||||
Masks\tMask #1\tMask Expansion\n\tFrame\tpixels\t\n\t0\t3\t\n\t12\t-4\t\n\n\
|
||||
Masks\tMask #2\tMask Feather\n\tFrame\tX pixels\tY pixels\t\n\t\t7\t8\t\n\n\
|
||||
\nEnd of Keyframe Data\n"
|
||||
),
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_properties_are_written_as_after_effects_does() {
|
||||
// The blocks After Effects 2019 wrote for the same text properties and values.
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"width": 1920, "height": 1080, "frameRate": 23.976})).unwrap();
|
||||
let l = LayerId(s.execute("layer.newText", json!({"text": "Hello"})).unwrap()["layer"].as_u64().unwrap());
|
||||
s.execute("layer.addTextAnimator", json!({"properties": ["position", "fillColor"]})).unwrap();
|
||||
s.execute("layer.addTextAnimator", json!({"properties": ["opacity"]})).unwrap();
|
||||
let second = s.active_comp().unwrap().layer(l).unwrap().props.group("text/animators/#2").unwrap().uid;
|
||||
s.execute("prop.renameGroup", json!({"layer": l.0, "prop": second, "name": "Fade"})).unwrap();
|
||||
let t = frame(&s, 12);
|
||||
add_key(&mut s, l, "text/sourceText", Tick::ZERO, json!("Hello"));
|
||||
add_key(&mut s, l, "text/pathOptions/firstMargin", Tick::ZERO, json!(0));
|
||||
add_key(&mut s, l, "text/pathOptions/firstMargin", t, json!(50));
|
||||
add_key(&mut s, l, "text/animators/#1/selectors/#1/start", Tick::ZERO, json!(0));
|
||||
add_key(&mut s, l, "text/animators/#1/selectors/#1/start", t, json!(100));
|
||||
add_key(&mut s, l, "text/animators/#1/properties/position", Tick::ZERO, json!([0, 0, 0]));
|
||||
add_key(&mut s, l, "text/animators/#1/properties/position", t, json!([10, -20, 0]));
|
||||
for (path, v) in [
|
||||
("text/animators/#1/selectors/#1/end", json!(80)),
|
||||
("text/animators/#1/selectors/#1/offset", json!(-10)),
|
||||
("text/animators/#1/properties/fillColor", json!([1, 0, 0, 1])),
|
||||
("text/moreOptions/groupingAlignment", json!([10, -20])),
|
||||
] {
|
||||
s.execute("prop.set", json!({"layer": l.0, "path": path, "value": v})).unwrap();
|
||||
}
|
||||
let text = copy_props(
|
||||
&mut s,
|
||||
l,
|
||||
&[
|
||||
"text/sourceText",
|
||||
"text/pathOptions/firstMargin",
|
||||
"text/animators/#1/selectors/#1/start",
|
||||
"text/animators/#1/selectors/#1/end",
|
||||
"text/animators/#1/selectors/#1/offset",
|
||||
"text/animators/#1/selectors/#1/advanced/amount",
|
||||
"text/animators/#1/properties/position",
|
||||
"text/animators/#1/properties/fillColor",
|
||||
"text/animators/#2/properties/opacity",
|
||||
"text/moreOptions/groupingAlignment",
|
||||
],
|
||||
);
|
||||
for block in [
|
||||
"Text\tPath Options\tFirst Margin\n\tFrame\t\t\n\t0\t0\t\n\t12\t50\t\n\n",
|
||||
"Text\tAnimators\tAnimator #1\tSelectors\tRange Selector #1\tStart #1\n\tFrame\tpercent\t\n\t0\t0\t\n\t12\t100\t\n\n",
|
||||
"Text\tAnimators\tAnimator #1\tSelectors\tRange Selector #1\tEnd #2\n\tFrame\tpercent\t\n\t\t80\t\n\n",
|
||||
"Text\tAnimators\tAnimator #1\tSelectors\tRange Selector #1\tOffset #3\n\tFrame\tpercent\t\n\t\t-10\t\n\n",
|
||||
"Text\tAnimators\tAnimator #1\tProperties\tPosition\n\tFrame\tX pixels\tY pixels\tZ pixels\t\n\t0\t0\t0\t0\t\n\t12\t10\t-20\t0\t\n\n",
|
||||
"Text\tAnimators\tAnimator #1\tProperties\tFill Color\n\tFrame\talpha \tred \tgreen \tblue \t\n\t\t255\t255\t0\t0\t\n\n",
|
||||
"Text\tAnimators\tAnimator #2\tProperties\tOpacity\n\tFrame\tpercent\t\n\t\t100\t\n\n",
|
||||
"Text\tAnimators\tAnimator #1\tSelectors\tRange Selector #1\tAdvanced\tAmount\n\tFrame\tpercent\t\n\t\t100\t\n\n",
|
||||
"Text\tMore Options\tGrouping Alignment\n\tFrame\tX percent\tY percent\t\n\t\t10\t-20\t\n\n",
|
||||
] {
|
||||
assert!(text.contains(block), "{block}\nin\n{text}");
|
||||
}
|
||||
assert!(!text.contains("Source Text"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_2d_layer_writes_the_z_it_shows() {
|
||||
// Keys pasted from a 3D layer keep their Z on a 2D one, which ignores it.
|
||||
let (mut s, l) = session();
|
||||
let src = LayerId(s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 200, "height": 100})).unwrap()["layer"].as_u64().unwrap());
|
||||
edit_layer(&mut s, src, |l| l.switches.three_d = true);
|
||||
add_key(&mut s, src, "transform/position", Tick::ZERO, json!([100, 200, 300]));
|
||||
add_key(&mut s, src, "transform/scale", Tick::ZERO, json!([50, 60, 70]));
|
||||
s.state.selected_keys = ["transform/position", "transform/scale"].map(|p| KeyRef { layer: src, prop: uid(&s, src, p), time: Tick::ZERO }).to_vec();
|
||||
s.execute("keys.copy", json!({})).unwrap();
|
||||
s.execute("keys.paste", json!({"layers": [l.0], "time": 0})).unwrap();
|
||||
let pasted = s.active_comp().unwrap().layer(l).unwrap().props.prop("transform/position").unwrap().keys[0].value.components();
|
||||
assert_eq!(pasted, [100.0, 200.0, 300.0], "the 2D layer keeps the pasted Z");
|
||||
s.state.selected_keys = ["transform/position", "transform/scale"].map(|p| KeyRef { layer: l, prop: uid(&s, l, p), time: Tick::ZERO }).to_vec();
|
||||
s.execute("keys.copy", json!({})).unwrap();
|
||||
let text = s.state.keyframe_text.clone().unwrap();
|
||||
assert!(text.contains("Transform\tPosition\n\tFrame\tX pixels\tY pixels\tZ pixels\t\n\t0\t100\t200\t0\t\n"), "{text}");
|
||||
assert!(text.contains("Transform\tScale\n\tFrame\tX percent\tY percent\tZ percent\t\n\t0\t50\t60\t100\t\n"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_header_has_the_source_pixel_aspect() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"width": 1920, "height": 1080, "frameRate": 24, "pixelAspect": 1.2})).unwrap();
|
||||
let solid = LayerId(s.execute("layer.newSolid", json!({"width": 200, "height": 100, "pixelAspect": 1.5})).unwrap()["layer"].as_u64().unwrap());
|
||||
let text = copy_transform(&mut s, solid);
|
||||
assert!(text.contains("\tSource Pixel Aspect Ratio\t1.5\n\tComp Pixel Aspect Ratio\t1.2\n"), "{text}");
|
||||
// A text layer: 1 × 1, as After Effects 2019 wrote it, with the comp's pixel aspect.
|
||||
let t = LayerId(s.execute("layer.newText", json!({"text": "Hi"})).unwrap()["layer"].as_u64().unwrap());
|
||||
let text = copy_transform(&mut s, t);
|
||||
assert!(text.contains("\tSource Width\t1\n\tSource Height\t1\n\tSource Pixel Aspect Ratio\t1.2\n"), "{text}");
|
||||
// A shape layer has no source of its own: the comp's size.
|
||||
let shape = LayerId(s.execute("layer.newShape", json!({})).unwrap()["layer"].as_u64().unwrap());
|
||||
let text = copy_transform(&mut s, shape);
|
||||
assert!(text.contains("\tSource Width\t1920\n\tSource Height\t1080\n"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_from_two_layers_have_no_text() {
|
||||
let (mut s, a) = session();
|
||||
let b = LayerId(s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 200, "height": 100})).unwrap()["layer"].as_u64().unwrap());
|
||||
for l in [a, b] {
|
||||
add_key(&mut s, l, "transform/opacity", Tick::ZERO, json!(50));
|
||||
}
|
||||
s.state.selected_keys = [a, b].map(|l| KeyRef { layer: l, prop: uid(&s, l, "transform/opacity"), time: Tick::ZERO }).to_vec();
|
||||
s.execute("keys.copy", json!({})).unwrap();
|
||||
assert_eq!(s.state.keyframe_text, None, "After Effects copies one layer's keys as text");
|
||||
assert_eq!(s.state.key_clipboard.len(), 2, "EffectCraft's own clipboard has both");
|
||||
// A copied layer leaves no text behind.
|
||||
s.state.selected_keys = vec![KeyRef { layer: a, prop: uid(&s, a, "transform/opacity"), time: Tick::ZERO }];
|
||||
s.execute("keys.copy", json!({})).unwrap();
|
||||
assert!(s.state.keyframe_text.is_some());
|
||||
s.state.selected_keys.clear();
|
||||
s.execute("edit.copy", json!({"layers": [a.0]})).unwrap();
|
||||
assert_eq!(s.state.keyframe_text, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_contents_are_written_as_after_effects_does() {
|
||||
// Headers and labels After Effects 2019 wrote for the same shape items: items go by kind and
|
||||
// place in their group, and a Size has no unit (`X `).
|
||||
let (mut s, _) = session();
|
||||
let sh = LayerId(s.execute("layer.newShape", json!({"kind": "none"})).unwrap()["layer"].as_u64().unwrap());
|
||||
for (kind, group) in [("group", None), ("rectangle", Some("contents/#1")), ("fill", Some("contents/#1")), ("star", None), ("repeater", None)] {
|
||||
let mut p = json!({"layer": sh.0, "kind": kind});
|
||||
if let Some(g) = group {
|
||||
p["group"] = json!(g);
|
||||
}
|
||||
s.execute("layer.addShapeItem", p).unwrap();
|
||||
}
|
||||
let text = copy_props(
|
||||
&mut s,
|
||||
sh,
|
||||
&[
|
||||
"contents/#1/contents/#1/size",
|
||||
"contents/#1/contents/#2/color",
|
||||
"contents/#1/transform/position",
|
||||
"contents/#2/points",
|
||||
"contents/#3/transform/startOpacity",
|
||||
],
|
||||
);
|
||||
for block in [
|
||||
"Contents\tGroup #1\tContents\tRectangle Path #1\tSize\n\tFrame\tX \tY \t\n",
|
||||
"Contents\tGroup #1\tContents\tFill #2\tColor\n\tFrame\talpha \tred \tgreen \tblue \t\n",
|
||||
"Contents\tGroup #1\tTransform\tPosition\n\tFrame\tX pixels\tY pixels\t\n",
|
||||
"Contents\tPolystar Path #2\tPoints\n\tFrame\t\t\n",
|
||||
"Contents\tRepeater #3\tTransform\tStart Opacity\n\tFrame\tpercent\t\n",
|
||||
] {
|
||||
assert!(text.contains(block), "{block}in\n{text}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn camera_light_and_material_options_are_written_as_after_effects_does() {
|
||||
// Blocks After Effects 2019 wrote for these options, defaults included: Zoom and Shadow
|
||||
// Diffusion in pixels, Highlight Threshold on 0–1, and the material's shadow choices from 0.
|
||||
let (mut s, l) = session();
|
||||
let cam = LayerId(s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap());
|
||||
let text = copy_props(&mut s, cam, &["cameraOptions/zoom", "cameraOptions/highlightThreshold"]);
|
||||
assert!(text.contains("Camera Options\tZoom\n\tFrame\tpixels\t\n"), "{text}");
|
||||
assert!(text.contains("Camera Options\tHighlight Threshold\n\tFrame\t\t\n\t\t1\t\n"), "{text}");
|
||||
let light = LayerId(s.execute("layer.newLight", json!({"type": "spot"})).unwrap()["layer"].as_u64().unwrap());
|
||||
let text = copy_props(&mut s, light, &["lightOptions/intensity", "lightOptions/shadowDiffusion"]);
|
||||
assert!(text.contains("Light Options\tIntensity\n\tFrame\tpercent\t\n\t\t100\t\n"), "{text}");
|
||||
assert!(text.contains("Light Options\tShadow Diffusion\n\tFrame\tpixels\t\n\t\t0\t\n"), "{text}");
|
||||
edit_layer(&mut s, l, |l| l.switches.three_d = true);
|
||||
let text = copy_props(&mut s, l, &["materialOptions/castsShadows", "materialOptions/acceptsShadows", "materialOptions/diffuse"]);
|
||||
for block in [
|
||||
"Material Options\tCasts Shadows\n\tFrame\t\t\n\t\t0\t\n",
|
||||
"Material Options\tAccepts Shadows\n\tFrame\t\t\n\t\t1\t\n",
|
||||
"Material Options\tDiffuse\n\tFrame\tpercent\t\n\t\t50\t\n",
|
||||
] {
|
||||
assert!(text.contains(block), "{block}in\n{text}");
|
||||
}
|
||||
}
|
||||
@@ -1103,6 +1103,66 @@ fn transform_commands_keep_depth_and_follow_separated_dimensions() {
|
||||
assert_eq!(pos(&s, b)[..2], [200.0, 100.0], "the 200×100 solid's centre");
|
||||
}
|
||||
|
||||
/// The layer's painted bounds mapped to comp space — what the viewer shows.
|
||||
fn seen_bounds(s: &Session, lid: u64) -> [f64; 4] {
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let comp = s.project.comp(cid).unwrap().clone();
|
||||
let layer = comp.layer(effectcraft_project::LayerId(lid)).unwrap();
|
||||
let ectx = effectcraft_render::EvalCtx::new(&s.project, cid, &comp, s.time());
|
||||
let b = effectcraft_render::content_bounds(&ectx, layer).expect("painted bounds");
|
||||
let m = ectx.layer_to_comp(layer).0;
|
||||
let corners = [[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]]];
|
||||
let pts: Vec<[f64; 2]> = corners
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let q = m.apply(effectcraft_geom::vec2(p[0], p[1]));
|
||||
[q.x, q.y]
|
||||
})
|
||||
.collect();
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
|
||||
for p in &pts {
|
||||
x0 = x0.min(p[0]);
|
||||
y0 = y0.min(p[1]);
|
||||
x1 = x1.max(p[0]);
|
||||
y1 = y1.max(p[1]);
|
||||
}
|
||||
[x0, y0, x1, y1]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_to_comp_fills_the_comp_with_shape_and_text_bounds() {
|
||||
let mut s = comp();
|
||||
let close = |x: f64, y: f64| (x - y).abs() < 1e-6;
|
||||
// A shape or text layer has no source item: fit sizes it by its painted bounds, anchored at
|
||||
// the bounds' centre, so what the viewer shows fills the frame (#2198).
|
||||
let a = s.execute("layer.newShape", json!({"kind": "rect", "size": [180, 80]})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.transform", json!({"layers": [a], "op": "fit"})).unwrap();
|
||||
let seen = seen_bounds(&s, a);
|
||||
assert!(close(seen[0], 0.0) && close(seen[1], 0.0) && close(seen[2], 640.0) && close(seen[3], 360.0), "the rect's painted bounds fill the comp: {seen:?}");
|
||||
|
||||
let t = s.execute("layer.newText", json!({"text": "Title", "size": 80})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.transform", json!({"layers": [t], "op": "fit"})).unwrap();
|
||||
let seen = seen_bounds(&s, t);
|
||||
assert!(close(seen[0], 0.0) && close(seen[1], 0.0) && close(seen[2], 640.0) && close(seen[3], 360.0), "the text's painted bounds fill the comp: {seen:?}");
|
||||
|
||||
// fitWidth spans the comp's width and centres vertically.
|
||||
for lid in [a, t] {
|
||||
s.execute("layer.transform", json!({"layers": [lid], "op": "fitWidth"})).unwrap();
|
||||
let seen = seen_bounds(&s, lid);
|
||||
assert!(close(seen[0], 0.0) && close(seen[2], 640.0), "fitWidth spans the comp width: {seen:?}");
|
||||
assert!(close((seen[1] + seen[3]) / 2.0, 180.0), "fitWidth centres vertically: {seen:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_to_comp_refuses_a_scale_that_overflows() {
|
||||
let mut s = comp();
|
||||
let a = s.execute("layer.newShape", json!({"kind": "rect", "size": [1e-305, 80]})).unwrap()["layer"].as_u64().unwrap();
|
||||
let before = s.project.clone();
|
||||
assert!(s.execute("layer.transform", json!({"layers": [a], "op": "fit"})).is_err(), "an infinite scale is refused");
|
||||
assert_eq!(s.project, before, "nothing was written");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicated_and_pasted_layers_keep_their_links() {
|
||||
let mut s = comp();
|
||||
|
||||
@@ -7,7 +7,7 @@ use std::sync::Arc;
|
||||
use effectcraft_geom::{Vec3, vec3};
|
||||
use effectcraft_keyframe::Value as KV;
|
||||
use effectcraft_project::{Footage, FootageKind, GroupKind, ItemId, Layer, LayerId, LayerSource, LightKind, Project};
|
||||
use effectcraft_render::three_d::camera::layer_frame;
|
||||
use effectcraft_render::three_d::camera::{is_two_node, layer_frame};
|
||||
use effectcraft_render::{EvalCtx, FootageSource, RenderOpts};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
use serde_json::{Value, json};
|
||||
@@ -40,7 +40,7 @@ fn close3(a: [f64; 3], b: [f64; 3], tol: f64) -> bool {
|
||||
fn frame(s: &Session, id: u64) -> (Vec3, Vec3) {
|
||||
let c = cid(s);
|
||||
let comp = s.project.comp(c).unwrap();
|
||||
let ctx = EvalCtx { project: &s.project, comp_id: c, comp, time: Tick::ZERO, expr: None, footage: None };
|
||||
let ctx = EvalCtx { project: &s.project, comp_id: c, comp, time: Tick::ZERO, expr: None, footage: None, property_overrides: &[], media_overrides: &[] };
|
||||
let (e, f, _) = layer_frame(&ctx, comp.layer(LayerId(id)).unwrap());
|
||||
(e, f)
|
||||
}
|
||||
@@ -217,8 +217,189 @@ fn control_light_with_camera_follows_and_releases() {
|
||||
assert!(s.execute("light.controlWithCamera", json!({"layers": [cam]})).is_err());
|
||||
}
|
||||
|
||||
/// Parenting keeps a camera where it is (After Effects' Parent & Link): Position and Point of
|
||||
/// Interest are re-expressed in the null's space, so the frame does not move.
|
||||
#[test]
|
||||
fn parented_camera_keeps_its_frame_under_an_offset_null() {
|
||||
let mut s = session();
|
||||
let cam = camera(&mut s);
|
||||
let (eye0, f0) = frame(&s, cam);
|
||||
let nul = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("prop.set", json!({"layer": nul, "path": "transform/position", "value": [100, 60, 0]})).unwrap();
|
||||
s.execute("layer.setParent", json!({"layers": [cam], "parent": nul})).unwrap();
|
||||
let (eye1, f1) = frame(&s, cam);
|
||||
assert!((eye1 - eye0).length() < 1e-6 && (f1 - f0).length() < 1e-6, "{eye0:?} {eye1:?}");
|
||||
// The null's origin is Position minus its default anchor (50, 50): (50, 10) in the comp.
|
||||
let c = layer_in(&s, cid(&s), cam);
|
||||
assert!(close3(v3(&c, "transform/position"), [270.0, 170.0, -800.0], 1e-6), "{:?}", v3(&c, "transform/position"));
|
||||
assert!(close3(v3(&c, "transform/poi"), [270.0, 170.0, 0.0], 1e-6), "{:?}", v3(&c, "transform/poi"));
|
||||
// Moving the null carries the camera and keeps its aim.
|
||||
s.execute("prop.set", json!({"layer": nul, "path": "transform/position", "value": [140, 90, 0]})).unwrap();
|
||||
let (eye2, f2) = frame(&s, cam);
|
||||
assert!((eye2 - (eye0 + vec3(40.0, 30.0, 0.0))).length() < 1e-6, "{eye2:?}");
|
||||
assert!((f2 - f0).length() < 1e-6, "{f2:?}");
|
||||
}
|
||||
|
||||
/// World (eye, forward, down) of a layer in the active comp at time `t`.
|
||||
fn frame_at(s: &Session, id: u64, t: Tick) -> (Vec3, Vec3, Vec3) {
|
||||
let c = cid(s);
|
||||
let comp = s.project.comp(c).unwrap();
|
||||
let ctx = EvalCtx { project: &s.project, comp_id: c, comp, time: t, expr: None, footage: None, property_overrides: &[], media_overrides: &[] };
|
||||
layer_frame(&ctx, comp.layer(LayerId(id)).unwrap())
|
||||
}
|
||||
|
||||
/// Whether two frames agree on eye, forward and down.
|
||||
fn frames_match(a: (Vec3, Vec3, Vec3), b: (Vec3, Vec3, Vec3)) -> bool {
|
||||
(a.0 - b.0).length() < 1e-6 && (a.1 - b.1).length() < 1e-6 && (a.2 - b.2).length() < 1e-6
|
||||
}
|
||||
|
||||
/// A 3D null off-centre in the comp, with an Orientation and a Z rotation.
|
||||
fn rotated_null(s: &mut Session, orientation: [f64; 3], turn: f64) -> u64 {
|
||||
let nul = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [nul], "switch": "threeD", "value": true})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": nul, "path": "transform/position", "value": [100, 60, 40]})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": nul, "path": "transform/orientation", "value": orientation})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": nul, "path": "transform/rotation", "value": turn})).unwrap();
|
||||
nul
|
||||
}
|
||||
|
||||
/// A two-node camera under a rotated null keeps its eye, aim and roll: its Orientation is
|
||||
/// re-expressed against the new aim, so Up does not drift.
|
||||
#[test]
|
||||
fn two_node_camera_under_rotated_null_keeps_its_roll() {
|
||||
let mut s = session();
|
||||
let cam = camera(&mut s);
|
||||
s.execute("prop.set", json!({"layer": cam, "path": "transform/rotation", "value": 25})).unwrap();
|
||||
assert!(is_two_node(&layer_in(&s, cid(&s), cam)));
|
||||
let before = frame_at(&s, cam, Tick::ZERO);
|
||||
let nul = rotated_null(&mut s, [20.0, -35.0, 60.0], 30.0);
|
||||
s.execute("layer.setParent", json!({"layers": [cam], "parent": nul})).unwrap();
|
||||
let after = frame_at(&s, cam, Tick::ZERO);
|
||||
assert!(frames_match(before, after), "{before:?} {after:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parented_point_light_keeps_its_direction_under_a_rotated_null() {
|
||||
let mut s = session();
|
||||
let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("prop.set", json!({"layer": light, "path": "transform/orientation", "value": [30, 0, 0]})).unwrap();
|
||||
let (eye0, f0) = frame(&s, light);
|
||||
let nul = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("prop.set", json!({"layer": nul, "path": "transform/position", "value": [100, 60, 0]})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": nul, "path": "transform/rotation", "value": 30})).unwrap();
|
||||
s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap();
|
||||
let (eye1, f1) = frame(&s, light);
|
||||
assert!((eye1 - eye0).length() < 1e-6 && (f1 - f0).length() < 1e-6, "{eye0:?} {eye1:?} {f0:?} {f1:?}");
|
||||
}
|
||||
|
||||
/// Keyed Orientation under a Z-only turn is shifted exactly, so the light keeps its direction at
|
||||
/// every time, between the keys too.
|
||||
#[test]
|
||||
fn keyed_orientation_under_a_z_turn_keeps_the_direction_at_every_time() {
|
||||
let mut s = session();
|
||||
let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap();
|
||||
for (time, value) in [(0.0, json!([-8.0, 12.0, -13.0])), (1.0, json!([5.0, 18.0, 14.0]))] {
|
||||
s.execute("prop.addKey", json!({"layer": light, "path": "transform/orientation", "time": time, "value": value})).unwrap();
|
||||
}
|
||||
let times = [0.0, 0.25, 0.5, 0.75, 1.0].map(Tick::from_seconds_f64);
|
||||
let before = times.map(|t| frame_at(&s, light, t));
|
||||
let nul = rotated_null(&mut s, [0.0, 0.0, 0.0], 30.0);
|
||||
s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap();
|
||||
for (t, b) in times.into_iter().zip(before) {
|
||||
assert!(frames_match(b, frame_at(&s, light, t)), "a Z-only turn keeps the light's direction");
|
||||
}
|
||||
}
|
||||
|
||||
/// Under a tilted null the keys alone cannot follow (Euler angles are blended between them), so keys are
|
||||
/// added between them: the light keeps its direction within a small angle at every time.
|
||||
#[test]
|
||||
fn keyed_orientation_under_a_tilted_null_follows_between_the_keys() {
|
||||
{
|
||||
let mut s = session();
|
||||
let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap();
|
||||
for (time, value) in [(0.0, json!([-8.0, 12.0, -13.0])), (1.5, json!([50.0, 118.0, 74.0]))] {
|
||||
s.execute("prop.addKey", json!({"layer": light, "path": "transform/orientation", "time": time, "value": value})).unwrap();
|
||||
}
|
||||
let times = [0.0, 0.2, 0.5, 0.9, 1.3, 1.5].map(Tick::from_seconds_f64);
|
||||
let before = times.map(|t| frame_at(&s, light, t));
|
||||
let nul = rotated_null(&mut s, [20.0, -35.0, 60.0], 30.0);
|
||||
s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap();
|
||||
for (t, b) in times.into_iter().zip(before) {
|
||||
let a = frame_at(&s, light, t);
|
||||
// 0.05 degrees is about 9e-4 of a radian on a unit vector.
|
||||
assert!((a.0 - b.0).length() < 1e-6 && (a.1 - b.1).length() < 2e-3, "at {t:?}: {:?} vs {:?}", a.1, b.1);
|
||||
}
|
||||
assert!(layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().keys.len() > 2, "keys were added between the two");
|
||||
}
|
||||
}
|
||||
|
||||
/// A one-node light parented under a mirrored null (Z scale -100%) keeps the Position and
|
||||
/// Orientation it was authored with: it has no Scale to carry the mirror, so none of it is
|
||||
/// compensated rather than reversing its frame (a half-compensated light would point backwards).
|
||||
#[test]
|
||||
fn one_node_light_under_a_mirrored_null_keeps_its_authored_values() {
|
||||
let mut s = session();
|
||||
let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("prop.set", json!({"layer": light, "path": "transform/orientation", "value": [30, 0, 0]})).unwrap();
|
||||
let before = layer_in(&s, cid(&s), light);
|
||||
let nul = rotated_null(&mut s, [0.0, 0.0, 0.0], 0.0);
|
||||
s.execute("prop.set", json!({"layer": nul, "path": "transform/scale", "value": [100, 100, -100]})).unwrap();
|
||||
s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap();
|
||||
let after = layer_in(&s, cid(&s), light);
|
||||
assert_eq!(after.parent, Some(LayerId(nul)));
|
||||
assert_eq!(v3(&after, "transform/position"), v3(&before, "transform/position"));
|
||||
assert_eq!(v3(&after, "transform/orientation"), v3(&before, "transform/orientation"));
|
||||
}
|
||||
|
||||
/// The cap on keys added by baking is for the whole Orientation property, not for each segment:
|
||||
/// many strongly eased segments under a tilted parent stay within it.
|
||||
#[test]
|
||||
fn baked_orientation_keys_are_capped_across_the_whole_property() {
|
||||
let mut s = session();
|
||||
let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap();
|
||||
let count = 120;
|
||||
for i in 0..count {
|
||||
let swing = if i % 2 == 0 { json!([0.0, 0.0, 0.0]) } else { json!([170.0, 60.0, 120.0]) };
|
||||
s.execute("prop.addKey", json!({"layer": light, "path": "transform/orientation", "time": i as f64 * 0.05, "value": swing})).unwrap();
|
||||
}
|
||||
let uid = layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().uid;
|
||||
let keys: Vec<_> = (0..count).map(|i| json!({"layer": light, "prop": uid, "time": i as f64 * 0.05})).collect();
|
||||
s.execute("keys.select", json!({"keys": keys})).unwrap();
|
||||
s.execute("keys.easyEase", json!({})).unwrap();
|
||||
let nul = rotated_null(&mut s, [20.0, -35.0, 60.0], 30.0);
|
||||
s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap();
|
||||
let n = layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().keys.len();
|
||||
assert!(n <= 4096, "{n} keys: the cap is for the property, not per segment");
|
||||
// the segments the cap left unchecked keep their authored easing instead of turning into straight blends
|
||||
let orientation = layer_in(&s, cid(&s), light);
|
||||
let keys = &orientation.props.prop("transform/orientation").unwrap().keys;
|
||||
let last = keys.iter().rev().nth(1).expect("the penultimate key");
|
||||
assert_ne!(last.out_interp, effectcraft_keyframe::Interp::Linear, "the last segment keeps its authored easing");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- from 3D model
|
||||
|
||||
/// Easy Ease agrees with a straight blend at the midpoint, but not at the quarter points.
|
||||
#[test]
|
||||
fn eased_orientation_under_x_parent_keeps_motion_between_keys() {
|
||||
let mut s = session();
|
||||
let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap();
|
||||
for (time, value) in [(0.0, json!([0.0, 0.0, 0.0])), (1.0, json!([90.0, 0.0, 0.0]))] {
|
||||
s.execute("prop.addKey", json!({"layer": light, "path": "transform/orientation", "time": time, "value": value})).unwrap();
|
||||
}
|
||||
let uid = layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().uid;
|
||||
s.execute("keys.select", json!({"keys": [{"layer": light, "prop": uid, "time": 0.0}, {"layer": light, "prop": uid, "time": 1.0}]})).unwrap();
|
||||
s.execute("keys.easyEase", json!({})).unwrap();
|
||||
let times: Vec<_> = (0..=100).map(|i| Tick::from_seconds_f64(i as f64 / 100.0)).collect();
|
||||
let before: Vec<_> = times.iter().map(|t| frame_at(&s, light, *t)).collect();
|
||||
let parent = rotated_null(&mut s, [30.0, 0.0, 0.0], 0.0);
|
||||
s.execute("layer.setParent", json!({"layers": [light], "parent": parent})).unwrap();
|
||||
for (t, b) in times.into_iter().zip(before) {
|
||||
let a = frame_at(&s, light, t);
|
||||
assert!((a.0 - b.0).length() < 1e-6 && (a.1 - b.1).length() < 1e-3, "at {t:?}: {:?} vs {:?}", a.1, b.1);
|
||||
}
|
||||
}
|
||||
|
||||
struct DiskModels;
|
||||
|
||||
impl FootageSource for DiskModels {
|
||||
|
||||
@@ -164,6 +164,33 @@ fn alpha_at(s: &Session, f: u32) -> Vec<f32> {
|
||||
img.data.iter().map(|p| p[3]).collect()
|
||||
}
|
||||
|
||||
/// Green, then Alt (red) over part of the object, then green there again: the last stroke wins,
|
||||
/// in the render and through Undo / Redo (#1646).
|
||||
#[test]
|
||||
fn roto_brush_strokes_correct_earlier_ones() {
|
||||
let (mut s, clip, _) = painted(center, "disk-1646.mov");
|
||||
let c = center(0);
|
||||
let spot = [c[0], c[1] + 25.0];
|
||||
let i = spot[1] as usize * W as usize + spot[0] as usize;
|
||||
let paint = |s: &mut Session, kind: &str| {
|
||||
s.execute_checked(
|
||||
"roto.stroke",
|
||||
json!({"layer": clip.0, "kind": kind, "points": line([spot[0] - 6.0, spot[1]], [spot[0] + 6.0, spot[1]]), "radius": 7}),
|
||||
)
|
||||
.unwrap();
|
||||
};
|
||||
assert!(alpha_at(&s, 0)[i] > 0.5, "the spot starts inside the matte");
|
||||
paint(&mut s, "bg");
|
||||
assert!(alpha_at(&s, 0)[i] < 0.5, "Alt removes it");
|
||||
paint(&mut s, "fg");
|
||||
assert!(alpha_at(&s, 0)[i] > 0.5, "green over the Alt stroke restores it");
|
||||
assert!(iou_at(&mut s, clip, 0, c) > 0.95);
|
||||
assert!(s.undo());
|
||||
assert!(alpha_at(&s, 0)[i] < 0.5, "undo brings the removal back");
|
||||
assert!(s.redo());
|
||||
assert!(alpha_at(&s, 0)[i] > 0.5, "redo restores it again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roto_brush_segments_and_propagates() {
|
||||
let (mut s, clip, uid) = painted(center, "disk-a.mov");
|
||||
|
||||
@@ -104,10 +104,11 @@ fn numbered_stills_import_as_stills_by_default() {
|
||||
let r = s.execute("file.import", json!({"paths": [dir.clone()], "drag": true})).unwrap();
|
||||
let (name, f) = footage(&s, &r["items"][0]);
|
||||
assert_eq!((name.as_str(), f.kind), ("shot_[0001-0005].png", FootageKind::Sequence));
|
||||
// Scripts and agents naming one frame without `sequence` still get its run.
|
||||
let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0003.png")]})).unwrap();
|
||||
let (_, f) = footage(&s, &r["items"][0]);
|
||||
assert_eq!(f.kind, FootageKind::Sequence);
|
||||
// #2018: scripts and agents naming one frame without `sequence` get that still, as
|
||||
// `ImportOptions.sequence` defaults to false in After Effects.
|
||||
let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0005.png")]})).unwrap();
|
||||
let (name, f) = footage(&s, &r["items"][0]);
|
||||
assert_eq!((name.as_str(), f.kind), ("shot_0005.png", FootageKind::Still));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -181,14 +182,58 @@ fn interpret_footage_start_timecode_relabels_source_time() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_footage_and_reload_keep_sequences() {
|
||||
fn replace_footage_keeps_a_still_a_still() {
|
||||
let dir = folder(&RUN);
|
||||
let mut s = session();
|
||||
let item = s.execute("file.import", json!({"paths": [at(&dir, "shot_0001.png")], "sequence": false})).unwrap()["items"][0].clone();
|
||||
s.state.project_selection = vec![ItemId(item.as_u64().unwrap())];
|
||||
// Replacing with a numbered still does not silently turn the item into a sequence (#2073).
|
||||
s.execute("file.replaceFootage", json!({"path": at(&dir, "shot_0004.png")})).unwrap();
|
||||
let (name, f) = footage(&s, &item);
|
||||
assert_eq!((name.as_str(), f.sequence.len()), ("shot_[0001-0005].png", 4));
|
||||
assert_eq!((name.as_str(), f.kind), ("shot_0004.png", FootageKind::Still));
|
||||
// JSON null is unspecified, not "on": the still stays a still.
|
||||
s.execute("file.replaceFootage", json!({"path": at(&dir, "shot_0004.png"), "sequence": null})).unwrap();
|
||||
let (name, f) = footage(&s, &item);
|
||||
assert_eq!((name.as_str(), f.kind), ("shot_0004.png", FootageKind::Still));
|
||||
// Alphabetical is a sequence modifier, not a request to make one.
|
||||
s.execute("file.replaceFootage", json!({"path": at(&dir, "shot_0004.png"), "alphabetical": true})).unwrap();
|
||||
let (name, f) = footage(&s, &item);
|
||||
assert_eq!((name.as_str(), f.kind), ("shot_0004.png", FootageKind::Still));
|
||||
// Asking for the run still brings it.
|
||||
s.execute("file.replaceFootage", json!({"path": at(&dir, "shot_0004.png"), "sequence": true})).unwrap();
|
||||
let (name, f) = footage(&s, &item);
|
||||
assert_eq!((name.as_str(), f.kind, f.sequence.len()), ("shot_[0001-0005].png", FootageKind::Sequence, 4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_footage_keeps_a_one_frame_run_a_sequence() {
|
||||
let dir = folder(&["shot_0001.png", "shot_0002.png", "solo_0007.png", "cover.png"]);
|
||||
let mut s = session();
|
||||
let item = s.execute("file.import", json!({"paths": [at(&dir, "shot_0001.png")], "sequence": true})).unwrap()["items"][0].clone();
|
||||
s.state.project_selection = vec![ItemId(item.as_u64().unwrap())];
|
||||
// A numbered file with no other frame in its run is still the replacement for a sequence.
|
||||
s.execute("file.replaceFootage", json!({"path": at(&dir, "solo_0007.png")})).unwrap();
|
||||
let (name, f) = footage(&s, &item);
|
||||
assert_eq!((name.as_str(), f.kind, f.sequence.len()), ("solo_0007.png", FootageKind::Sequence, 1));
|
||||
// A single-file source keeps the sequence kind too.
|
||||
s.execute("file.replaceFootage", json!({"path": at(&dir, "cover.png")})).unwrap();
|
||||
let (name, f) = footage(&s, &item);
|
||||
assert_eq!((name.as_str(), f.kind, f.sequence.len()), ("cover.png", FootageKind::Sequence, 1));
|
||||
// An explicit `sequence: false` still asks for one frame.
|
||||
s.execute("file.replaceFootage", json!({"path": at(&dir, "cover.png"), "sequence": false})).unwrap();
|
||||
let (_, f) = footage(&s, &item);
|
||||
assert_eq!((f.kind, f.sequence.len()), (FootageKind::Still, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_footage_and_reload_keep_sequences() {
|
||||
let dir = folder(&RUN);
|
||||
let mut s = session();
|
||||
let item = s.execute("file.import", json!({"paths": [at(&dir, "shot_0001.png")], "sequence": true})).unwrap()["items"][0].clone();
|
||||
s.state.project_selection = vec![ItemId(item.as_u64().unwrap())];
|
||||
s.execute("file.replaceFootage", json!({"path": at(&dir, "shot_0004.png")})).unwrap();
|
||||
let (name, f) = footage(&s, &item);
|
||||
assert_eq!((name.as_str(), f.kind, f.sequence.len()), ("shot_[0001-0005].png", FootageKind::Sequence, 4));
|
||||
// Reload finds a new frame and keeps the interpretation.
|
||||
s.execute("file.interpretFootage", json!({"items": [item], "frameRate": 12, "startTimecode": 2})).unwrap();
|
||||
std::fs::write(at(&dir, "shot_0003.png"), b"").unwrap();
|
||||
|
||||
@@ -476,6 +476,9 @@ fn set_text_paragraph_fill_and_leading() {
|
||||
s.execute("layer.setText", json!({"layer": t, "hScale": 120, "vScale": 80, "baselineShift": 4, "smallCaps": true, "strokeOverFill": true})).unwrap();
|
||||
let d = doc(&s);
|
||||
assert_eq!((d.h_scale, d.v_scale, d.baseline_shift, d.small_caps, d.stroke_over_fill), (120.0, 80.0, 4.0, true, true));
|
||||
s.execute("layer.setText", json!({"layer": t, "lineJoin": "bevel"})).unwrap();
|
||||
assert_eq!(doc(&s).line_join, effectcraft_keyframe::LineJoin::Bevel);
|
||||
assert!(s.execute("layer.setText", json!({"layer": t, "lineJoin": "square"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -389,6 +389,23 @@ fn new_project_template_and_default_renderer() {
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_files_are_indented_unless_compact_is_on() {
|
||||
let d = tmp("compact-project-files");
|
||||
let path = d.join("p.ecproj");
|
||||
let mut s = session_with_comp();
|
||||
s.execute("file.saveAs", json!({"path": path.to_string_lossy()})).unwrap();
|
||||
let pretty = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(pretty.starts_with("{\n \"savedBy\""), "indented by default");
|
||||
s.execute("prefs.set", json!({"key": "project.compactProjectFiles", "value": true})).unwrap();
|
||||
s.execute("file.saveAs", json!({"path": path.to_string_lossy()})).unwrap();
|
||||
let compact = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(!compact.contains('\n'), "one line when Save Compact Project Files is on");
|
||||
let open = |text: &str| effectcraft_project::Project::from_json(text).unwrap();
|
||||
assert_eq!(open(&compact), open(&pretty), "both read back as the same project");
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- shortcuts
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -184,6 +184,28 @@ fn in_and_out_points_stretch_to_the_current_time() {
|
||||
assert!(s.execute("layer.timeStretch", json!({"to": "middle"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trimming_out_to_the_playhead_keeps_its_frame_visible() {
|
||||
let (mut s, ids) = session([(1.0, 9.0); 3]);
|
||||
let frame = 1.0 / 30.0;
|
||||
// Alt+] at 4 s: the Out edge lands one frame after the playhead, so 4 s is the layer's last
|
||||
// visible frame — it must not be cut away at the playhead (as in After Effects).
|
||||
s.execute("time.set", json!({"time": 4.0})).unwrap();
|
||||
s.execute("layer.timing", json!({"layers": [ids[0]], "op": "trimOutToTime"})).unwrap();
|
||||
assert!((span(&s, ids[0]).1 - (4.0 + frame)).abs() < 1e-9, "{:?}", span(&s, ids[0]));
|
||||
// Alt+[ at 2 s: the playhead's frame becomes the layer's first visible one.
|
||||
s.execute("time.set", json!({"time": 2.0})).unwrap();
|
||||
s.execute("layer.timing", json!({"layers": [ids[1]], "op": "trimInToTime"})).unwrap();
|
||||
assert!((span(&s, ids[1]).0 - 2.0).abs() < 1e-9, "{:?}", span(&s, ids[1]));
|
||||
// Trimmed before the In point, the Out point keeps one frame of layer.
|
||||
s.execute("time.set", json!({"time": 0.5})).unwrap();
|
||||
s.execute("layer.timing", json!({"layers": [ids[2]], "op": "trimOutToTime"})).unwrap();
|
||||
assert!((span(&s, ids[2]).1 - (1.0 + frame)).abs() < 1e-9, "{:?}", span(&s, ids[2]));
|
||||
// One undo step back to the original span.
|
||||
assert!(s.undo());
|
||||
assert!((span(&s, ids[2]).1 - 9.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masks_are_stepped_through_and_wrap() {
|
||||
let (mut s, ids) = session([(0.0, 10.0); 3]);
|
||||
|
||||
@@ -519,9 +519,10 @@ fn convert_to_bezier_turns_a_parametric_shape_into_an_editable_path() {
|
||||
};
|
||||
let radius = |v: [f64; 2]| v[0].hypot(v[1]);
|
||||
|
||||
// A 120 x 80 rectangle is its four corners, joined by straight edges.
|
||||
// A 120 x 80 rectangle is its four corners, joined by straight edges, from the top-right
|
||||
// corner clockwise as After Effects draws it.
|
||||
let (mut s, l, was, rect) = convert(json!({"kind": "rect", "size": [120, 80]}));
|
||||
assert_eq!(rect.vertices, vec![[-60.0, -40.0], [60.0, -40.0], [60.0, 40.0], [-60.0, 40.0]]);
|
||||
assert_eq!(rect.vertices, vec![[60.0, -40.0], [60.0, 40.0], [-60.0, 40.0], [-60.0, -40.0]]);
|
||||
assert!(rect.in_tangents.iter().chain(&rect.out_tangents).all(|t| *t == [0.0, 0.0]), "straight edges: {rect:?}");
|
||||
// Undo brings the parametric shape back.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
@@ -583,3 +584,22 @@ fn convert_to_bezier_reads_the_shape_as_it_is_set() {
|
||||
let mid = |v: &[f64]| (v.iter().copied().fold(f64::MAX, f64::min) + v.iter().copied().fold(f64::MIN, f64::max)) / 2.0;
|
||||
assert!((mid(&xs) - 10.0).abs() < 1e-6 && (mid(&ys) - 5.0).abs() < 1e-6, "centred on the position: {:?} {:?}", mid(&xs), mid(&ys));
|
||||
}
|
||||
|
||||
/// #2201: a viewer drag on the free-transform box sends one `path.freeTransform` per frame with a
|
||||
/// shared merge key; one undo must revert the whole drag, not the last frame.
|
||||
#[test]
|
||||
fn free_transform_drag_undoes_in_one_step() {
|
||||
let mut s = comp();
|
||||
let (l, m) = masked(&mut s);
|
||||
s.state.selected_vertices.clear();
|
||||
let before = mask_path(&s, l, m);
|
||||
for k in [90, 90, 90, 90] {
|
||||
s.execute("path.freeTransform", json!({"layer": l, "scale": k, "merge": "viewer-drag-1"})).unwrap();
|
||||
}
|
||||
let after = mask_path(&s, l, m);
|
||||
assert_ne!(after, before);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(mask_path(&s, l, m), before, "one undo reverts the whole drag");
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(mask_path(&s, l, m), after, "redo restores the whole drag");
|
||||
}
|
||||
|
||||
@@ -421,6 +421,70 @@ fn camera_focus_commands_undo_and_serde() {
|
||||
assert!(s.execute("camera.linkFocusToPoi", json!({})).is_ok());
|
||||
}
|
||||
|
||||
/// Set Focus Distance to Layer: degenerate targets (behind the camera, exactly at it) are clean
|
||||
/// errors, and a depth-of-field frame renders right after the command.
|
||||
#[test]
|
||||
fn camera_set_focus_to_layer_degenerate_targets_and_dof_frame() {
|
||||
let mut s = comp(400, 300);
|
||||
let t = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [t], "switch": "threeD", "value": true})).unwrap();
|
||||
let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("prop.set", json!({"layer": cam, "path": "transform/position", "value": [200, 150, -500]})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": cam, "path": "cameraOptions/dof", "value": true})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [cam, t]})).unwrap();
|
||||
// Behind the camera (which now sits at z = −500 looking towards +z).
|
||||
s.execute("layer.setTransform", json!({"layer": t, "prop": "position", "value": [200, 150, -1000]})).unwrap();
|
||||
let e = s.execute("camera.setFocusToLayer", json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains("behind the camera"), "{e}");
|
||||
// Exactly at the camera: depth 0, still a clean error.
|
||||
s.execute("layer.setTransform", json!({"layer": t, "prop": "position", "value": [200, 150, -500]})).unwrap();
|
||||
assert!(s.execute("camera.setFocusToLayer", json!({})).is_err());
|
||||
// In front: the command works, and the depth-of-field frame renders with finite pixels.
|
||||
s.execute("layer.setTransform", json!({"layer": t, "prop": "position", "value": [200, 150, 400]})).unwrap();
|
||||
let d = s.execute("camera.setFocusToLayer", json!({})).unwrap()["focusDistance"].as_f64().unwrap();
|
||||
assert!((d - 900.0).abs() < 1e-6, "{d}");
|
||||
// An out-of-focus 3D layer, which must visibly blur, and a 0%-scaled one, whose pixels cover
|
||||
// ~0 screen pixels and so turn an ordinary defocus into a radius of ~1e9 buffer pixels.
|
||||
s.execute("prop.set", json!({"layer": cam, "path": "cameraOptions/aperture", "value": 120})).unwrap();
|
||||
let blur = s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 60, "height": 60})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [blur], "switch": "threeD", "value": true})).unwrap();
|
||||
s.execute("layer.setTransform", json!({"layer": blur, "prop": "position", "value": [200, 150, 1200]})).unwrap();
|
||||
let invis = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 40, "height": 40})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [invis], "switch": "threeD", "value": true})).unwrap();
|
||||
s.execute("layer.setTransform", json!({"layer": invis, "prop": "position", "value": [200, 150, 1600]})).unwrap();
|
||||
s.execute("layer.setTransform", json!({"layer": invis, "prop": "scale", "value": [0.0, 0.0, 0.0]})).unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let img = s.render(cid, effectcraft_time::Tick::ZERO, Default::default());
|
||||
assert!(img.data.iter().all(|p| p.iter().all(|c| c.is_finite())), "non-finite pixels after setFocusToLayer");
|
||||
// The DoF frame actually blurred (not a no-op blur): the same frame with DoF off differs.
|
||||
s.execute("prop.set", json!({"layer": cam, "path": "cameraOptions/dof", "value": false})).unwrap();
|
||||
let plain = s.render(cid, effectcraft_time::Tick::ZERO, Default::default());
|
||||
assert_ne!(img.data, plain.data, "the out-of-focus layer should have blurred");
|
||||
}
|
||||
|
||||
/// Set Focus Distance to Layer fails cleanly on a depth that isn't finite. Every stored number
|
||||
/// here is finite, but their difference overflows f64 to infinity; JSON has no infinity, so
|
||||
/// storing it would leave a project that can't be saved.
|
||||
#[test]
|
||||
fn camera_set_focus_to_layer_rejects_non_finite_depth() {
|
||||
let mut s = comp(400, 300);
|
||||
let t = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [t], "switch": "threeD", "value": true})).unwrap();
|
||||
s.execute("layer.setTransform", json!({"layer": t, "prop": "position", "value": [200.0, 150.0, 1e308]})).unwrap();
|
||||
let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.setTransform", json!({"layer": cam, "prop": "position", "value": [200.0, 150.0, -1e308]})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [cam, t]})).unwrap();
|
||||
let before = s.execute("prop.get", json!({"layer": cam, "path": "cameraOptions/focusDistance"})).unwrap()["value"].as_f64().unwrap();
|
||||
let e = s.execute("camera.setFocusToLayer", json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains("out of range"), "{e}");
|
||||
let after = s.execute("prop.get", json!({"layer": cam, "path": "cameraOptions/focusDistance"})).unwrap()["value"].as_f64().unwrap();
|
||||
assert!((after - before).abs() < 1e-9, "a non-finite focus distance was written: {after}");
|
||||
// The project still round-trips through serde (a stored infinity would come back as null).
|
||||
let back = roundtrip(&s);
|
||||
let f = back.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(cam)).unwrap().props.prop("cameraOptions/focusDistance").unwrap().value.as_f64();
|
||||
assert!((f - before).abs() < 1e-9, "serde focus distance: {f}");
|
||||
}
|
||||
|
||||
/// An id sent as a string (`"98"`, as MCP clients often do) resolves as an id when no name matches.
|
||||
#[test]
|
||||
fn numeric_string_resolves_as_id() {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user