Compare commits
108 Commits
m3-project
...
ci-packagi
| Author | SHA1 | Date |
|---|---|---|
|
|
a48de85015 | |
|
|
0bb8baf4da | |
|
|
3f7250043d | |
|
|
fc0462a855 | |
|
|
d31094692e | |
|
|
2039688245 | |
|
|
053c445465 | |
|
|
05188d036a | |
|
|
9e79271893 | |
|
|
58dd94e212 | |
|
|
1e8bcb519a | |
|
|
26f577cb1a | |
|
|
9b5652f686 | |
|
|
c7c90cfa74 | |
|
|
a008a904af | |
|
|
73b2607c9a | |
|
|
47f2a5a3e6 | |
|
|
c90fd35ff8 | |
|
|
ef856d8f67 | |
|
|
dfb0227468 | |
|
|
43bf2906d3 | |
|
|
9ea1f1325d | |
|
|
daed03305a | |
|
|
9929b5f9ac | |
|
|
3b6b390f68 | |
|
|
e3778b1afd | |
|
|
f3db1ebe38 | |
|
|
2d4a0902ea | |
|
|
4b37443481 | |
|
|
a965d8df49 | |
|
|
cb892b5239 | |
|
|
a12d4ae1fa | |
|
|
b21e04b301 | |
|
|
339a05d771 | |
|
|
7f1e2df862 | |
|
|
61e859e3de | |
|
|
6aa490b826 | |
|
|
c6b8f5412f | |
|
|
4926dd2c4c | |
|
|
fc0eee53dc | |
|
|
5d716c2f5a | |
|
|
f82ac49855 | |
|
|
64531e4aac | |
|
|
c6488d0cd4 | |
|
|
f864b29549 | |
|
|
46cd757f99 | |
|
|
a3976913c2 | |
|
|
6f52a0aab8 | |
|
|
010cb8129f | |
|
|
43c485e31e | |
|
|
d350f4e7ac | |
|
|
a792844d7e | |
|
|
e171c33686 | |
|
|
91a3b14cb2 | |
|
|
a69f417d63 | |
|
|
e6bda37e80 | |
|
|
7ee6d6a301 | |
|
|
e295679b7f | |
|
|
995d99f938 | |
|
|
c5e33f1a02 | |
|
|
543942d085 | |
|
|
0b5cff1743 | |
|
|
0fe6db00e8 | |
|
|
e910858445 | |
|
|
fe89ab9146 | |
|
|
fc737d76da | |
|
|
5dd7d9bb57 | |
|
|
4e98b8c4bd | |
|
|
19c1d2ea1d | |
|
|
ad5aa542b4 | |
|
|
d1413d5209 | |
|
|
da3177954f | |
|
|
fa27d621e0 | |
|
|
5ce641c6b3 | |
|
|
6214aecdb9 | |
|
|
5648fdb1c2 | |
|
|
786e4e8b97 | |
|
|
3d5432c318 | |
|
|
0873393e43 | |
|
|
6144ca0804 | |
|
|
f8460971f0 | |
|
|
bf8962becf | |
|
|
1da7dc4659 | |
|
|
5cc376a818 | |
|
|
da3c7a842d | |
|
|
ee90d87742 | |
|
|
fc8fea031c | |
|
|
249cc145e3 | |
|
|
9ec7680291 | |
|
|
cb6e1beebc | |
|
|
948f06fbf2 | |
|
|
587d4803fe | |
|
|
6fc39eb133 | |
|
|
a34d0c421b | |
|
|
89653f37de | |
|
|
dc573f77e6 | |
|
|
753de070a0 | |
|
|
37c9b449c6 | |
|
|
8e4e29844e | |
|
|
6708ff55b4 | |
|
|
ed54a8cd15 | |
|
|
3861b6d043 | |
|
|
0da12c676f | |
|
|
0a8cdfc67e | |
|
|
3411fc80ab | |
|
|
18fcc48046 | |
|
|
780419c235 | |
|
|
0ae7a1925a |
|
|
@ -221,8 +221,9 @@ jobs:
|
|||
- name: Install wasm-bindgen CLI
|
||||
run: |
|
||||
# Must match the wasm-bindgen version in Cargo.lock exactly (cargo xtask web checks it).
|
||||
command -v wasm-bindgen >/dev/null && wasm-bindgen --version | grep -q "$WASM_BINDGEN_VERSION" \
|
||||
|| cargo install wasm-bindgen-cli --version "$WASM_BINDGEN_VERSION" --locked
|
||||
if ! { command -v wasm-bindgen >/dev/null && wasm-bindgen --version | grep -q "$WASM_BINDGEN_VERSION"; }; then
|
||||
cargo install wasm-bindgen-cli --version "$WASM_BINDGEN_VERSION" --locked
|
||||
fi
|
||||
wasm-bindgen --version
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
|
|
|
|||
33
AGENTS.md
33
AGENTS.md
|
|
@ -3,6 +3,39 @@
|
|||
These rules apply to every human and AI contributor. `CLAUDE.md` holds the working instructions; this
|
||||
file holds the rules that must never be broken. When the two disagree, this file wins.
|
||||
|
||||
## Never crash
|
||||
|
||||
People trust EffectCraft with hours of work. A malformed project, PSD, SVG, Lottie or media file, a bad
|
||||
expression or script, a broken plug-in, a bad control-channel or MCP argument, a corrupt preferences file
|
||||
or a full disk must produce an error the user (or agent) can act on, never a crash and never lost work.
|
||||
**This rule outranks feature work:** don't ship a feature by adding a panic path, and fix a crash before
|
||||
building on top of it. Full standard: [craftrules `standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md).
|
||||
|
||||
- **Fail with `Result<T, E>`** through the crate's error type and propagate with `?`; add context
|
||||
(`map_err`, an error variant) rather than discarding it. Lenient readers fall back and record the repair.
|
||||
- **No panicking shortcuts in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`,
|
||||
`todo!` or `unimplemented!`. Use `?`, `ok_or(..)?`, `let … else`, `if let`, or `unwrap_or*` where a
|
||||
fallback is genuinely correct (never where it would silently corrupt a document). Unfinished features
|
||||
return an "unsupported" error. The only exception is a provably infallible literal, written as
|
||||
`#[allow(clippy::expect_used)]` + `.expect("why this cannot fail")`.
|
||||
- **No `unsafe`** (`unsafe_code = "forbid"` for the workspace).
|
||||
- **Every input-derived number is hostile.** Use `get()` instead of `[i]`/`[a..b]` when the position
|
||||
comes from a file, a user, an agent or arithmetic on those; slice strings only at char boundaries; use
|
||||
checked or saturating arithmetic for lengths, offsets and counts; guard division by zero and NaN/inf
|
||||
casts; cap allocations sized by input.
|
||||
- **Bound recursion:** projects, precomps, expressions and SVG/PSD trees can be cyclic or deeply nested;
|
||||
walk them with seen-sets or depth limits.
|
||||
- **Don't cascade:** handle lock poisoning (`lock().unwrap_or_else(PoisonError::into_inner)` or an
|
||||
error), and treat worker-thread join results as `Result`s.
|
||||
- **Last-resort guard:** the app wraps command execution and file import/export in a panic hook /
|
||||
`catch_unwind` boundary that turns an escaped panic into an error and keeps the document. It is a
|
||||
safety net, not a substitute for the rules above; keep `panic = "unwind"`.
|
||||
- **Prove it:** every crash fix comes with a small synthetic regression test that panicked before the fix.
|
||||
|
||||
Tests, benches, fuzz targets and `xtask` may `unwrap()`; `clippy.toml` allows it in tests. Crates that are
|
||||
clean carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented,
|
||||
clippy::todo, clippy::unreachable)]`; new crates start with it.
|
||||
|
||||
## 1. Assets: no Adobe artwork, every asset licensed and attributed
|
||||
|
||||
This rule is absolute. Breaking it is the most serious mistake a contributor can make on this project.
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ EffectCraft is a clean-room, open-source, pure-Rust motion graphics and visual e
|
|||
|
||||
## Non-negotiables
|
||||
**Read [`AGENTS.md`](AGENTS.md) first; its rules override everything here.** No Adobe assets, every asset licensed + attributed (`cargo xtask assets`).
|
||||
- **Never crash** (AGENTS.md "Never crash"; outranks feature work: a crash loses people's work). Non-test code has no `unwrap()`/`expect()`/`panic!`/`unreachable!`/`todo!`/`unimplemented!` and no `unsafe`: return `Result` and use `?`, `ok_or`, `let … else`. Input-derived indices, slices and arithmetic use `get()` and checked/saturating math; recursion is depth-limited; locks handle poisoning. The app's last-resort guard turns an escaped panic into an error and keeps the document. Every crash fix gets a regression test. Standard: [craftrules `standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md).
|
||||
- **Clean-room.** Behaviour and public docs only. No GPL/LGPL/AGPL code. ffmpeg only as an external test oracle.
|
||||
- **Pure Rust.** Media codecs come from FilmCraft crates (git deps behind `crates/media`, see `plan/adr/0001`).
|
||||
- **Layering** (`cargo xtask layers`): nothing below L5 depends on egui/eframe/winit/rfd/cpal; L0–L4 build for wasm32.
|
||||
|
|
|
|||
|
|
@ -24,6 +24,10 @@ cargo xtask ci # what every commit must pass
|
|||
- **Everything is a command.** New features are engine commands with an id, label, menu path,
|
||||
shortcut and parameters, so the UI, the CLI and the MCP server get them at once. Interactive
|
||||
widgets register an automation id.
|
||||
- **Never crash.** Non-test code returns errors instead of panicking: no `unwrap()`, `expect()`,
|
||||
`panic!`, `unreachable!`, `todo!`, `unimplemented!` or `unsafe`, checked indexing and arithmetic on
|
||||
input-derived values, and a regression test with every crash fix. See the "Never crash" section of
|
||||
[AGENTS.md](AGENTS.md).
|
||||
- **Tests with every change**, and green `cargo xtask ci` before every commit. One task per commit.
|
||||
|
||||
## How to add…
|
||||
|
|
|
|||
|
|
@ -1134,7 +1134,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"cpal",
|
||||
"effectcraft-engine",
|
||||
|
|
@ -1155,7 +1155,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-automation"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-engine",
|
||||
"effectcraft-host",
|
||||
|
|
@ -1167,14 +1167,14 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-av1enc"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"filmcraft-av1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-cli"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"cpu-time",
|
||||
"effectcraft-automation",
|
||||
|
|
@ -1190,7 +1190,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-color"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
|
|
@ -1198,7 +1198,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-effects"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-geom",
|
||||
|
|
@ -1214,7 +1214,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-engine"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-effects",
|
||||
|
|
@ -1246,7 +1246,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-export"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-av1enc",
|
||||
"effectcraft-color",
|
||||
|
|
@ -1275,7 +1275,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-expr"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"boa_engine",
|
||||
"effectcraft-color",
|
||||
|
|
@ -1292,7 +1292,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-geom"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
|
|
@ -1300,7 +1300,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-gpu"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"effectcraft-color",
|
||||
|
|
@ -1319,14 +1319,14 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-hevcenc"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"filmcraft-hevc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-host"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-engine",
|
||||
"effectcraft-export",
|
||||
|
|
@ -1348,7 +1348,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-interchange"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-keyframe",
|
||||
|
|
@ -1367,7 +1367,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-keyframe"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-geom",
|
||||
|
|
@ -1379,7 +1379,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-lottie"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-effects",
|
||||
|
|
@ -1396,7 +1396,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-media"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-model",
|
||||
"effectcraft-pdf",
|
||||
|
|
@ -1421,7 +1421,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-model"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-geom",
|
||||
"image",
|
||||
|
|
@ -1432,14 +1432,14 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-opusenc"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"filmcraft-opus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-path"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-geom",
|
||||
"effectcraft-keyframe",
|
||||
|
|
@ -1451,7 +1451,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-pdf"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-raster",
|
||||
"effectcraft-svg",
|
||||
|
|
@ -1465,7 +1465,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-plugin"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-effects",
|
||||
"effectcraft-keyframe",
|
||||
|
|
@ -1477,7 +1477,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-project"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-geom",
|
||||
|
|
@ -1490,14 +1490,14 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-psd"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"miniz_oxide 0.8.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-raster"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-geom",
|
||||
|
|
@ -1506,7 +1506,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-render"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-effects",
|
||||
|
|
@ -1528,7 +1528,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-script"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"boa_engine",
|
||||
"effectcraft-engine",
|
||||
|
|
@ -1543,7 +1543,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-svg"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-geom",
|
||||
"effectcraft-path",
|
||||
|
|
@ -1555,7 +1555,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-text"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-keyframe",
|
||||
"harfrust",
|
||||
|
|
@ -1567,7 +1567,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-time"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
|
|
@ -1575,7 +1575,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-track"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-raster",
|
||||
"rayon",
|
||||
|
|
@ -1586,7 +1586,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-ui-egui"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-engine",
|
||||
|
|
@ -1614,11 +1614,11 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-vp9enc"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-web"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-engine",
|
||||
"effectcraft-export",
|
||||
|
|
@ -6318,7 +6318,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
|||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
|||
default-members = ["crates/*", "apps/*"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
|
@ -116,7 +116,7 @@ cpal = "0.16"
|
|||
# filmcraft-isobmff = { path = "../filmcraft/crates/isobmff" }
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "deny"
|
||||
unsafe_code = "forbid"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
dbg_macro = "warn"
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ once.
|
|||
| M0 | Skeleton: crates, compositor, the After Effects style shell, control channel, `cargo xtask` | Done (web build: [docs/web.md](docs/web.md)) |
|
||||
| M1 | Keyframes: temporal and spatial interpolation, Easy Ease, roving, velocity | Done |
|
||||
| M2 | Compositing: 38 blend modes, track mattes, parenting, adjustment layers | Done |
|
||||
| M3 | Project operations: settings dialogs, layer commands, `.ecproj`, undo, After Effects menu bar | Mostly done |
|
||||
| M4 | Preview: precomps, motion blur, cached playback | Mostly done |
|
||||
| M3 | Project operations: settings dialogs, layer commands, `.ecproj`, undo, After Effects menu bar | Done (unsaved-changes prompts, Solid / Layer Settings, lock-aware layer commands, safe project files; View ▸ New Viewer still opens only the one locked viewer) |
|
||||
| M4 | Preview: precomps, motion blur, cached playback | Mostly done (Preserve frame rate / resolution when nested, one motion blur gate, RAM preview fixes, Cache Frames When Idle, motion blur of collapsed precomps and of animated shape / text content; left: content-keyed RAM preview frames) |
|
||||
| M5 | Timeline depth: graph editor, keyframe clipboard and dialogs, time remapping, pick-whips, expression editor | Done |
|
||||
| M6 | Shapes, masks and footage: shape operators, masks and the pen tool, video and image import | Done |
|
||||
| M7 | 3D: 3D layers, cameras, lights, shadows, depth of field, 3D views and camera tools | In review |
|
||||
|
|
@ -33,6 +33,6 @@ Measured feature by feature in [docs/parity.md](docs/parity.md) (4 October 2026)
|
|||
- **≈ 8.5 agent-hours of work remain**: about **2–3 hours of wall-clock time** with five Claude
|
||||
Opus 5.5 agents in parallel, ≈ 1 hour without learned models for Roto Brush and face tracking.
|
||||
- What is left: GPU kernels for the remaining visual effects, and learned-model quality for Roto
|
||||
Brush and face tracking.
|
||||
Brush and face tracking; in previews, content-keyed RAM preview frames ([parity.md](docs/parity.md#highest-value-gaps-in-order)).
|
||||
|
||||
Come tell us what matters most to you on [Discord](https://discord.gg/artcraft).
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@
|
|||
//! `<comp>` is a comp id or name (`-` = the active comp); `<layer>` an id, `#n` or name; `<value>`
|
||||
//! is JSON (`50`, `[960,540]`, `"#ff0000"`) or a bare string. See `docs/agents.md`.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
use effectcraft_automation::tools::{self, Reply};
|
||||
use effectcraft_automation::{Backend, McpServer};
|
||||
use serde_json::{Value, json};
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@
|
|||
//!
|
||||
//! [`store`] is 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 store;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
|
|
|||
|
|
@ -167,7 +167,7 @@ pub fn restore(s: &mut Session) -> bool {
|
|||
}
|
||||
if meta.dirty {
|
||||
// Unsaved changes stay unsaved.
|
||||
s.bump();
|
||||
s.mark_unsaved();
|
||||
}
|
||||
SNAP.with(|c| c.set((s.revision, 0.0)));
|
||||
log::info!("restored the last session ({})", meta.path.as_deref().unwrap_or("untitled"));
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
//! `--control <port>` (or `EFFECTCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server;
|
||||
//! see `effectcraft_ui_egui::control` for the methods.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod audio_out;
|
||||
mod control_server;
|
||||
#[cfg(target_os = "macos")]
|
||||
|
|
@ -26,6 +28,7 @@ fn app_icon() -> egui::IconData {
|
|||
fn main() -> eframe::Result {
|
||||
// Help ▸ Enable Logging writes through this logger; warnings feed the compatibility report.
|
||||
effectcraft_engine::logging::install();
|
||||
effectcraft_engine::logging::install_panic_hook();
|
||||
let mut control_port: Option<u16> = std::env::var("EFFECTCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
|
||||
let mut files = Vec::new();
|
||||
let mut demo = true;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
allow-unwrap-in-tests = true
|
||||
allow-expect-in-tests = true
|
||||
allow-panic-in-tests = true
|
||||
allow-indexing-slicing-in-tests = true
|
||||
|
|
@ -13,6 +13,8 @@
|
|||
//! Layering: depends on `effectcraft-engine` only (never egui); the CLI injects a fully wired
|
||||
//! session (`effectcraft_host::session()`).
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod backend;
|
||||
pub mod base64;
|
||||
pub mod bridge;
|
||||
|
|
|
|||
|
|
@ -378,7 +378,13 @@ impl<'a> Decider<'a> {
|
|||
}
|
||||
}
|
||||
}
|
||||
let (_, y_mode, tx_type_y, ty, recon) = best.expect("an intra mode");
|
||||
// Every ranked mode tries DCT_DCT, so `best` is always set; without a candidate the block
|
||||
// can't be coded intra (an infinite cost keeps it from being chosen).
|
||||
let Some((_, y_mode, tx_type_y, ty, recon)) = best else {
|
||||
let leaf =
|
||||
Leaf { bsize, is_inter: false, y_mode: DC_PRED, uv_mode: DC_PRED, tx_type_y: DCT_DCT, mv: [0, 0], skip: true, coefs: Default::default() };
|
||||
return (leaf, f64::INFINITY);
|
||||
};
|
||||
self.put(0, c * 4, r * 4, n, recon.as_deref().unwrap_or(&best_pred[..n * n]));
|
||||
let tx_type_y = if ty.nonzero { tx_type_y } else { DCT_DCT };
|
||||
self.mark_decoded(0, r, c, n4);
|
||||
|
|
@ -414,16 +420,17 @@ impl<'a> Decider<'a> {
|
|||
let uv_mode = best_uv.1;
|
||||
let uv_tx = tx_size_for(clog2);
|
||||
let uv_type = intra_uv_tx_type(uv_mode, uv_tx);
|
||||
let mut tuv = Vec::with_capacity(2);
|
||||
for i in 0..2 {
|
||||
let mut code_uv = |i: usize| {
|
||||
predict_intra(&ced[i], &ce[i], uv_mode, &mut pred);
|
||||
tuv.push(self.code_tx(1 + i, c * 2, r * 2, clog2, uv_type, &pred[..cn * cn], true));
|
||||
let t = self.code_tx(1 + i, c * 2, r * 2, clog2, uv_type, &pred[..cn * cn], true);
|
||||
self.mark_decoded(1 + i, r, c, n4);
|
||||
}
|
||||
let skip = !ty.nonzero && !tuv[0].nonzero && !tuv[1].nonzero;
|
||||
let dist = ty.dist + tuv[0].dist + tuv[1].dist;
|
||||
let bits = if skip { 1.0 } else { ty.bits + tuv[0].bits + tuv[1].bits + 1.0 } + MODE_BITS_INTRA + 2.0;
|
||||
let [u, v]: [TxResult; 2] = tuv.try_into().ok().expect("two chroma planes");
|
||||
t
|
||||
};
|
||||
let u = code_uv(0);
|
||||
let v = code_uv(1);
|
||||
let skip = !ty.nonzero && !u.nonzero && !v.nonzero;
|
||||
let dist = ty.dist + u.dist + v.dist;
|
||||
let bits = if skip { 1.0 } else { ty.bits + u.bits + v.bits + 1.0 } + MODE_BITS_INTRA + 2.0;
|
||||
let leaf = Leaf { bsize, is_inter: false, y_mode, uv_mode, tx_type_y, mv: [0, 0], skip, coefs: [ty.levels, u.levels, v.levels] };
|
||||
(leaf, dist + self.lambda * bits)
|
||||
}
|
||||
|
|
@ -432,8 +439,9 @@ impl<'a> Decider<'a> {
|
|||
let n4 = NUM_4X4_BLOCKS_WIDE[bsize] as usize;
|
||||
let n = n4 * 4;
|
||||
let log2 = n.trailing_zeros();
|
||||
// Inter blocks are only decided with a reference frame; without one, code intra.
|
||||
let Some(refr) = self.refr else { return self.intra_leaf(r, c, bsize) };
|
||||
let (mv, mv_bits) = self.motion_search(r, c, n, parent_mv);
|
||||
let refr = self.refr.expect("reference frame");
|
||||
let mut pred = vec![0u16; n * n];
|
||||
let mut tx = Vec::with_capacity(3);
|
||||
let mut dist = 0.0;
|
||||
|
|
@ -461,7 +469,16 @@ impl<'a> Decider<'a> {
|
|||
}
|
||||
let skip = tx.iter().all(|t| !t.nonzero);
|
||||
let bits = mode_bits + if skip { 1.0 } else { 1.0 + tx.iter().map(|t| t.bits).sum::<f64>() };
|
||||
let [y, u, v]: [TxResult; 3] = tx.try_into().ok().expect("three planes");
|
||||
let [y, u, v]: [TxResult; 3] = match tx.try_into() {
|
||||
Ok(planes) => planes,
|
||||
// One transform block per plane was coded above.
|
||||
Err(_) => {
|
||||
return (
|
||||
Leaf { bsize, is_inter: true, y_mode: NEWMV, uv_mode: DC_PRED, tx_type_y: DCT_DCT, mv, skip: true, coefs: Default::default() },
|
||||
f64::INFINITY,
|
||||
);
|
||||
}
|
||||
};
|
||||
let leaf = Leaf { bsize, is_inter: true, y_mode: NEWMV, uv_mode: DC_PRED, tx_type_y: DCT_DCT, mv, skip, coefs: [y.levels, u.levels, v.levels] };
|
||||
(leaf, dist + self.lambda * bits)
|
||||
}
|
||||
|
|
@ -470,7 +487,7 @@ impl<'a> Decider<'a> {
|
|||
/// Returns the vector and an estimate of its coding cost in bits.
|
||||
fn motion_search(&self, r: usize, c: usize, n: usize, parent_mv: Option<[i32; 2]>) -> ([i32; 2], f64) {
|
||||
let g = self.g;
|
||||
let refr = self.refr.expect("reference frame");
|
||||
let Some(refr) = self.refr else { return ([0, 0], 0.0) };
|
||||
let mi = |rr: usize, cc: usize| self.mvs[rr * g.mi_cols + cc];
|
||||
let mut pred_mv = [0, 0];
|
||||
let mut cands: Vec<[i32; 2]> = vec![[0, 0]];
|
||||
|
|
@ -579,7 +596,7 @@ impl<'a> Decider<'a> {
|
|||
/// Approximate luma prediction for motion search: exact at whole and half-sample
|
||||
/// positions (precomputed planes), averaged neighbours at quarter-sample positions.
|
||||
fn search_block(&self, mv: [i32; 2], x: usize, y: usize, n: usize, out: &mut [u16]) {
|
||||
let refr = self.refr.expect("reference frame");
|
||||
let Some(refr) = self.refr else { return };
|
||||
let (w, h) = (self.g.width as i32, self.g.height as i32);
|
||||
// per axis: up to two (integer offset, half) taps
|
||||
let axis = |v: i32| -> ([(i32, bool); 2], usize) {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@
|
|||
//!
|
||||
//! The public API below is the contract `crates/export` codes against; keep it stable.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod bitw;
|
||||
mod cdf;
|
||||
mod decide;
|
||||
|
|
@ -444,7 +446,16 @@ impl Encoder {
|
|||
let src = self.load_source(frame);
|
||||
let (frame_obu, rf) = match self.cfg.rate {
|
||||
RateControl::ConstantQ(q) => self.encode_frame(&src, key, q.max(1)),
|
||||
RateControl::Bitrate { .. } => self.encode_rate_controlled(&src, key),
|
||||
RateControl::Bitrate { .. } => match self.rc.take() {
|
||||
Some(mut rc) => {
|
||||
let out = self.encode_rate_controlled(&src, key, &mut rc);
|
||||
self.rc = Some(rc);
|
||||
out
|
||||
}
|
||||
// The state is created with the encoder for bitrate mode; without it, encode at a
|
||||
// middle quantizer rather than fail.
|
||||
None => self.encode_frame(&src, key, 128),
|
||||
},
|
||||
};
|
||||
self.refr = Some(rf);
|
||||
self.frame_num += 1;
|
||||
|
|
@ -456,9 +467,8 @@ impl Encoder {
|
|||
Packet { data, keyframe: key }
|
||||
}
|
||||
|
||||
fn encode_rate_controlled(&mut self, src: &[Plane; 3], key: bool) -> (Vec<u8>, RefFrame) {
|
||||
fn encode_rate_controlled(&mut self, src: &[Plane; 3], key: bool, rc: &mut RateState) -> (Vec<u8>, RefFrame) {
|
||||
let first = self.frame_num == 0;
|
||||
let rc = self.rc.as_ref().expect("rate control state");
|
||||
let (frame_bits, debt, q_inter) = (rc.frame_bits, rc.debt, rc.q);
|
||||
// key frames get a lower quantizer and a larger share of the budget
|
||||
let key_boost = 1.0 + (self.cfg.keyint.clamp(1, 60) as f64 - 1.0).sqrt() * 0.75;
|
||||
|
|
@ -481,8 +491,8 @@ impl Encoder {
|
|||
q = (q + 30.0 * err).clamp(1.0, 255.0);
|
||||
}
|
||||
}
|
||||
let (obu, rf, bits) = best.expect("encoded");
|
||||
let rc = self.rc.as_mut().expect("rate control state");
|
||||
// At least one pass ran, so `best` is set.
|
||||
let Some((obu, rf, bits)) = best else { return self.encode_frame(src, key, q.round().clamp(1.0, 255.0) as u8) };
|
||||
rc.debt += bits - budget;
|
||||
// adapt the inter quantizer: about 30 index steps per doubling of the rate
|
||||
let err = (bits / target).log2().clamp(-1.0, 1.0);
|
||||
|
|
|
|||
|
|
@ -290,7 +290,11 @@ pub(crate) fn kinds(tx_type: usize) -> (Kind, Kind) {
|
|||
ADST_DCT => (Kind::Adst, Kind::Dct),
|
||||
DCT_ADST => (Kind::Dct, Kind::Adst),
|
||||
ADST_ADST => (Kind::Adst, Kind::Adst),
|
||||
_ => unreachable!("unsupported transform type {tx_type}"),
|
||||
// The encoder only chooses the four types above.
|
||||
_ => {
|
||||
debug_assert!(false, "unsupported transform type {tx_type}");
|
||||
(Kind::Dct, Kind::Dct)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -211,7 +211,8 @@ impl<'a> TileWriter<'a> {
|
|||
debug_assert_eq!(leaf.bsize, bsize);
|
||||
self.block(r, c, leaf);
|
||||
}
|
||||
Node::Out => unreachable!("decided block inside the frame"),
|
||||
// Blocks inside the frame are always decided; nothing is coded outside it.
|
||||
Node::Out => debug_assert!(false, "undecided block inside the frame"),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -460,10 +461,11 @@ impl<'a> TileWriter<'a> {
|
|||
if set > 0 && self.qidx > 0 {
|
||||
let sqr = TX_SIZE_SQR[tx_sz] as usize;
|
||||
if b.is_inter {
|
||||
let s = TX_TYPE_INTER_INV_SET3.iter().position(|&v| v as usize == t).expect("inter tx type in set");
|
||||
// The decision only picks types of this set (DCT_DCT otherwise).
|
||||
let s = TX_TYPE_INTER_INV_SET3.iter().position(|&v| v as usize == t).unwrap_or_default();
|
||||
self.sw.symbol(s, &mut self.cdf.inter_tx_type_set3[sqr]);
|
||||
} else {
|
||||
let s = TX_TYPE_INTRA_INV_SET2.iter().position(|&v| v as usize == t).expect("intra tx type in set");
|
||||
let s = TX_TYPE_INTRA_INV_SET2.iter().position(|&v| v as usize == t).unwrap_or_default();
|
||||
self.sw.symbol(s, &mut self.cdf.intra_tx_type_set2[sqr][b.y_mode]);
|
||||
}
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
//! standard definitions of the "classic" photographic modes; they are applied to un-premultiplied
|
||||
//! colour and composited with Porter-Duff source-over on premultiplied pixels.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod blend;
|
||||
pub mod icc;
|
||||
pub mod space;
|
||||
|
|
|
|||
|
|
@ -610,6 +610,9 @@ mod tests {
|
|||
let g = run_fx("ec.keying.keylight", &[("screenColour", green()), ("screenGain", num(150.0))], plate(), 0.0, EffectEnv::default());
|
||||
assert!(g.img.get(27, 5)[3] < out.img.get(27, 5)[3]);
|
||||
assert_eq!(crate::lookup("Keylight (1.2)").map(|s| s.id), Some("ec.keying.keylight"));
|
||||
// Searches find it by After Effects' name as well as its own.
|
||||
let spec = crate::find("ec.keying.keylight").unwrap();
|
||||
assert!(crate::name_matches(spec, "keylight") && crate::name_matches(spec, "key light") && !crate::name_matches(spec, "blur"));
|
||||
}
|
||||
|
||||
/// Instances saved with the flat layout get their parameters moved into the twirl-downs.
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@
|
|||
//! [`instantiate`]), so parameters animate, take expressions and are addressable like any other
|
||||
//! property.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod audio_fx;
|
||||
mod blur2;
|
||||
mod blur3;
|
||||
|
|
@ -502,14 +504,25 @@ pub fn categories() -> Vec<&'static str> {
|
|||
v
|
||||
}
|
||||
|
||||
/// After Effects' third-party display names of effects we register under a generic name.
|
||||
const ALIASES: &[(&str, &[&str])] = &[("ec.keying.keylight", keylight::KEYLIGHT_ALIASES)];
|
||||
|
||||
/// Other names effect `id` answers to (in [`lookup`] and searches).
|
||||
pub fn aliases(id: &str) -> &'static [&'static str] {
|
||||
ALIASES.iter().find(|(i, _)| *i == id).map_or(&[], |(_, a)| a)
|
||||
}
|
||||
|
||||
/// Search (Effects & Presets, `effect.list`): the display name or one of its [`aliases`]
|
||||
/// contains `query` (lowercase).
|
||||
pub fn name_matches(spec: &EffectSpec, query: &str) -> bool {
|
||||
std::iter::once(spec.name).chain(aliases(spec.id).iter().copied()).any(|n| n.to_lowercase().contains(query))
|
||||
}
|
||||
|
||||
/// Find by id or (case-insensitive) display name.
|
||||
pub fn lookup(name_or_id: &str) -> Option<&'static EffectSpec> {
|
||||
find(name_or_id)
|
||||
.or_else(|| all().into_iter().find(|s| s.name.eq_ignore_ascii_case(name_or_id)))
|
||||
.or_else(|| {
|
||||
// After Effects' third-party display names we register under a generic name.
|
||||
keylight::KEYLIGHT_ALIASES.iter().any(|a| a.eq_ignore_ascii_case(name_or_id)).then(|| find("ec.keying.keylight")).flatten()
|
||||
})
|
||||
.or_else(|| ALIASES.iter().find(|(_, names)| names.iter().any(|a| a.eq_ignore_ascii_case(name_or_id))).and_then(|(id, _)| find(id)))
|
||||
.or_else(|| {
|
||||
// Display names of earlier versions.
|
||||
migrate::EFFECT_NAME_ALIASES.iter().find(|(old, _)| old.eq_ignore_ascii_case(name_or_id)).and_then(|(_, id)| find(id))
|
||||
|
|
@ -551,8 +564,8 @@ pub fn instantiate(spec: &EffectSpec, ids: &mut Ids, instance_name: &str, layer_
|
|||
if matches!(ps.ui, ParamUi::Checkbox | ParamUi::Popup { .. } | ParamUi::Layer) {
|
||||
pr.hold_only = true;
|
||||
}
|
||||
match path {
|
||||
Some(path) => nested_group(&mut g, ids, path).children.push(pr.into()),
|
||||
match path.and_then(|path| nested_group(&mut g, ids, path)) {
|
||||
Some(sub) => sub.children.push(pr.into()),
|
||||
None => g.children.push(pr.into()),
|
||||
}
|
||||
// Layer parameters carry a source choice (Source / Masks / Effects & Masks) unless the
|
||||
|
|
@ -563,8 +576,8 @@ pub fn instantiate(spec: &EffectSpec, ids: &mut Ids, instance_name: &str, layer_
|
|||
if matches!(ps.ui, ParamUi::Layer) && !spec.params.iter().any(|q| q.id == sid) {
|
||||
let mut src = Property::new(ids.alloc(), &layer_source_id(leaf), &format!("{} Source", ps.name), Value::Enum(2)).with_ui(ParamUi::Hidden);
|
||||
src.hold_only = true;
|
||||
match path {
|
||||
Some(path) => nested_group(&mut g, ids, path).children.push(src.into()),
|
||||
match path.and_then(|path| nested_group(&mut g, ids, path)) {
|
||||
Some(sub) => sub.children.push(src.into()),
|
||||
None => g.children.push(src.into()),
|
||||
}
|
||||
}
|
||||
|
|
@ -765,7 +778,7 @@ fn group_name(m: &str) -> &str {
|
|||
}
|
||||
|
||||
/// The nested group at `path` (`borders/autoScale`) under `g`, created on first use.
|
||||
fn nested_group<'a>(g: &'a mut PropGroup, ids: &mut Ids, path: &str) -> &'a mut PropGroup {
|
||||
fn nested_group<'a>(g: &'a mut PropGroup, ids: &mut Ids, path: &str) -> Option<&'a mut PropGroup> {
|
||||
let (first, rest) = match path.split_once('/') {
|
||||
Some((a, b)) => (a, Some(b)),
|
||||
None => (path, None),
|
||||
|
|
@ -773,10 +786,10 @@ fn nested_group<'a>(g: &'a mut PropGroup, ids: &mut Ids, path: &str) -> &'a mut
|
|||
if g.sub(first).is_none() {
|
||||
g.children.push(ids.group(first, group_name(first)).into());
|
||||
}
|
||||
let sub = g.sub_mut(first).expect("just added");
|
||||
let sub = g.sub_mut(first)?;
|
||||
match rest {
|
||||
Some(r) => nested_group(sub, ids, r),
|
||||
None => sub,
|
||||
None => Some(sub),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -192,7 +192,7 @@ fn prop_at<'a>(g: &'a mut PropGroup, id: &str) -> Option<&'a mut Property> {
|
|||
}
|
||||
}
|
||||
|
||||
fn group_at<'a>(g: &'a mut PropGroup, ids: &mut Ids, path: &str) -> &'a mut PropGroup {
|
||||
fn group_at<'a>(g: &'a mut PropGroup, ids: &mut Ids, path: &str) -> Option<&'a mut PropGroup> {
|
||||
let (first, rest) = match path.split_once('/') {
|
||||
Some((a, b)) => (a, Some(b)),
|
||||
None => (path, None),
|
||||
|
|
@ -200,10 +200,10 @@ fn group_at<'a>(g: &'a mut PropGroup, ids: &mut Ids, path: &str) -> &'a mut Prop
|
|||
if g.sub(first).is_none() {
|
||||
g.children.push(ids.group(first, group_name(first)).into());
|
||||
}
|
||||
let sub = g.sub_mut(first).expect("just added");
|
||||
let sub = g.sub_mut(first)?;
|
||||
match rest {
|
||||
Some(r) => group_at(sub, ids, r),
|
||||
None => sub,
|
||||
None => Some(sub),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -288,8 +288,8 @@ pub fn upgrade_instance(spec: &EffectSpec, g: &mut PropGroup, ids: &mut Ids, lay
|
|||
pr
|
||||
}
|
||||
};
|
||||
match path {
|
||||
Some(path) => group_at(g, ids, path).children.push(pr.into()),
|
||||
match path.and_then(|path| group_at(g, ids, path)) {
|
||||
Some(sub) => sub.children.push(pr.into()),
|
||||
None => g.children.push(pr.into()),
|
||||
}
|
||||
}
|
||||
|
|
@ -310,8 +310,8 @@ pub fn upgrade_instance(spec: &EffectSpec, g: &mut PropGroup, ids: &mut Ids, lay
|
|||
src.hold_only = true;
|
||||
src
|
||||
});
|
||||
match path {
|
||||
Some(path) => group_at(g, ids, path).children.push(src.into()),
|
||||
match path.and_then(|path| group_at(g, ids, path)) {
|
||||
Some(sub) => sub.children.push(src.into()),
|
||||
None => g.children.push(src.into()),
|
||||
}
|
||||
}
|
||||
|
|
@ -363,7 +363,7 @@ mod tests {
|
|||
let removed = spec.params.last().unwrap().id;
|
||||
g.children.retain(|c| c.match_id() != removed);
|
||||
let popup = spec.params.iter().find(|p| matches!(p.ui, ParamUi::Popup { .. })).expect("Gaussian Blur has a popup");
|
||||
let ParamUi::Popup { options } = &popup.ui else { unreachable!() };
|
||||
let ParamUi::Popup { options } = &popup.ui else { panic!("not a popup") };
|
||||
{
|
||||
let pr = g.get_mut(popup.id).unwrap();
|
||||
pr.name = "Old Name".into();
|
||||
|
|
|
|||
|
|
@ -128,10 +128,14 @@ const fn sp(name: &'static str, prims: [[f64; 2]; 3], white: [f64; 2], tf: Tf) -
|
|||
}
|
||||
|
||||
/// The built-in configuration's colour spaces, in popup order.
|
||||
const ACES2065_1: Space = sp("ACES2065-1", AP0, ACES_WHITE, Tf::Linear);
|
||||
const ACES_CG: Space = sp("ACEScg", AP1, ACES_WHITE, Tf::Linear);
|
||||
const ACES_CCT: Space = sp("ACEScct", AP1, ACES_WHITE, Tf::AcesCct);
|
||||
|
||||
pub const SPACES: &[Space] = &[
|
||||
sp("ACES2065-1", AP0, ACES_WHITE, Tf::Linear),
|
||||
sp("ACEScg", AP1, ACES_WHITE, Tf::Linear),
|
||||
sp("ACEScct", AP1, ACES_WHITE, Tf::AcesCct),
|
||||
ACES2065_1,
|
||||
ACES_CG,
|
||||
ACES_CCT,
|
||||
sp("Linear Rec.709 (sRGB)", REC709, D65, Tf::Linear),
|
||||
sp("sRGB", REC709, D65, Tf::Srgb),
|
||||
sp("Gamma 2.4 Rec.709", REC709, D65, Tf::Gamma(2.4)),
|
||||
|
|
@ -147,6 +151,7 @@ pub fn space_names() -> Vec<&'static str> {
|
|||
SPACES.iter().map(|s| s.name).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn space_by_name(n: &str) -> Option<&'static Space> {
|
||||
SPACES.iter().find(|s| s.name.eq_ignore_ascii_case(n))
|
||||
}
|
||||
|
|
@ -778,7 +783,7 @@ fn ocio_look(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|||
}
|
||||
let (src, dst) = (space_at(ctx.params.e("source")), space_at(ctx.params.e("destination")));
|
||||
let inv = ctx.params.e("direction") == 1;
|
||||
let cct = space_by_name("ACEScct").expect("built-in");
|
||||
let cct = &ACES_CCT;
|
||||
let (a, z) = if inv { (dst, src) } else { (src, dst) };
|
||||
let to_p = Xform::new(a, cct);
|
||||
let from_p = Xform::new(cct, z);
|
||||
|
|
@ -805,8 +810,8 @@ fn profile(ctx: &EffectCtx, i: u32) -> (Space, bool) {
|
|||
ColorSpace::Rec709 => sp("Rec.709", REC709, D65, Tf::Gamma(2.4)),
|
||||
ColorSpace::Rec2020 => sp("Rec.2020", REC2020, D65, Tf::Gamma(2.4)),
|
||||
ColorSpace::DisplayP3 => sp("Display P3", P3, D65, Tf::Srgb),
|
||||
ColorSpace::AcesCg => *space_by_name("ACEScg").expect("built-in"),
|
||||
ColorSpace::Aces2065 => *space_by_name("ACES2065-1").expect("built-in"),
|
||||
ColorSpace::AcesCg => ACES_CG,
|
||||
ColorSpace::Aces2065 => ACES2065_1,
|
||||
// HDR encodings are output spaces, never a working space.
|
||||
ColorSpace::Rec2100Pq | ColorSpace::Rec2100Hlg => sp("Rec.2020", REC2020, D65, Tf::Gamma(2.4)),
|
||||
};
|
||||
|
|
@ -816,8 +821,8 @@ fn profile(ctx: &EffectCtx, i: u32) -> (Space, bool) {
|
|||
2 => (ws(ColorSpace::Rec709), false),
|
||||
3 => (ws(ColorSpace::Rec2020), false),
|
||||
4 => (ws(ColorSpace::DisplayP3), false),
|
||||
5 => (*space_by_name("ACEScg").expect("built-in"), true),
|
||||
_ => (*space_by_name("ACES2065-1").expect("built-in"), true),
|
||||
5 => (ACES_CG, true),
|
||||
_ => (ACES2065_1, true),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1089,7 +1094,7 @@ pub fn color_program(id: &str, ctx: &EffectCtx) -> Option<(Vec<ColorOp>, Straigh
|
|||
if !list.is_empty() {
|
||||
let (src, dst) = (space_at(pr.e("source")), space_at(pr.e("destination")));
|
||||
let inv = pr.e("direction") == 1;
|
||||
let cct = space_by_name("ACEScct").expect("built-in");
|
||||
let cct = &ACES_CCT;
|
||||
let (a, z) = if inv { (dst, src) } else { (src, dst) };
|
||||
if inv {
|
||||
list.reverse();
|
||||
|
|
@ -1157,7 +1162,7 @@ fn curve3(xs: [f64; 3], ys: [f64; 3], v: f64) -> f64 {
|
|||
let mut pairs: Vec<(f64, f64)> = xs.iter().cloned().zip(ys.iter().cloned()).collect();
|
||||
pairs.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
for (x, y) in pairs {
|
||||
if x > pts.last().expect("non-empty").0 + 1e-4 && x < 1.0 - 1e-4 {
|
||||
if pts.last().is_some_and(|l| x > l.0 + 1e-4) && x < 1.0 - 1e-4 {
|
||||
pts.push((x, y));
|
||||
}
|
||||
}
|
||||
|
|
@ -1196,7 +1201,7 @@ pub fn color_stabilizer_maps(ctx: &EffectCtx, b: &Buf) -> Option<[Vec<(f64, f64)
|
|||
let mut pairs: Vec<(f64, f64)> = (0..3).map(|i| (cur[i][k], refv[i][k])).collect();
|
||||
pairs.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
for (x, y) in pairs {
|
||||
if x > v.last().expect("non-empty").0 + 1e-4 && x < 1.0 - 1e-4 {
|
||||
if v.last().is_some_and(|l| x > l.0 + 1e-4) && x < 1.0 - 1e-4 {
|
||||
v.push((x, y));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -686,13 +686,10 @@ fn path_text_passes(ctx: &EffectCtx, b: &Buf) -> Vec<TextPass> {
|
|||
let js = if j_scale != 0.0 { (1.0 + rnd(i, 4) * j_scale).max(0.05) } else { 1.0 };
|
||||
let jb = if j_base != 0.0 { rnd(i, 1) * j_base } else { 0.0 };
|
||||
let left = ink_left(c, true);
|
||||
let set = match polys_by_alpha.iter_mut().find(|(a, _)| (*a - alpha).abs() < 1e-4) {
|
||||
Some((_, v)) => v,
|
||||
None => {
|
||||
polys_by_alpha.push((alpha, Vec::new()));
|
||||
&mut polys_by_alpha.last_mut().expect("pushed").1
|
||||
}
|
||||
};
|
||||
if !polys_by_alpha.iter().any(|(a, _)| (*a - alpha).abs() < 1e-4) {
|
||||
polys_by_alpha.push((alpha, Vec::new()));
|
||||
}
|
||||
let Some((_, set)) = polys_by_alpha.iter_mut().find(|(a, _)| (*a - alpha).abs() < 1e-4) else { continue };
|
||||
for st in parse_glyph(c) {
|
||||
set.push(
|
||||
st.iter()
|
||||
|
|
|
|||
|
|
@ -245,16 +245,17 @@ pub fn end(store: &dyn ConfigStore) {
|
|||
}
|
||||
|
||||
/// After Effects' Increment and Save name: `Intro.ecproj` → `Intro 2.ecproj`, `Intro 2.ecproj`
|
||||
/// → `Intro 3.ecproj` (skipping names that exist).
|
||||
/// → `Intro 3.ecproj` (skipping names that exist; the extension, `.ecprojx` too, is kept).
|
||||
pub fn increment_path(path: &str, exists: impl Fn(&str) -> bool) -> String {
|
||||
let p = Path::new(path);
|
||||
let stem = p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let ext = p.extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_else(|| EXT.to_string());
|
||||
let (base, mut n) = match stem.rsplit_once(' ') {
|
||||
Some((b, n)) if !n.is_empty() && n.chars().all(|c| c.is_ascii_digit()) => (b.to_string(), n.parse::<u32>().unwrap_or(1) + 1),
|
||||
_ => (stem.clone(), 2),
|
||||
};
|
||||
loop {
|
||||
let cand = p.with_file_name(format!("{base} {n}.{EXT}")).to_string_lossy().to_string();
|
||||
let cand = p.with_file_name(format!("{base} {n}.{ext}")).to_string_lossy().to_string();
|
||||
if !exists(&cand) {
|
||||
return cand;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -382,7 +382,7 @@ impl Session {
|
|||
if !job.is_finished() {
|
||||
return false;
|
||||
}
|
||||
let mut job = self.camera_job.take().expect("job");
|
||||
let Some(mut job) = self.camera_job.take() else { return false };
|
||||
if let Some(t) = job.thread.take() {
|
||||
let _ = t.join();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use effectcraft_project::{LayerId, Property, Uid};
|
|||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::prop::{edit_keys, prop_ref};
|
||||
use super::prop::prop_ref;
|
||||
use super::{CommandSpec, bad, comp_id, f_p, has_comp, has_keys, layers_p, merge_p, str_p, time_p};
|
||||
use crate::{EngineError, KeyClip, KeyRef, Result, Session, cmd, query};
|
||||
|
||||
|
|
@ -73,11 +73,19 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let path = str_p(p, "path")?;
|
||||
comp.layer(*targets.first()?)?.props.prop(path).map(|pr| pr.uid)
|
||||
});
|
||||
// Keys copied from several layers, pasted onto as many layers: the first copied layer's keys
|
||||
// go to the first target and so on (else every target gets every copied property).
|
||||
let mut sources: Vec<LayerId> = clip.iter().map(|c| c.layer).collect();
|
||||
sources.dedup();
|
||||
let paired = sources.len() > 1 && sources.len() == targets.len();
|
||||
// (layer, prop uid, clip index)
|
||||
let mut plan: Vec<(LayerId, Uid, usize)> = vec![];
|
||||
for lid in &targets {
|
||||
for (ti, lid) in targets.iter().enumerate() {
|
||||
let Some(l) = comp.layer(*lid) else { continue };
|
||||
for (ci, c) in clip.iter().enumerate() {
|
||||
if paired && sources.get(ti) != Some(&c.layer) {
|
||||
continue;
|
||||
}
|
||||
let Some(sample) = c.keys.first().map(|k| &k.value) else { continue };
|
||||
let target = if clip.len() == 1 {
|
||||
explicit
|
||||
|
|
@ -131,7 +139,14 @@ fn select_all(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let (cid, mut layers) = layers_p(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let mut sel = vec![];
|
||||
if p.get("layers").is_none() && p.get("layer").is_none() && !s.state.selected_props.is_empty() {
|
||||
if let Some(visible) = super::visible_p(p) {
|
||||
// Ctrl+Alt+A in the Timeline: every key it shows (its revealed properties).
|
||||
for (lid, uid) in visible {
|
||||
if let Some(pr) = comp.layer(lid).and_then(|l| l.props.find(uid)) {
|
||||
sel.extend(pr.keys.iter().map(|k| KeyRef { layer: lid, prop: uid, time: k.time }));
|
||||
}
|
||||
}
|
||||
} else if p.get("layers").is_none() && p.get("layer").is_none() && !s.state.selected_props.is_empty() {
|
||||
for (lid, uid) in &s.state.selected_props {
|
||||
if let Some(pr) = comp.layer(*lid).and_then(|l| l.props.find(*uid)) {
|
||||
sel.extend(pr.keys.iter().map(|k| KeyRef { layer: *lid, prop: *uid, time: k.time }));
|
||||
|
|
@ -145,6 +160,37 @@ fn select_all(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
l.props.walk("", &mut |_, pr| sel.extend(pr.keys.iter().map(|k| KeyRef { layer: l.id, prop: pr.uid, time: k.time })));
|
||||
}
|
||||
}
|
||||
// Keys of locked layers can't be selected.
|
||||
sel.retain(|k| comp.layer(k.layer).is_some_and(|l| !l.switches.locked));
|
||||
s.state.selected_keys = sel;
|
||||
Ok(json!(s.state.selected_keys.len()))
|
||||
}
|
||||
|
||||
/// The keyframe context menu's Select Equal / Previous / Following Keyframes: in every property
|
||||
/// with a selected key, its keys with the same value as a selected one / before the first
|
||||
/// selected one / after the last selected one (the selected keys stay selected).
|
||||
fn select_related(s: &mut Session, which: &str) -> Result<Value> {
|
||||
let comp = s.active_comp().ok_or(EngineError::NoComp)?;
|
||||
let mut sel = s.state.selected_keys.clone();
|
||||
let mut props: Vec<(LayerId, Uid)> = sel.iter().map(|k| (k.layer, k.prop)).collect();
|
||||
props.sort();
|
||||
props.dedup();
|
||||
for (lid, uid) in props {
|
||||
let Some(pr) = comp.layer(lid).and_then(|l| l.props.find(uid)) else { continue };
|
||||
let picked: Vec<&Keyframe> = pr.keys.iter().filter(|k| sel.iter().any(|r| r.layer == lid && r.prop == uid && r.time == k.time)).collect();
|
||||
let (Some(first), Some(last)) = (picked.iter().map(|k| k.time).min(), picked.iter().map(|k| k.time).max()) else { continue };
|
||||
for k in &pr.keys {
|
||||
let hit = match which {
|
||||
"equal" => picked.iter().any(|p| p.value == k.value),
|
||||
"previous" => k.time < first,
|
||||
_ => k.time > last,
|
||||
};
|
||||
let r = KeyRef { layer: lid, prop: uid, time: k.time };
|
||||
if hit && !sel.contains(&r) {
|
||||
sel.push(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
s.state.selected_keys = sel;
|
||||
Ok(json!(s.state.selected_keys.len()))
|
||||
}
|
||||
|
|
@ -153,11 +199,8 @@ fn select_all(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
fn nudge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let frames = p.get("frames").and_then(Value::as_i64).ok_or_else(|| bad("keys.nudge", "missing `frames`"))?;
|
||||
let comp = s.active_comp().ok_or(EngineError::NoComp)?;
|
||||
let d = Tick(comp.frame_duration().0 * frames);
|
||||
edit_keys(s, "Nudge Keyframes", merge_p(p), move |_, _, t| {
|
||||
*t += d;
|
||||
true
|
||||
})
|
||||
let d = Tick(comp.frame_duration().0.saturating_mul(frames));
|
||||
super::prop::shift_keys(s, "Nudge Keyframes", merge_p(p), d)
|
||||
}
|
||||
|
||||
/// Edit one key directly: move it in time and/or set its value (Graph Editor drags).
|
||||
|
|
@ -386,7 +429,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
vec![
|
||||
cmd!("keys.copy", "Copy Keyframes", [], None, "{}", has_keys, copy),
|
||||
cmd!("keys.paste", "Paste Keyframes", [], None, "{layers?, prop?|path?, time?}", has_key_clip, paste),
|
||||
cmd!("keys.selectAll", "Select All Keyframes", [], Some("Cmd+Alt+A"), "{layers?}", keys_or_layers, select_all),
|
||||
cmd!(
|
||||
"keys.selectAll",
|
||||
"Select All Keyframes",
|
||||
[],
|
||||
Some("Cmd+Alt+A"),
|
||||
"{layers?, visible?: [{layer, prop}] (every key of these properties)}",
|
||||
keys_or_layers,
|
||||
select_all
|
||||
),
|
||||
cmd!("keys.nudge", "Nudge Keyframes", [], None, "{frames, merge?}", has_keys, nudge),
|
||||
cmd!("keys.nudgeForward", "Move Keyframes 1 Frame Later", [], Some("Alt+ArrowRight"), "{}", has_keys, |s, _| nudge(s, &json!({"frames": 1}))),
|
||||
cmd!("keys.nudgeBackward", "Move Keyframes 1 Frame Earlier", [], Some("Alt+ArrowLeft"), "{}", has_keys, |s, _| nudge(s, &json!({"frames": -1}))),
|
||||
|
|
@ -399,6 +450,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
&json!({"frames": -10})
|
||||
)),
|
||||
cmd!("keys.set", "Edit Keyframe", [], None, "{layer?, path|prop, time (layer s), newTime?, value?, merge?}", has_comp, set_key),
|
||||
cmd!("keys.selectEqual", "Select Equal Keyframes", [], None, "{}", has_keys, |s, _| select_related(s, "equal")),
|
||||
cmd!("keys.selectPrevious", "Select Previous Keyframes", [], None, "{}", has_keys, |s, _| select_related(s, "previous")),
|
||||
cmd!("keys.selectFollowing", "Select Following Keyframes", [], None, "{}", has_keys, |s, _| select_related(s, "following")),
|
||||
cmd!(
|
||||
"keys.setEase",
|
||||
"Set Keyframe Ease",
|
||||
|
|
|
|||
|
|
@ -128,7 +128,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_json()).map_err(EngineError::Other)?;
|
||||
let xml = crate::xml_project::to_xml(&s.project.to_file_json()?).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()}))
|
||||
|
|
|
|||
|
|
@ -162,7 +162,7 @@ fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let base = masks.children.len();
|
||||
for k in 0..n_masks {
|
||||
// Depth decides the mode: outlines add, holes subtract.
|
||||
let first = frames.iter().find_map(|f| f.get(k)).expect("mask");
|
||||
let Some(first) = frames.iter().find_map(|f| f.get(k)) else { continue };
|
||||
let mode = if first.depth % 2 == 0 { MaskMode::Add } else { MaskMode::Subtract };
|
||||
let mode = if k == 0 { MaskMode::Add } else { mode };
|
||||
let mut g = build::mask(
|
||||
|
|
@ -173,14 +173,16 @@ fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
build::MASK_COLORS[(base + k) % build::MASK_COLORS.len()],
|
||||
);
|
||||
if frames.len() > 1 {
|
||||
let path = g.get_mut("path").expect("path");
|
||||
for (f, lt) in frames.iter().zip(&layer_times) {
|
||||
if let Some(tr) = f.get(k) {
|
||||
effectcraft_keyframe::set_key(&mut path.keys, Keyframe::new(*lt, KV::Path(tr.path.clone())));
|
||||
if let Some(path) = g.get_mut("path") {
|
||||
for (f, lt) in frames.iter().zip(&layer_times) {
|
||||
if let Some(tr) = f.get(k) {
|
||||
effectcraft_keyframe::set_key(&mut path.keys, Keyframe::new(*lt, KV::Path(tr.path.clone())));
|
||||
}
|
||||
}
|
||||
}
|
||||
let op = g.get_mut("opacity").expect("opacity");
|
||||
if frames.iter().any(|f| f.get(k).is_none()) {
|
||||
if let Some(op) = g.get_mut("opacity")
|
||||
&& frames.iter().any(|f| f.get(k).is_none())
|
||||
{
|
||||
for (f, lt) in frames.iter().zip(&layer_times) {
|
||||
let mut key = Keyframe::new(*lt, KV::Scalar(if f.get(k).is_some() { 100.0 } else { 0.0 }));
|
||||
key.in_interp = Interp::Hold;
|
||||
|
|
|
|||
|
|
@ -12,6 +12,14 @@ pub(crate) fn rate_p(p: &Value) -> Option<FrameRate> {
|
|||
f_p(p, "frameRate").or(f_p(p, "fps")).map(FrameRate::from_f64)
|
||||
}
|
||||
|
||||
/// A zero, negative or tiny rate would make a zero-length frame (and divide by zero later).
|
||||
fn check_rate(p: &Value, cmd: &str) -> Result<()> {
|
||||
match f_p(p, "frameRate").or(f_p(p, "fps")) {
|
||||
Some(f) if !(0.001..=1000.0).contains(&f) => Err(bad(cmd, "frameRate: fps from 0.001 to 1000")),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_settings(c: &mut Comp, p: &Value) {
|
||||
let (ow, oh) = (c.width, c.height);
|
||||
if let Some(w) = p.get("width").and_then(Value::as_u64) {
|
||||
|
|
@ -90,9 +98,16 @@ fn apply_settings(c: &mut Comp, p: &Value) {
|
|||
if let Some(n) = p.get("adaptiveSampleLimit").and_then(Value::as_u64) {
|
||||
c.motion_blur_adaptive_limit = (n as u32).clamp(16, 256);
|
||||
}
|
||||
if let Some(b) = p.get("preserveFrameRate").and_then(Value::as_bool) {
|
||||
c.preserve_frame_rate = b;
|
||||
}
|
||||
if let Some(b) = p.get("preserveResolution").and_then(Value::as_bool) {
|
||||
c.preserve_resolution = b;
|
||||
}
|
||||
}
|
||||
|
||||
fn new_comp(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
check_rate(p, "comp.new")?;
|
||||
let name = str_p(p, "name").unwrap_or("Comp 1").to_string();
|
||||
let rate = rate_p(p).unwrap_or(FrameRate::FPS_29_97);
|
||||
let mut c = Comp::new(1920, 1080, rate, rate.snap_nearest(Tick::from_seconds_f64(10.0)));
|
||||
|
|
@ -119,6 +134,7 @@ fn new_comp(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
}
|
||||
|
||||
fn settings(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
check_rate(p, "comp.settings")?;
|
||||
let cid = comp_id(s, p)?;
|
||||
s.edit("Composition Settings", None, |proj, _| {
|
||||
if let Some(n) = str_p(p, "name")
|
||||
|
|
@ -252,7 +268,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"New Composition...",
|
||||
["Composition"],
|
||||
Some("Cmd+N"),
|
||||
"{name?, width?, height?, frameRate?, duration? (s), startTime? (s) | startTimecode?, background? [r,g,b]|#hex, pixelAspect?, shutterAngle?, shutterPhase?, motionBlurSamples?, adaptiveSampleLimit? (16–256), renderer? classic3D|advanced3D, anchor?, open?}",
|
||||
"{name?, width?, height?, frameRate?, duration? (s), startTime? (s) | startTimecode?, background? [r,g,b]|#hex, pixelAspect?, shutterAngle?, shutterPhase?, motionBlurSamples?, adaptiveSampleLimit? (16–256), preserveFrameRate?: bool, preserveResolution?: bool, renderer? classic3D|advanced3D, anchor?, open?}",
|
||||
always,
|
||||
new_comp
|
||||
),
|
||||
|
|
@ -261,7 +277,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Composition Settings...",
|
||||
["Composition"],
|
||||
Some("Cmd+K"),
|
||||
"{comp?, name?, width?, height?, anchor? 0-8 (resize anchor, 4 = center), frameRate?, duration?, startTime? (s) | startTimecode?, background?, shutterAngle?, shutterPhase?, motionBlurSamples?, adaptiveSampleLimit? (16–256), pixelAspect?, renderer? classic3D|advanced3D}",
|
||||
"{comp?, name?, width?, height?, anchor? 0-8 (resize anchor, 4 = center), frameRate?, duration?, startTime? (s) | startTimecode?, background?, shutterAngle?, shutterPhase?, motionBlurSamples?, adaptiveSampleLimit? (16–256), preserveFrameRate?: bool (nested or in the render queue it shows only its own frames), preserveResolution?: bool (nested, it renders at full size), pixelAspect?, renderer? classic3D|advanced3D}",
|
||||
has_comp,
|
||||
settings
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
//! Edit menu.
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_project::{Expression, ItemKind, Layer, LayerId, LayerSource};
|
||||
use effectcraft_project::{Comp, Expression, ItemKind, Layer, LayerId, LayerSource, TrackMatte};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
|
|
@ -60,8 +60,9 @@ fn select_all(s: &mut Session, _: &Value) -> Result<Value> {
|
|||
if let Some(r) = super::puppet::select_all_of_kind(s) {
|
||||
return Ok(r);
|
||||
}
|
||||
// Locked layers (and shy layers while they are hidden) can't be selected.
|
||||
if let Some(c) = s.active_comp() {
|
||||
s.state.selected_layers = c.layers.iter().map(|l| l.id).collect();
|
||||
s.state.selected_layers = c.layers.iter().filter(|l| super::selectable(c, l)).map(|l| l.id).collect();
|
||||
}
|
||||
Ok(json!(s.state.selected_layers.len()))
|
||||
}
|
||||
|
|
@ -73,6 +74,17 @@ fn deselect_all(s: &mut Session, _: &Value) -> Result<Value> {
|
|||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// Copied layers keep their parent and track matte: the copy of that layer when it was copied
|
||||
/// too, else the layer itself when it is in `comp`, else none. `map`: (original, copy).
|
||||
fn relink_copies(comp: &mut Comp, map: &[(LayerId, LayerId)]) {
|
||||
let present: Vec<LayerId> = comp.layers.iter().map(|l| l.id).collect();
|
||||
let link = |id: LayerId| map.iter().find(|(o, _)| *o == id).map(|(_, c)| *c).or(present.contains(&id).then_some(id));
|
||||
for l in comp.layers.iter_mut().filter(|l| map.iter().any(|(_, c)| *c == l.id)) {
|
||||
l.parent = l.parent.and_then(link);
|
||||
l.track_matte = l.track_matte.and_then(|m| link(m.layer).map(|layer| TrackMatte { layer, ..m }));
|
||||
}
|
||||
}
|
||||
|
||||
/// Fresh ids for a copied layer (and its property uids).
|
||||
pub(crate) fn reid(layer: &mut Layer, next: &mut u64) {
|
||||
layer.id = LayerId(*next);
|
||||
|
|
@ -98,14 +110,17 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let mut next = proj.next_id;
|
||||
let comp = proj.comp_mut(cid).ok_or(crate::EngineError::NoComp)?;
|
||||
let mut created = vec![];
|
||||
let mut map = vec![];
|
||||
for id in &ids {
|
||||
let Some(i) = comp.layers.iter().position(|l| l.id == *id) else { continue };
|
||||
let mut l = comp.layers[i].clone();
|
||||
reid(&mut l, &mut next);
|
||||
l.name = comp.unique_layer_name(&l.name);
|
||||
created.push(l.id);
|
||||
map.push((*id, l.id));
|
||||
comp.layers.insert(i, l);
|
||||
}
|
||||
relink_copies(comp, &map);
|
||||
proj.next_id = next;
|
||||
st.selected_layers = created.clone();
|
||||
Ok(created)
|
||||
|
|
@ -133,12 +148,20 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
return s.execute("effect.remove", json!({}));
|
||||
}
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
let ids = super::unlocked(s, cid, ids, "edit.clear")?;
|
||||
// Children of deleted layers are unparented where they are (like After Effects).
|
||||
let comp = s.project.comp(cid).ok_or(crate::EngineError::NoComp)?;
|
||||
let orphans: Vec<LayerId> = comp.layers.iter().filter(|l| !ids.contains(&l.id) && l.parent.is_some_and(|p| ids.contains(&p))).map(|l| l.id).collect();
|
||||
let fixes = super::layer::parent_fixes(s, cid, &orphans, None);
|
||||
s.edit("Clear", None, |proj, st| {
|
||||
let comp = proj.comp_mut(cid).ok_or(crate::EngineError::NoComp)?;
|
||||
comp.layers.retain(|l| !ids.contains(&l.id));
|
||||
for l in &mut comp.layers {
|
||||
if l.parent.is_some_and(|p| ids.contains(&p)) {
|
||||
l.parent = None;
|
||||
if let Some(f) = fixes.iter().find(|f| f.layer == l.id) {
|
||||
f.apply(l);
|
||||
}
|
||||
}
|
||||
if l.track_matte.is_some_and(|m| ids.contains(&m.layer)) {
|
||||
l.track_matte = None;
|
||||
|
|
@ -276,46 +299,6 @@ fn copy_expression_only(s: &mut Session, _: &Value) -> Result<Value> {
|
|||
}
|
||||
|
||||
/// Paste the keyframe clipboard at the CTI onto the selected layers (same property paths).
|
||||
fn paste_keys(s: &mut Session, reversed: bool) -> Result<Value> {
|
||||
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
|
||||
let ids = s.state.selected_layers.clone();
|
||||
if ids.is_empty() {
|
||||
return Err(EngineError::Other("select a layer to paste keyframes into".into()));
|
||||
}
|
||||
let clip = s.state.key_clipboard.clone();
|
||||
let t = s.time();
|
||||
let n = s.edit(if reversed { "Paste Reversed Keyframes" } else { "Paste Keyframes" }, None, |proj, st| {
|
||||
let mut n = 0;
|
||||
st.selected_keys.clear();
|
||||
for lid in &ids {
|
||||
let l = super::layer_mut(proj, cid, *lid)?;
|
||||
let lt = l.layer_time(t);
|
||||
for c in &clip {
|
||||
let Some(pr) = l.props.prop_mut(&c.path) else { continue };
|
||||
let span = c.keys.last().map(|k| k.time).unwrap_or(Tick::ZERO);
|
||||
for k in &c.keys {
|
||||
let mut k = k.clone();
|
||||
if std::mem::discriminant(&k.value) != std::mem::discriminant(&pr.value) {
|
||||
continue;
|
||||
}
|
||||
if reversed {
|
||||
k.time = span - k.time;
|
||||
std::mem::swap(&mut k.in_interp, &mut k.out_interp);
|
||||
std::mem::swap(&mut k.in_ease, &mut k.out_ease);
|
||||
std::mem::swap(&mut k.spatial_in, &mut k.spatial_out);
|
||||
}
|
||||
k.time += lt;
|
||||
st.selected_keys.push(crate::KeyRef { layer: *lid, prop: pr.uid, time: k.time });
|
||||
effectcraft_keyframe::set_key(&mut pr.keys, k);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(n)
|
||||
})?;
|
||||
Ok(json!({"keys": n}))
|
||||
}
|
||||
|
||||
fn paste_links(s: &mut Session, clip: LinkClip) -> Result<Value> {
|
||||
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
|
||||
let ids = s.state.selected_layers.clone();
|
||||
|
|
@ -374,14 +357,16 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let comp = proj.comp_mut(cid).ok_or(crate::EngineError::NoComp)?;
|
||||
let at = st.selected_layers.first().and_then(|id| comp.layers.iter().position(|l| l.id == *id)).unwrap_or(0);
|
||||
let mut created = vec![];
|
||||
let mut map = vec![];
|
||||
for (k, mut l) in clip.into_iter().enumerate() {
|
||||
let original = l.id;
|
||||
reid(&mut l, &mut next);
|
||||
l.name = comp.unique_layer_name(&l.name);
|
||||
l.parent = None;
|
||||
l.track_matte = None;
|
||||
created.push(l.id);
|
||||
map.push((original, l.id));
|
||||
comp.layers.insert(at + k, l);
|
||||
}
|
||||
relink_copies(comp, &map);
|
||||
proj.next_id = next;
|
||||
st.selected_layers = created.clone();
|
||||
Ok(created)
|
||||
|
|
@ -445,8 +430,34 @@ fn label(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
fn paste_reversed(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
paste_keys(s, true)
|
||||
/// Edit ▸ Paste Reversed Keyframes: the copied keys mirrored in time over their whole span (the
|
||||
/// last key first, incoming and outgoing eases swapped), pasted like Edit ▸ Paste
|
||||
/// (`keys.paste`: at the current time, into the selected or the same properties).
|
||||
fn paste_reversed(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let original = s.state.key_clipboard.clone();
|
||||
let span = original.iter().flat_map(|c| c.keys.iter().map(|k| k.time)).max().unwrap_or(Tick::ZERO);
|
||||
s.state.key_clipboard = original
|
||||
.iter()
|
||||
.map(|c| crate::KeyClip {
|
||||
keys: c
|
||||
.keys
|
||||
.iter()
|
||||
.rev()
|
||||
.map(|k| {
|
||||
let mut k = k.clone();
|
||||
k.time = span - k.time;
|
||||
std::mem::swap(&mut k.in_interp, &mut k.out_interp);
|
||||
std::mem::swap(&mut k.in_ease, &mut k.out_ease);
|
||||
std::mem::swap(&mut k.spatial_in, &mut k.spatial_out);
|
||||
k
|
||||
})
|
||||
.collect(),
|
||||
..c.clone()
|
||||
})
|
||||
.collect();
|
||||
let r = s.execute("keys.paste", p.clone());
|
||||
s.state.key_clipboard = original;
|
||||
r
|
||||
}
|
||||
|
||||
/// Lift (leave a gap) or extract (close the gap) the work area from the selected layers (all
|
||||
|
|
@ -544,6 +555,7 @@ fn purge_caches(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let disk = matches!(what.as_str(), "all" | "memoryAndDisk" | "disk");
|
||||
if memory {
|
||||
s.layer_cache.clear();
|
||||
s.footage.purge();
|
||||
}
|
||||
let mut disk_entries = 0;
|
||||
if disk && let Some(dc) = &s.disk_cache {
|
||||
|
|
@ -556,7 +568,11 @@ fn purge_caches(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
if matches!(what.as_str(), "all" | "image" | "memory" | "memoryAndDisk") {
|
||||
effectcraft_effects::roto::purge();
|
||||
}
|
||||
s.events.push(crate::Event::PurgeCaches);
|
||||
// The viewers' RAM preview goes with the memory purges (and the 3D one, whose renders it
|
||||
// holds); purging the disk cache or the snapshot leaves it.
|
||||
if memory || what == "3d" {
|
||||
s.events.push(crate::Event::PurgeCaches);
|
||||
}
|
||||
s.toast(format!("Purged {what} cache"));
|
||||
Ok(json!({"purged": what, "diskEntries": disk_entries}))
|
||||
}
|
||||
|
|
@ -637,7 +653,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
),
|
||||
cmd!("edit.copyExpressionOnly", "Copy Expression Only", ["Edit"], None, "{}", has_props_or_layers, copy_expression_only),
|
||||
cmd!("edit.paste", "Paste", ["Edit"], Some("Cmd+V"), "{} (layers, keyframes at the CTI, or property links / expressions)", has_clip, paste),
|
||||
cmd!("edit.pasteReversedKeyframes", "Paste Reversed Keyframes", ["Edit"], None, "{}", has_key_clip, paste_reversed),
|
||||
cmd!("edit.pasteReversedKeyframes", "Paste Reversed Keyframes", ["Edit"], None, "{layers?, prop?|path?, time?}", has_key_clip, paste_reversed),
|
||||
cmd!("edit.clear", "Clear", ["Edit"], Some("Delete"), "{layers?}", layers_or_keys, delete),
|
||||
cmd!("edit.duplicate", "Duplicate", ["Edit"], Some("Cmd+D"), "{layers?}", has_layers, duplicate),
|
||||
cmd!("edit.splitLayer", "Split Layer", ["Edit"], Some("Cmd+Shift+D"), "{layers?}", has_layers, split),
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ use effectcraft_project::build::Ids;
|
|||
use effectcraft_project::{GroupKind, LayerId, PropGroup, Uid};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, b_p, bad, has_layers, layer_mut, layers_p, str_p};
|
||||
use super::{CommandSpec, b_p, bad, f_p, has_layers, layer_mut, layers_p, str_p};
|
||||
use crate::{EngineError, Result, Session, cmd};
|
||||
|
||||
/// `effect.plugins.list`: the registered plug-in effects (WebAssembly or Rust).
|
||||
|
|
@ -132,6 +132,52 @@ fn find_fx(s: &Session, p: &Value, cmd: &str) -> Result<(effectcraft_project::It
|
|||
Ok((cid, lid, g.uid))
|
||||
}
|
||||
|
||||
/// A colour parameter's eyedropper on a keyer (Effect Controls): the colour of the effect's input
|
||||
/// (the layer's pixels before this effect, so a keyed screen can still be picked) at a
|
||||
/// layer-space point, set on the parameter. `average` takes the 5 × 5 pixels around it.
|
||||
fn pick_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "effect.pickColor";
|
||||
let (cid, lid, fx_uid) = find_fx(s, p, cmd)?;
|
||||
let (Some(x), Some(y)) = (f_p(p, "x"), f_p(p, "y")) else { return Err(bad(cmd, "missing `x` / `y` (layer pixels)")) };
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
|
||||
let fx = layer.effects().ok_or_else(|| bad(cmd, "no effects"))?;
|
||||
let index = fx.groups().position(|g| g.uid == fx_uid).ok_or_else(|| bad(cmd, "no such effect"))?;
|
||||
let g = fx.groups().nth(index).ok_or_else(|| bad(cmd, "no such effect"))?;
|
||||
let prop = match (p.get("prop").and_then(Value::as_u64), str_p(p, "param")) {
|
||||
(Some(uid), _) => g.find(uid),
|
||||
(None, Some(path)) => g.prop(path),
|
||||
_ => return Err(bad(cmd, "missing `param` (e.g. `screenColour`) or `prop` (uid)")),
|
||||
}
|
||||
.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 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);
|
||||
let buf = r.layer_input(&ctx, layer, index).ok_or_else(|| bad(cmd, "this layer has no pixels"))?;
|
||||
let (px, py) = buf.to_px([x, y]);
|
||||
let (cx, cy) = (px.floor() as i64, py.floor() as i64);
|
||||
let rad = if b_p(p, "average") == Some(true) { 2 } else { 0 };
|
||||
let mut sum = [0.0f32; 4];
|
||||
for yy in cy.saturating_sub(rad)..=cy.saturating_add(rad) {
|
||||
for xx in cx.saturating_sub(rad)..=cx.saturating_add(rad) {
|
||||
// Outside the layer reads as transparent.
|
||||
let c = buf.img.get(xx, yy);
|
||||
(0..4).for_each(|i| sum[i] += c[i]);
|
||||
}
|
||||
}
|
||||
if sum[3] <= 1e-6 {
|
||||
return Err(bad(cmd, "nothing to sample there (transparent or outside the layer)"));
|
||||
}
|
||||
// Straight colour of the (averaged) premultiplied pixels.
|
||||
let color: Vec<f64> = (0..3).map(|i| (sum[i] / sum[3]).clamp(0.0, 1.0) as f64).chain([1.0]).collect();
|
||||
let uid = prop.uid;
|
||||
s.execute("prop.set", json!({"comp": cid.0, "layer": lid.0, "prop": uid, "value": color}))?;
|
||||
Ok(json!({"prop": uid, "color": color}))
|
||||
}
|
||||
|
||||
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = super::comp_id(s, p)?;
|
||||
let fx = effects_p(s, p, "effect.remove")?;
|
||||
|
|
@ -355,11 +401,11 @@ fn last(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
}
|
||||
|
||||
fn list(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
let filter = str_p(p, "filter").map(str::to_ascii_lowercase);
|
||||
let filter = str_p(p, "filter").map(str::to_lowercase);
|
||||
let v: Vec<Value> = effectcraft_effects::all()
|
||||
.into_iter()
|
||||
.filter(|e| {
|
||||
filter.as_ref().is_none_or(|f| e.name.to_ascii_lowercase().contains(f) || e.id.contains(f.as_str()) || e.category.to_ascii_lowercase().contains(f))
|
||||
filter.as_ref().is_none_or(|f| effectcraft_effects::name_matches(e, f) || e.id.contains(f.as_str()) || e.category.to_ascii_lowercase().contains(f))
|
||||
})
|
||||
.map(|e| {
|
||||
json!({"id": e.id, "name": e.name, "category": e.category, "gpu": e.gpu, "float": e.float, "params": e.params.iter().map(|p| json!({"id": p.id, "name": p.name, "default": p.default.to_json()})).collect::<Vec<_>>()})
|
||||
|
|
@ -371,6 +417,15 @@ fn list(_: &mut Session, p: &Value) -> Result<Value> {
|
|||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("effect.apply", "Apply Effect", [], None, "{effect: id|name (e.g. Gaussian Blur), layers?}", has_layers, apply),
|
||||
cmd!(
|
||||
"effect.pickColor",
|
||||
"Pick Effect Colour",
|
||||
[],
|
||||
None,
|
||||
"{layer?, effect?: index|uid|name, param: id (e.g. screenColour) | prop: uid, x, y (layer px), average?: bool (5×5), time?} — the colour of the effect's input there",
|
||||
has_layers,
|
||||
pick_color
|
||||
),
|
||||
cmd!("effect.applyLast", "Last Effect", ["Effect"], Some("Cmd+Alt+Shift+E"), "{layers?}", has_layers, last),
|
||||
cmd!("effect.removeAll", "Remove All", ["Effect"], Some("Cmd+Shift+E"), "{layers?}", has_layers, remove_all),
|
||||
cmd!("effect.remove", "Remove Effect", [], None, "{layer?, effect: index|uid|name}", has_layers, remove),
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
//! Language menu (the ⓕ button next to an expression), written from the public Expression
|
||||
//! Language Reference.
|
||||
|
||||
use effectcraft_project::{ItemId, Layer, Property};
|
||||
use effectcraft_project::{Expression, ItemId, Layer, Property};
|
||||
use effectcraft_render::EvalCtx;
|
||||
use effectcraft_time::Tick;
|
||||
use serde::Serialize;
|
||||
|
|
@ -45,14 +45,13 @@ pub fn errors(s: &Session, cid: ItemId, t: Tick) -> Vec<ExprError> {
|
|||
let ctx = EvalCtx { footage: Some(s.footage.as_ref()), expr: s.expr.as_deref(), ..EvalCtx::new(&s.project, cid, comp, t) };
|
||||
let mut out = vec![];
|
||||
for (i, l) in comp.layers.iter().enumerate() {
|
||||
let mut props: Vec<&Property> = vec![];
|
||||
let mut props: Vec<(&Property, &Expression)> = vec![];
|
||||
l.props.walk("", &mut |_, p| {
|
||||
if p.expr.as_ref().is_some_and(|e| !e.text.trim().is_empty()) {
|
||||
props.push(p);
|
||||
if let Some(e) = p.expr.as_ref().filter(|e| !e.text.trim().is_empty()) {
|
||||
props.push((p, e));
|
||||
}
|
||||
});
|
||||
for p in props {
|
||||
let e = p.expr.as_ref().expect("filtered");
|
||||
for (p, e) in props {
|
||||
let msg = if e.enabled {
|
||||
match &ctx.expr {
|
||||
Some(h) => h.eval(&ctx, l, p, &p.value_at(l.layer_time(t))).err(),
|
||||
|
|
|
|||
|
|
@ -42,21 +42,35 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let text = String::from_utf8(bytes).map_err(|_| EngineError::Other("not a text project file".into()))?;
|
||||
// XML copies (File ▸ Save a Copy As XML…) open like the JSON project.
|
||||
let text = if crate::xml_project::is_xml(&text) { crate::xml_project::from_xml(&text).map_err(EngineError::Other)? } else { text };
|
||||
let proj = Project::from_json(&text)?;
|
||||
let proj = Project::from_json(&text).map_err(|e| EngineError::Other(format!("{path} is not a project EffectCraft can open: {e}")))?;
|
||||
s.replace_project(proj, Some(path.to_string()));
|
||||
s.note_project_path(path);
|
||||
// Written by a newer EffectCraft: what this version doesn't know would be lost on saving.
|
||||
let saved_by = effectcraft_project::saved_by(&text);
|
||||
if let Some(v) = saved_by.as_deref().filter(|v| effectcraft_project::is_newer_version(v, effectcraft_project::APP_VERSION)) {
|
||||
s.toast(format!(
|
||||
"This project was saved by EffectCraft {v}, newer than this version ({}). Settings this version doesn't know are lost if you save it.",
|
||||
effectcraft_project::APP_VERSION
|
||||
));
|
||||
}
|
||||
// Lazy open: footage is checked in the background (Progress panel), not before the
|
||||
// project shows.
|
||||
if s.check_footage_on_open {
|
||||
crate::footage_check::start(s, None, false)?;
|
||||
}
|
||||
Ok(json!({"path": path}))
|
||||
Ok(json!({"path": path, "savedBy": saved_by}))
|
||||
}
|
||||
|
||||
/// The project file's text for `path`: `.ecproj` JSON, or XML for `.ecprojx` / `.xml` (a
|
||||
/// 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()?;
|
||||
if crate::xml_project::is_xml_path(path) { crate::xml_project::to_xml(&json).map_err(EngineError::Other) } else { Ok(json) }
|
||||
}
|
||||
|
||||
fn save_to(s: &mut Session, path: &str) -> Result<Value> {
|
||||
let json = s.project.to_json();
|
||||
// A project opened from an XML copy saves as XML again.
|
||||
let json = if crate::xml_project::is_xml_path(path) { crate::xml_project::to_xml(&json).map_err(EngineError::Other)? } else { json };
|
||||
let json = file_text(s, path)?;
|
||||
s.services.write_file(path, json.as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
|
||||
s.path = Some(path.to_string());
|
||||
s.mark_saved();
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ use effectcraft_project::{AlphaMode, Comp, Footage, FootageKind, ItemId, ItemKin
|
|||
use effectcraft_time::{FrameRate, Tick};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, f_p, has_comp, has_project_selection, str_p};
|
||||
use super::{CommandSpec, always, b_p, bad, f_p, has_comp, has_project_selection, str_p};
|
||||
use crate::{EngineError, Result, Session, cmd};
|
||||
|
||||
fn has_footage_selection(s: &Session) -> std::result::Result<(), String> {
|
||||
|
|
@ -77,7 +77,7 @@ fn close_project(s: &mut Session, _: &Value) -> Result<Value> {
|
|||
|
||||
fn save_copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = str_p(p, "path").ok_or_else(|| bad("file.saveCopy", "missing `path`"))?;
|
||||
let json = s.project.to_json();
|
||||
let json = super::file::file_text(s, path)?;
|
||||
s.services.write_file(path, json.as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
|
||||
Ok(json!({"path": path, "bytes": json.len()}))
|
||||
}
|
||||
|
|
@ -150,29 +150,88 @@ fn import_solid(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(json!({"item": id.0}))
|
||||
}
|
||||
|
||||
/// File ▸ New Comp from Selection. Each selected item becomes a composition of its size, pixel
|
||||
/// aspect, frame rate and duration holding it as a layer; with `single`, one composition holds
|
||||
/// them all (first selected on top), with the settings of item `dimensionsFrom` (an index into
|
||||
/// the selection, default 0), as long as the longest item, or with `sequence` the layers one
|
||||
/// after another (Sequence Layers: `overlap`, `overlapDuration` seconds, `transition`).
|
||||
/// `duration`: seconds for stills (default: Settings ▸ Import ▸ Still Footage when it is a
|
||||
/// duration, else 10 s).
|
||||
/// `addToRenderQueue` queues the new compositions. One undo step.
|
||||
fn new_comp_from_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let items: Vec<ItemId> = s.state.project_selection.iter().copied().filter(|i| s.project.item(*i).is_some_and(|x| !x.is_folder())).collect();
|
||||
if items.is_empty() {
|
||||
return Err(bad("file.newCompFromSelection", "select footage in the Project panel"));
|
||||
}
|
||||
let mut made = vec![];
|
||||
for item in items {
|
||||
let it = s.project.item(item).ok_or(EngineError::NoComp)?.clone();
|
||||
let (w, h) = it.dimensions().unwrap_or((1920, 1080));
|
||||
let rate = it.frame_rate().unwrap_or(FrameRate::FPS_29_97);
|
||||
let dur = it.duration().filter(|d| *d > Tick::ZERO).unwrap_or(Tick::from_seconds_f64(f_p(p, "duration").unwrap_or(10.0)));
|
||||
let name = it.name.rsplit_once('.').map(|(a, _)| a.to_string()).unwrap_or(it.name.clone());
|
||||
let c = Comp::new(w.max(1), h.max(1), rate, dur);
|
||||
let cid = s.edit("New Comp from Selection", None, |proj, st| {
|
||||
let cid = proj.add_item(&name, Label::Sandstone, None, ItemKind::Comp(c.into()));
|
||||
st.project_selection = vec![cid];
|
||||
Ok(cid)
|
||||
})?;
|
||||
s.open_comp(cid);
|
||||
s.execute("layer.addItem", json!({"comp": cid.0, "item": item.0}))?;
|
||||
made.push(cid.0);
|
||||
let single = b_p(p, "single").unwrap_or(false) && items.len() > 1;
|
||||
let from = p.get("dimensionsFrom").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
if single && from >= items.len() {
|
||||
return Err(bad("file.newCompFromSelection", format!("`dimensionsFrom` must be below {}", items.len())));
|
||||
}
|
||||
Ok(json!({"comps": made}))
|
||||
let still = f_p(p, "duration")
|
||||
.or((s.prefs.import.still_footage == "seconds").then_some(s.prefs.import.still_seconds))
|
||||
.filter(|d| d.is_finite() && *d > 0.0)
|
||||
.unwrap_or(10.0);
|
||||
// Layer ▸ Keyframe Assistant ▸ Sequence Layers' parameters.
|
||||
let sequence = b_p(p, "sequence").unwrap_or(false).then(|| {
|
||||
json!({
|
||||
"overlap": b_p(p, "overlap").unwrap_or(false),
|
||||
"duration": f_p(p, "overlapDuration").filter(|d| d.is_finite() && *d >= 0.0).unwrap_or(1.0),
|
||||
"transition": str_p(p, "transition").unwrap_or("off"),
|
||||
})
|
||||
});
|
||||
let queue = b_p(p, "addToRenderQueue").unwrap_or(false);
|
||||
let proj = s.project.clone();
|
||||
let duration = |i: ItemId| proj.item(i).and_then(|it| it.duration()).filter(|d| *d > Tick::ZERO).unwrap_or(Tick::from_seconds_f64(still));
|
||||
let groups: Vec<Vec<ItemId>> = if single { vec![items.clone()] } else { items.iter().map(|i| vec![*i]).collect() };
|
||||
super::app_more::grouped(s, "New Comp from Selection", |s| {
|
||||
let mut made = vec![];
|
||||
for g in groups {
|
||||
let lead = g.get(if single { from } else { 0 }).and_then(|i| proj.item(*i)).ok_or(EngineError::NoComp)?;
|
||||
let (w, h) = lead.dimensions().unwrap_or((1920, 1080));
|
||||
let rate = lead.frame_rate().unwrap_or(FrameRate::FPS_29_97);
|
||||
let pixel_aspect = match &lead.kind {
|
||||
ItemKind::Footage(f) => f.pixel_aspect,
|
||||
ItemKind::Solid(so) => so.pixel_aspect,
|
||||
ItemKind::Comp(c) => c.pixel_aspect,
|
||||
ItemKind::Folder => 1.0,
|
||||
};
|
||||
let overlap =
|
||||
sequence.as_ref().filter(|q| q["overlap"] == true).map_or(Tick::ZERO, |q| Tick::from_seconds_f64(q["duration"].as_f64().unwrap_or(1.0)));
|
||||
let dur = match &sequence {
|
||||
Some(_) if g.len() > 1 => g.iter().map(|i| duration(*i)).fold(Tick::ZERO, |a, d| a + d) - Tick(overlap.0.saturating_mul(g.len() as i64 - 1)),
|
||||
_ => g.iter().map(|i| duration(*i)).max().unwrap_or(Tick::from_seconds_f64(still)),
|
||||
}
|
||||
.max(rate.frame_duration());
|
||||
let name = lead.name.rsplit_once('.').map(|(a, _)| a.to_string()).unwrap_or(lead.name.clone());
|
||||
let c = Comp { pixel_aspect, ..Comp::new(w.max(1), h.max(1), rate, dur) };
|
||||
let cid = s.edit("New Comp from Selection", None, |proj, st| {
|
||||
let cid = proj.add_item(&name, Label::Sandstone, None, ItemKind::Comp(c.into()));
|
||||
st.project_selection = vec![cid];
|
||||
Ok(cid)
|
||||
})?;
|
||||
s.open_comp(cid);
|
||||
// Added bottom first, so the first selected ends up on top.
|
||||
let mut layers = vec![];
|
||||
for item in g.iter().rev() {
|
||||
layers.push(s.execute("layer.addItem", json!({"comp": cid.0, "item": item.0, "duration": still}))?["layer"].clone());
|
||||
}
|
||||
layers.reverse();
|
||||
if let Some(q) = &sequence
|
||||
&& layers.len() > 1
|
||||
{
|
||||
let mut q = q.clone();
|
||||
q["comp"] = json!(cid.0);
|
||||
q["layers"] = json!(layers);
|
||||
s.execute("layer.sequence", q)?;
|
||||
}
|
||||
if queue {
|
||||
s.execute("renderQueue.add", json!({"comp": cid.0}))?;
|
||||
}
|
||||
made.push(cid.0);
|
||||
}
|
||||
Ok(json!({"comps": made}))
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- dependencies
|
||||
|
|
@ -834,7 +893,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"New Comp from Selection...",
|
||||
["File"],
|
||||
Some("Cmd+Alt+\\"),
|
||||
"{duration? (s, for stills)}",
|
||||
"{duration? (s, for stills), single?: bool (one comp for all), dimensionsFrom?: index, sequence?: bool, overlap?: bool, overlapDuration? (s), transition?: off|dissolveFront|crossDissolve, addToRenderQueue?: bool} → {comps}",
|
||||
has_project_selection,
|
||||
new_comp_from_selection
|
||||
),
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
fe!("window.saveWorkspaceAs", "Save as New Workspace...", ["Window", "Workspace"], None, "{name}", always),
|
||||
fe!("window.editWorkspaces", "Edit Workspaces...", ["Window", "Workspace"], None, "{name, rename?: new name, delete?: bool}", always),
|
||||
fe!("window.resetWorkspace", "Reset to Saved Layout", ["Window", "Workspace"], None, "{}", always),
|
||||
fe!("comp.flowchart", "Composition Flowchart", ["Composition"], Some("Cmd+F11"), "{}", always),
|
||||
fe!("comp.flowchart", "Composition Flowchart", ["Composition"], Some("Cmd+Shift+F11"), "{}", always),
|
||||
fe!("comp.miniFlowchart", "Composition Mini-Flowchart", ["Composition"], Some("Tab"), "{}", has_comp),
|
||||
fe!("layer.openLayer", "Open Layer", ["Layer"], None, "{layer?}", has_layers),
|
||||
fe!("effect.manage", "Manage Effects...", ["Effect"], None, "{}", always),
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Other ArtCraft Apps",
|
||||
[],
|
||||
None,
|
||||
"{app: photocraft|drawcraft|filmcraft|lightcraft|printcraft|designcraft, kind?: page|github}",
|
||||
"{app: photocraft|vectorcraft|filmcraft|lightcraft|printcraft|designcraft, kind?: page|github}",
|
||||
always,
|
||||
sibling
|
||||
),
|
||||
|
|
|
|||
|
|
@ -19,11 +19,25 @@ fn v3(v: Option<&Value>) -> Option<[f64; 3]> {
|
|||
/// Transform the selected keys: times scale about `timeAnchor` (comp seconds, default the first
|
||||
/// selected key) by `timeScale` and move by `timeOffset`; values scale about `valueAnchor` by
|
||||
/// `valueScale` and move by `valueOffset` (every dimension, or only `dim`). Times land on frames.
|
||||
/// With `fromStart` (drags), each step of one merged gesture gives the whole transform since the
|
||||
/// drag started and applies to the keys as they were then: keys squeezed onto one frame on the
|
||||
/// way, or rounded steps, are never lost; only the final position counts.
|
||||
fn transform(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let merge = merge_p(p);
|
||||
let from_start = b_p(p, "fromStart").unwrap_or(false) && merge.is_some();
|
||||
let gesture = from_start && merge.is_some_and(|m| s.history.merge_key.as_deref() == Some(m)) && s.key_move.as_ref().map(|g| g.merge.as_str()) == merge;
|
||||
let (sel, base) = match (&s.key_move, s.history.undo.last()) {
|
||||
(Some(g), Some((_, b))) if gesture => (g.from.clone(), Some(b.clone())),
|
||||
_ => (s.state.selected_keys.clone(), None),
|
||||
};
|
||||
if from_start {
|
||||
s.key_move = merge.map(|m| crate::KeyMove { merge: m.to_string(), from: sel.clone(), total: Tick::ZERO });
|
||||
}
|
||||
// The comp as it was when the drag started (its keys are what the transform applies to).
|
||||
let start = base.clone().unwrap_or_else(|| s.project.clone());
|
||||
let comp = start.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let fr = comp.frame_rate;
|
||||
let sel = s.state.selected_keys.clone();
|
||||
let ts = f_p(p, "timeScale").unwrap_or(1.0);
|
||||
let toff = f_p(p, "timeOffset").unwrap_or(0.0);
|
||||
let vs = f_p(p, "valueScale").unwrap_or(1.0);
|
||||
|
|
@ -46,7 +60,10 @@ fn transform(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
fr.snap_nearest(Tick::from_seconds_f64(x.max(0.0)))
|
||||
};
|
||||
let touch_values = vs != 1.0 || voff != 0.0;
|
||||
s.edit("Transform Keyframes", merge_p(p), |proj, st| {
|
||||
s.edit("Transform Keyframes", merge, |proj, st| {
|
||||
if let Some(b) = base {
|
||||
*proj = (*b).clone();
|
||||
}
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let mut groups: std::collections::BTreeMap<(LayerId, Uid), Vec<Tick>> = Default::default();
|
||||
for k in &sel {
|
||||
|
|
@ -136,7 +153,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Transform Keyframes",
|
||||
[],
|
||||
None,
|
||||
"{timeScale?, timeAnchor? (comp s), timeOffset? (s), valueScale?, valueAnchor?, valueOffset?, dim? | dims?: [d…], merge?}",
|
||||
"{timeScale?, timeAnchor? (comp s), timeOffset? (s), valueScale?, valueAnchor?, valueOffset?, dim? | dims?: [d…], merge?, fromStart?: bool (with merge: values are the whole transform since the drag started)}",
|
||||
has_keys,
|
||||
transform
|
||||
),
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ use effectcraft_project::{
|
|||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, b_p, bad, comp_id, f_p, has_comp, has_layers, layer_mut, layer_p, layers_p, merge_p, resolve_layer, str_p};
|
||||
use super::{CommandSpec, b_p, bad, comp_id, f_p, has_comp, has_layers, layer_mut, layer_p, layers_p, merge_p, resolve_layer, str_p, unlocked};
|
||||
use crate::{EngineError, Result, Session, cmd};
|
||||
|
||||
pub(crate) fn color_p(p: &Value, k: &str) -> Option<[f32; 3]> {
|
||||
|
|
@ -49,11 +49,12 @@ fn new_solid_like(s: &mut Session, p: &Value, adjustment: bool) -> Result<Value>
|
|||
let color = color_p(p, "color").unwrap_or(if adjustment { [1.0, 1.0, 1.0] } else { [0.85, 0.2, 0.2] });
|
||||
let w = p.get("width").and_then(Value::as_u64).map(|v| v as u32).unwrap_or(comp.width);
|
||||
let h = p.get("height").and_then(Value::as_u64).map(|v| v as u32).unwrap_or(comp.height);
|
||||
let pixel_aspect = pixel_aspect_p(p)?.unwrap_or(comp.pixel_aspect);
|
||||
let default_name = if adjustment { "Adjustment Layer 1".to_string() } else { "Solid 1".to_string() };
|
||||
let name = str_p(p, "name").map(str::to_string).unwrap_or(default_name);
|
||||
let id = s.edit(if adjustment { "New Adjustment Layer" } else { "New Solid" }, None, |proj, st| {
|
||||
let folder = solids_folder(proj);
|
||||
let sid = proj.add_item(&name, Label::Red, Some(folder), ItemKind::Solid(Solid { color, width: w, height: h, pixel_aspect: comp.pixel_aspect }));
|
||||
let sid = proj.add_item(&name, Label::Red, Some(folder), ItemKind::Solid(Solid { color, width: w, height: h, pixel_aspect }));
|
||||
let mut l = build::layer(proj, &comp, &name, LayerSource::Solid { item: sid }, (w, h), None);
|
||||
if adjustment {
|
||||
l.switches.adjustment = true;
|
||||
|
|
@ -64,6 +65,14 @@ fn new_solid_like(s: &mut Session, p: &Value, adjustment: bool) -> Result<Value>
|
|||
Ok(json!({"layer": id.0}))
|
||||
}
|
||||
|
||||
/// `pixelAspect` (a positive number), when given.
|
||||
fn pixel_aspect_p(p: &Value) -> Result<Option<f64>> {
|
||||
match p.get("pixelAspect").and_then(Value::as_f64) {
|
||||
Some(v) if !(v.is_finite() && v > 0.0) => Err(bad("layer.settings", "`pixelAspect` must be a positive number")),
|
||||
v => Ok(v),
|
||||
}
|
||||
}
|
||||
|
||||
fn new_solid(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
new_solid_like(s, p, false)
|
||||
}
|
||||
|
|
@ -237,8 +246,12 @@ fn add_item(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let (src, size, dur) = match &it.kind {
|
||||
ItemKind::Comp(c) => (LayerSource::Comp { item }, (c.width, c.height), Some(c.duration)),
|
||||
ItemKind::Footage(f) if f.kind == effectcraft_project::FootageKind::Still => {
|
||||
// Settings ▸ Import ▸ Still Footage: length of the composition or a duration.
|
||||
let d = (s.prefs.import.still_footage == "seconds").then(|| Tick::from_seconds_f64(s.prefs.import.still_seconds));
|
||||
// `duration`, else Settings ▸ Import ▸ Still Footage: length of the composition or a
|
||||
// duration.
|
||||
let d = f_p(p, "duration")
|
||||
.filter(|d| d.is_finite() && *d > 0.0)
|
||||
.or((s.prefs.import.still_footage == "seconds").then_some(s.prefs.import.still_seconds))
|
||||
.map(Tick::from_seconds_f64);
|
||||
(LayerSource::Footage { item }, (f.width, f.height), d)
|
||||
}
|
||||
ItemKind::Footage(f) if f.kind == effectcraft_project::FootageKind::Data => {
|
||||
|
|
@ -306,17 +319,17 @@ fn select_step(s: &mut Session, p: &Value, dir: i64) -> Result<Value> {
|
|||
}
|
||||
let cur = s.state.selected_layers.first().and_then(|id| comp.layers.iter().position(|l| l.id == *id));
|
||||
let n = comp.layers.len() as i64;
|
||||
let i = match cur {
|
||||
Some(i) => (i as i64 + dir).clamp(0, n - 1),
|
||||
None => {
|
||||
if dir > 0 {
|
||||
0
|
||||
} else {
|
||||
n - 1
|
||||
}
|
||||
}
|
||||
} as usize;
|
||||
let id = comp.layers[i].id;
|
||||
// The next layer that can be selected (locked and hidden shy layers are stepped over); at
|
||||
// the end of the stack the selection stays.
|
||||
let start = match cur {
|
||||
Some(i) => i as i64 + dir,
|
||||
None if dir > 0 => 0,
|
||||
None => n - 1,
|
||||
};
|
||||
let next =
|
||||
std::iter::successors(Some(start), |i| Some(i + dir)).take_while(|i| (0..n).contains(i)).find(|i| super::selectable(comp, &comp.layers[*i as usize]));
|
||||
let Some(i) = next.or(cur.map(|i| i as i64)) else { return Ok(Value::Null) };
|
||||
let id = comp.layers[i as usize].id;
|
||||
let add = b_p(p, "add").unwrap_or(false);
|
||||
if add {
|
||||
if !s.state.selected_layers.contains(&id) {
|
||||
|
|
@ -504,6 +517,101 @@ fn track_matte(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// How a 2D layer's transform changes so it stays where it is when its parent changes.
|
||||
pub(crate) struct ParentFix {
|
||||
pub layer: LayerId,
|
||||
/// Maps Position from the old parent's space into the new one's.
|
||||
m: effectcraft_geom::Mat3,
|
||||
/// Added to Rotation.
|
||||
rotation: f64,
|
||||
/// Multiply Scale.
|
||||
scale: [f64; 2],
|
||||
}
|
||||
|
||||
impl ParentFix {
|
||||
/// Re-express the layer's Position, Rotation and Scale (every key) in the new parent's space.
|
||||
pub fn apply(&self, l: &mut Layer) {
|
||||
let Some(tr) = l.props.sub_mut("transform") else { return };
|
||||
let map = |pr: &mut effectcraft_project::Property, f: &dyn Fn(&KV) -> KV| {
|
||||
pr.value = f(&pr.value);
|
||||
for key in &mut pr.keys {
|
||||
key.value = f(&key.value);
|
||||
}
|
||||
};
|
||||
let m = self.m;
|
||||
if tr.get("positionX").is_some() {
|
||||
// Separated dimensions: X and Y move together, key by key at the same times.
|
||||
// Everything is read before anything is written.
|
||||
let [x, y] = [tr.get("positionX"), tr.get("positionY")].map(|p| p.map(|p| p.value.as_f64()).unwrap_or(0.0));
|
||||
let q = m.apply(effectcraft_geom::vec2(x, y));
|
||||
let keys_x: Vec<(Tick, f64)> = tr.get("positionX").map(|p| p.keys.iter().map(|k| (k.time, k.value.as_f64())).collect()).unwrap_or_default();
|
||||
let keys_y: Vec<(Tick, f64)> = tr.get("positionY").map(|p| p.keys.iter().map(|k| (k.time, k.value.as_f64())).collect()).unwrap_or_default();
|
||||
let at = |keys: &[(Tick, f64)], name: &str, t: Tick, tr: &PropGroup| {
|
||||
keys.iter().find(|k| k.0 == t).map(|k| k.1).unwrap_or_else(|| tr.get(name).map(|p| p.value_at(t).as_f64()).unwrap_or(0.0))
|
||||
};
|
||||
let (xs, ys): (Vec<f64>, Vec<f64>) = (
|
||||
keys_x.iter().map(|&(t, x)| m.apply(effectcraft_geom::vec2(x, at(&keys_y, "positionY", t, tr))).x).collect(),
|
||||
keys_y.iter().map(|&(t, y)| m.apply(effectcraft_geom::vec2(at(&keys_x, "positionX", t, tr), y)).y).collect(),
|
||||
);
|
||||
for (name, value, vals) in [("positionX", q.x, xs), ("positionY", q.y, ys)] {
|
||||
if let Some(pr) = tr.get_mut(name) {
|
||||
pr.value = KV::Scalar(value);
|
||||
for (k, v) in pr.keys.iter_mut().zip(vals) {
|
||||
k.value = KV::Scalar(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if let Some(pr) = tr.get_mut("position") {
|
||||
map(pr, &|v| {
|
||||
let c = v.as_vec3();
|
||||
let q = m.apply(effectcraft_geom::vec2(c[0], c[1]));
|
||||
KV::Vec3([q.x, q.y, c[2]])
|
||||
});
|
||||
}
|
||||
let (dr, k) = (self.rotation, self.scale);
|
||||
if let Some(pr) = tr.get_mut("rotation") {
|
||||
map(pr, &|v| KV::Scalar(v.as_f64() + dr));
|
||||
}
|
||||
if let Some(pr) = tr.get_mut("scale") {
|
||||
map(pr, &|v| {
|
||||
let c = v.as_vec3();
|
||||
KV::Vec3([c[0] * k[0], c[1] * k[1], c[2]])
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Like the pick-whip, a parent change keeps 2D layers where they are: their transform is
|
||||
/// re-expressed in the new parent's space (`None`: the composition), evaluated at the current
|
||||
/// 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 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)
|
||||
};
|
||||
let decompose = |m: &effectcraft_geom::Mat3| {
|
||||
let a = m.0;
|
||||
let sx = (a[0][0] * a[0][0] + a[1][0] * a[1][0]).sqrt();
|
||||
let det = a[0][0] * a[1][1] - a[0][1] * a[1][0];
|
||||
(a[1][0].atan2(a[0][0]).to_degrees(), sx, if sx > 1e-12 { det / sx } else { 0.0 })
|
||||
};
|
||||
let new_m = space(parent);
|
||||
let Some(inv) = new_m.inverse() else { return vec![] };
|
||||
let (rn, sxn, syn) = decompose(&new_m);
|
||||
comp.layers
|
||||
.iter()
|
||||
.filter(|l| ids.contains(&l.id) && !l.is_3d() && l.parent != parent)
|
||||
.map(|l| {
|
||||
let old_m = space(l.parent);
|
||||
let (ro, sxo, syo) = decompose(&old_m);
|
||||
let scale = [if sxn.abs() > 1e-12 { sxo / sxn } else { 1.0 }, if syn.abs() > 1e-12 { syo / syn } else { 1.0 }];
|
||||
ParentFix { layer: l.id, m: inv * old_m, rotation: ro - rn, scale }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn set_parent(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
|
|
@ -524,59 +632,13 @@ fn set_parent(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
// Like AE's pick-whip, parenting keeps the layer where it is: its transform is re-expressed
|
||||
// in the new parent's space (Position, Rotation, Scale), unless `compensate: false`.
|
||||
let compensate = b_p(p, "compensate").unwrap_or(true);
|
||||
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 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)
|
||||
};
|
||||
let decompose = |m: &effectcraft_geom::Mat3| {
|
||||
let a = m.0;
|
||||
let sx = (a[0][0] * a[0][0] + a[1][0] * a[1][0]).sqrt();
|
||||
let det = a[0][0] * a[1][1] - a[0][1] * a[1][0];
|
||||
(a[1][0].atan2(a[0][0]).to_degrees(), sx, if sx > 1e-12 { det / sx } else { 0.0 })
|
||||
};
|
||||
// Per layer: (id, position transform, rotation delta, scale factors).
|
||||
let mut fixes: Vec<(LayerId, effectcraft_geom::Mat3, f64, [f64; 2])> = vec![];
|
||||
if compensate {
|
||||
let new_m = space(parent);
|
||||
for l in comp.layers.iter().filter(|l| ids.contains(&l.id) && !l.is_3d() && l.parent != parent) {
|
||||
let old_m = space(l.parent);
|
||||
let Some(inv) = new_m.inverse() else { continue };
|
||||
let (ro, sxo, syo) = decompose(&old_m);
|
||||
let (rn, sxn, syn) = decompose(&new_m);
|
||||
let k = [if sxn.abs() > 1e-12 { sxo / sxn } else { 1.0 }, if syn.abs() > 1e-12 { syo / syn } else { 1.0 }];
|
||||
fixes.push((l.id, inv * old_m, ro - rn, k));
|
||||
}
|
||||
}
|
||||
let fixes = if compensate { parent_fixes(s, cid, &ids, parent) } else { vec![] };
|
||||
s.edit("Parent", None, |proj, _| {
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
|
||||
l.parent = parent;
|
||||
let Some((_, m, dr, k)) = fixes.iter().find(|f| f.0 == l.id) else { continue };
|
||||
let Some(tr) = l.props.sub_mut("transform") else { continue };
|
||||
let map = |pr: &mut effectcraft_project::Property, f: &dyn Fn(&KV) -> KV| {
|
||||
pr.value = f(&pr.value);
|
||||
for key in &mut pr.keys {
|
||||
key.value = f(&key.value);
|
||||
}
|
||||
};
|
||||
if tr.get("positionX").is_none()
|
||||
&& let Some(pr) = tr.get_mut("position")
|
||||
{
|
||||
map(pr, &|v| {
|
||||
let c = v.as_vec3();
|
||||
let q = m.apply(effectcraft_geom::vec2(c[0], c[1]));
|
||||
KV::Vec3([q.x, q.y, c[2]])
|
||||
});
|
||||
}
|
||||
if let Some(pr) = tr.get_mut("rotation") {
|
||||
map(pr, &|v| KV::Scalar(v.as_f64() + dr));
|
||||
}
|
||||
if let Some(pr) = tr.get_mut("scale") {
|
||||
map(pr, &|v| {
|
||||
let c = v.as_vec3();
|
||||
KV::Vec3([c[0] * k[0], c[1] * k[1], c[2]])
|
||||
});
|
||||
if let Some(f) = fixes.iter().find(|f| f.layer == l.id) {
|
||||
f.apply(l);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
|
@ -687,6 +749,7 @@ fn slip(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
|
||||
fn arrange(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
let ids = unlocked(s, cid, ids, "layer.arrange")?;
|
||||
let how = str_p(p, "to").unwrap_or("front").to_string();
|
||||
let index = p.get("index").and_then(Value::as_u64).map(|i| i as usize);
|
||||
// `above`: put the layers right above that one (the timeline's drag to reorder).
|
||||
|
|
@ -703,10 +766,29 @@ fn arrange(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
};
|
||||
s.edit("Arrange Layers", None, |proj, _| {
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let n = comp.layers.len();
|
||||
// Bring Forward / Send Backward move each layer one step past the next layer that isn't
|
||||
// moving (a run of selected layers moves as one; one already at the end stays).
|
||||
if above.is_none() && index.is_none() && matches!(how.as_str(), "forward" | "backward") {
|
||||
let picked = |l: &Layer| ids.contains(&l.id);
|
||||
let n = comp.layers.len();
|
||||
if how == "forward" {
|
||||
for i in 1..n {
|
||||
if picked(&comp.layers[i]) && !picked(&comp.layers[i - 1]) {
|
||||
let run_end = (i..n).find(|j| !picked(&comp.layers[*j])).unwrap_or(n);
|
||||
comp.layers[i - 1..run_end].rotate_left(1);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for i in (0..n.saturating_sub(1)).rev() {
|
||||
if picked(&comp.layers[i]) && !picked(&comp.layers[i + 1]) {
|
||||
let run_start = (0..=i).rev().find(|j| !picked(&comp.layers[*j])).map_or(0, |j| j + 1);
|
||||
comp.layers[run_start..=i + 1].rotate_right(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let mut picked: Vec<Layer> = Vec::new();
|
||||
let first_idx = comp.layers.iter().position(|l| ids.contains(&l.id)).unwrap_or(0);
|
||||
let last_idx = comp.layers.iter().rposition(|l| ids.contains(&l.id)).unwrap_or(0);
|
||||
comp.layers.retain(|l| {
|
||||
if ids.contains(&l.id) {
|
||||
picked.push(l.clone());
|
||||
|
|
@ -718,13 +800,9 @@ fn arrange(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let at = match (how.as_str(), index) {
|
||||
_ if above.is_some() => comp.layers.iter().position(|l| Some(l.id) == above).unwrap_or(comp.layers.len()),
|
||||
(_, Some(i)) => i.saturating_sub(1).min(comp.layers.len()),
|
||||
("front", _) => 0,
|
||||
("back", _) => comp.layers.len(),
|
||||
("forward", _) => first_idx.saturating_sub(1),
|
||||
("backward", _) => (last_idx + 2 - picked.len()).min(comp.layers.len()),
|
||||
_ => 0,
|
||||
};
|
||||
let _ = n;
|
||||
for (k, l) in picked.into_iter().enumerate() {
|
||||
comp.layers.insert((at + k).min(comp.layers.len()), l);
|
||||
}
|
||||
|
|
@ -766,8 +844,7 @@ fn precompose(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
_ => comp.duration,
|
||||
};
|
||||
let new = s.edit("Pre-compose", None, |proj, st| {
|
||||
let mut inner = Comp::new(w, h, comp.frame_rate, dur);
|
||||
inner.background = comp.background;
|
||||
let mut inner = comp.nested_like(w, h, dur);
|
||||
let mut il = build::layer(proj, &inner, &layer.name, layer.source.clone(), (w, h), Some(dur));
|
||||
il.name = layer.name.clone();
|
||||
inner.layers.push(il);
|
||||
|
|
@ -791,8 +868,7 @@ fn precompose(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
};
|
||||
let (offset, dur) = if adjust && span.1 > span.0 { (span.0, span.1 - span.0) } else { (Tick(0), comp.duration) };
|
||||
let new = s.edit("Pre-compose", None, |proj, st| {
|
||||
let mut inner = Comp::new(comp.width, comp.height, comp.frame_rate, dur);
|
||||
inner.background = comp.background;
|
||||
let mut inner = comp.nested_like(comp.width, comp.height, dur);
|
||||
inner.layers = comp.layers.iter().filter(|l| ids.contains(&l.id)).cloned().collect();
|
||||
for l in &mut inner.layers {
|
||||
if l.parent.is_some_and(|p| !ids.contains(&p)) {
|
||||
|
|
@ -955,8 +1031,8 @@ fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
&& let Some(doc) = super::text_edit::layer_doc(s, lid)
|
||||
&& doc.char_len() > 0
|
||||
&& let Some(obj) = p.as_object()
|
||||
&& let Some(e) = s.state.text_edit.as_mut()
|
||||
{
|
||||
let e = s.state.text_edit.as_mut().expect("checked");
|
||||
let mut st = e.pending.clone().unwrap_or_else(|| doc.insertion_style(r.start));
|
||||
let mut any = false;
|
||||
for (k, v) in obj {
|
||||
|
|
@ -990,8 +1066,26 @@ fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// Set a layer's Position at layer time `lt` to `f(current)`: X/Y/Z Position when its dimensions
|
||||
/// are separated.
|
||||
pub(crate) fn update_position(tr: &mut PropGroup, lt: Tick, f: impl Fn([f64; 3]) -> [f64; 3]) {
|
||||
const SEPARATE: [&str; 3] = ["positionX", "positionY", "positionZ"];
|
||||
if tr.get("positionX").is_some() {
|
||||
let v = f(SEPARATE.map(|m| tr.get(m).map(|p| p.value_at(lt).as_f64()).unwrap_or(0.0)));
|
||||
for (m, x) in SEPARATE.iter().zip(v) {
|
||||
if let Some(pr) = tr.get_mut(m) {
|
||||
pr.set_value_at(lt, KV::Scalar(x));
|
||||
}
|
||||
}
|
||||
} else if let Some(pr) = tr.get_mut("position") {
|
||||
let v = f(pr.value_at(lt).as_vec3());
|
||||
pr.set_value_at(lt, KV::Vec3(v));
|
||||
}
|
||||
}
|
||||
|
||||
fn transform_op(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
let ids = unlocked(s, cid, ids, "layer.transform")?;
|
||||
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();
|
||||
|
|
@ -1012,7 +1106,7 @@ fn transform_op(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
match op.as_str() {
|
||||
"reset" => {
|
||||
set(tr, "anchor", KV::Vec3([w / 2.0, h / 2.0, 0.0]));
|
||||
set(tr, "position", KV::Vec3([cw / 2.0, ch / 2.0, 0.0]));
|
||||
update_position(tr, lt, |_| [cw / 2.0, ch / 2.0, 0.0]);
|
||||
set(tr, "scale", KV::Vec3([100.0; 3]));
|
||||
set(tr, "rotation", KV::Scalar(0.0));
|
||||
set(tr, "rotationX", KV::Scalar(0.0));
|
||||
|
|
@ -1020,16 +1114,19 @@ fn transform_op(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
set(tr, "orientation", KV::Vec3([0.0; 3]));
|
||||
set(tr, "opacity", KV::Scalar(100.0));
|
||||
}
|
||||
"center" => set(tr, "position", KV::Vec3([cw / 2.0, ch / 2.0, 0.0])),
|
||||
// Centred in the frame; a 3D layer keeps its depth.
|
||||
"center" => update_position(tr, lt, |p| [cw / 2.0, ch / 2.0, p[2]]),
|
||||
"fit" | "fitWidth" | "fitHeight" if w > 0.0 && h > 0.0 => {
|
||||
let (sx, sy) = match op.as_str() {
|
||||
"fitWidth" => (cw / w, cw / w),
|
||||
"fitHeight" => (ch / h, ch / h),
|
||||
_ => (cw / w, ch / h),
|
||||
};
|
||||
set(tr, "scale", KV::Vec3([sx * 100.0, sy * 100.0, 100.0]));
|
||||
set(tr, "position", KV::Vec3([cw / 2.0, ch / 2.0, 0.0]));
|
||||
set(tr, "anchor", KV::Vec3([w / 2.0, h / 2.0, 0.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]));
|
||||
}
|
||||
"flipH" | "flipV" => {
|
||||
let cur = tr.get("scale").map(|p| p.value_at(lt).as_vec3()).unwrap_or([100.0; 3]);
|
||||
|
|
@ -1044,6 +1141,11 @@ fn transform_op(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// Layer ▸ Layer Settings: cameras and lights open their own settings; solids (and adjustment
|
||||
/// layers, which are solids) edit their Solid Settings. With `affectAll: false`, a solid other
|
||||
/// layers also use is copied into a new solid for this layer instead of being changed for all of
|
||||
/// them (After Effects' "Affect all layers that use this solid"). `name` renames the layer and
|
||||
/// its solid.
|
||||
fn layer_settings(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, lid) = layer_p(s, p, "layer.settings")?;
|
||||
// Cameras and lights open their own settings (Camera Settings / Light Settings).
|
||||
|
|
@ -1053,32 +1155,53 @@ fn layer_settings(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
_ => {}
|
||||
}
|
||||
let color = color_p(p, "color");
|
||||
let w = p.get("width").and_then(Value::as_u64).map(|v| v as u32);
|
||||
let h = p.get("height").and_then(Value::as_u64).map(|v| v as u32);
|
||||
let name = str_p(p, "name").map(str::to_string);
|
||||
s.edit("Layer Settings", None, |proj, _| {
|
||||
let l = layer_mut(proj, cid, lid)?;
|
||||
let w = p.get("width").and_then(Value::as_u64).map(|v| v.max(1) as u32);
|
||||
let h = p.get("height").and_then(Value::as_u64).map(|v| v.max(1) as u32);
|
||||
let pixel_aspect = pixel_aspect_p(p)?;
|
||||
let name = str_p(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
|
||||
let affect_all = p.get("affectAll").and_then(Value::as_bool).unwrap_or(true);
|
||||
let solid = s.edit("Layer Settings", None, |proj, _| {
|
||||
let src = layer_mut(proj, cid, lid)?.source.clone();
|
||||
if let Some(n) = &name {
|
||||
l.name = n.clone();
|
||||
layer_mut(proj, cid, lid)?.name = n.clone();
|
||||
}
|
||||
let src = l.source.clone();
|
||||
if let LayerSource::Solid { item } = src
|
||||
&& let Some(it) = proj.item_mut(item)
|
||||
&& let ItemKind::Solid(so) = &mut it.kind
|
||||
{
|
||||
if let Some(c) = color {
|
||||
so.color = c;
|
||||
}
|
||||
if let Some(w) = w {
|
||||
so.width = w.max(1);
|
||||
}
|
||||
if let Some(h) = h {
|
||||
so.height = h.max(1);
|
||||
}
|
||||
let LayerSource::Solid { item } = src else { return Ok(None) };
|
||||
let shared = proj.comps().flat_map(|(_, c)| &c.layers).filter(|l| l.source == src).count() > 1;
|
||||
let target = if shared && !affect_all {
|
||||
// This layer gets its own copy of the solid.
|
||||
let it = proj.item(item).ok_or_else(|| bad("layer.settings", "the layer's solid is missing"))?.clone();
|
||||
let copy = proj.add_item(name.as_deref().unwrap_or(&it.name), it.label, it.parent, it.kind);
|
||||
layer_mut(proj, cid, lid)?.source = LayerSource::Solid { item: copy };
|
||||
copy
|
||||
} else {
|
||||
item
|
||||
};
|
||||
let it = proj.item_mut(target).ok_or_else(|| bad("layer.settings", "the layer's solid is missing"))?;
|
||||
if let Some(n) = &name {
|
||||
it.name = n.clone();
|
||||
}
|
||||
Ok(())
|
||||
if let ItemKind::Solid(so) = &mut it.kind {
|
||||
so.color = color.unwrap_or(so.color);
|
||||
so.width = w.unwrap_or(so.width);
|
||||
so.height = h.unwrap_or(so.height);
|
||||
so.pixel_aspect = pixel_aspect.unwrap_or(so.pixel_aspect);
|
||||
}
|
||||
Ok(Some(target))
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
Ok(match solid {
|
||||
Some(item) => json!({"layer": lid.0, "solid": item.0}),
|
||||
None => json!({"layer": lid.0}),
|
||||
})
|
||||
}
|
||||
|
||||
/// Layer Settings applies to solids (and adjustment layers), nulls, cameras and lights.
|
||||
fn has_layer_settings(s: &Session) -> std::result::Result<(), String> {
|
||||
has_layers(s)?;
|
||||
let l = s.active_comp().zip(s.state.selected_layers.first()).and_then(|(c, l)| c.layer(*l)).ok_or("select a layer first")?;
|
||||
match l.source {
|
||||
LayerSource::Solid { .. } | LayerSource::Null | LayerSource::Camera | LayerSource::Light { .. } => Ok(()),
|
||||
_ => Err("this kind of layer has no settings".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn with_layer(p: &Value, lid: LayerId) -> Value {
|
||||
|
|
@ -1098,7 +1221,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
has_comp,
|
||||
new_text
|
||||
),
|
||||
cmd!("layer.newSolid", "Solid...", ["Layer", "New"], Some("Cmd+Y"), "{name?, color? #hex|[r,g,b], width?, height?}", has_comp, new_solid),
|
||||
cmd!("layer.newSolid", "Solid...", ["Layer", "New"], Some("Cmd+Y"), "{name?, color? #hex|[r,g,b], width?, height?, pixelAspect?}", has_comp, new_solid),
|
||||
cmd!("layer.newNull", "Null Object", ["Layer", "New"], Some("Cmd+Alt+Shift+Y"), "{name?}", has_comp, new_null),
|
||||
cmd!(
|
||||
"layer.newShape",
|
||||
|
|
@ -1110,8 +1233,16 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
new_shape
|
||||
),
|
||||
cmd!("layer.newAdjustment", "Adjustment Layer", ["Layer", "New"], Some("Cmd+Alt+Y"), "{name?}", has_comp, new_adjustment),
|
||||
cmd!("layer.settings", "Layer Settings...", ["Layer"], Some("Cmd+Shift+Y"), "{layer?, name?, color?, width?, height?}", has_layers, layer_settings),
|
||||
cmd!("layer.addItem", "Add Footage to Comp", ["File"], Some("Cmd+/"), "{item: id|name, time?}", has_comp, add_item),
|
||||
cmd!(
|
||||
"layer.settings",
|
||||
"Layer Settings...",
|
||||
["Layer"],
|
||||
Some("Cmd+Shift+Y"),
|
||||
"{layer?, name?, color?, width?, height?, pixelAspect?, affectAll?: bool (default true; false gives this layer its own copy of a solid other layers use)} → {layer, solid?}",
|
||||
has_layer_settings,
|
||||
layer_settings
|
||||
),
|
||||
cmd!("layer.addItem", "Add Footage to Comp", ["File"], Some("Cmd+/"), "{item: id|name, time?, duration? (s, for a still)}", has_comp, add_item),
|
||||
cmd!("layer.select", "Select Layers", [], None, "{layers: [id|name|#n], add?, toggle?}", has_comp, select),
|
||||
cmd!("layer.selectNext", "Select Next Layer", [], Some("Cmd+ArrowDown"), "{add?}", has_comp, select_next),
|
||||
cmd!("layer.selectPrevious", "Select Previous Layer", [], Some("Cmd+ArrowUp"), "{add?}", has_comp, select_prev),
|
||||
|
|
|
|||
|
|
@ -209,6 +209,7 @@ fn set_transform(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
|
||||
fn center_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
let ids = super::unlocked(s, cid, ids, "layer.centerAnchor")?;
|
||||
let t = s.time();
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, comp, t);
|
||||
|
|
@ -234,17 +235,25 @@ fn center_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
if let Some(a) = tr.get_mut("anchor") {
|
||||
a.set_value_at(lt, KV::Vec3([c[0], c[1], anchor[2]]));
|
||||
}
|
||||
if let Some(pos) = tr.get_mut("position") {
|
||||
// Shift every key so the layer stays put over its whole animation.
|
||||
if pos.keys.is_empty() {
|
||||
let v = pos.value.as_vec3();
|
||||
pos.value = KV::Vec3([v[0] + shift[0], v[1] + shift[1], v[2]]);
|
||||
} else {
|
||||
for k in &mut pos.keys {
|
||||
let v = k.value.as_vec3();
|
||||
k.value = KV::Vec3([v[0] + shift[0], v[1] + shift[1], v[2]]);
|
||||
// Shift every key so the layer stays put over its whole animation (X and Y Position
|
||||
// when the dimensions are separated).
|
||||
let offset = |pr: &mut effectcraft_project::Property, f: &dyn Fn(&KV) -> KV| {
|
||||
pr.value = f(&pr.value);
|
||||
for k in &mut pr.keys {
|
||||
k.value = f(&k.value);
|
||||
}
|
||||
};
|
||||
if tr.get("positionX").is_some() {
|
||||
for (m, d) in [("positionX", shift[0]), ("positionY", shift[1])] {
|
||||
if let Some(pr) = tr.get_mut(m) {
|
||||
offset(pr, &|v| KV::Scalar(v.as_f64() + d));
|
||||
}
|
||||
}
|
||||
} else if let Some(pr) = tr.get_mut("position") {
|
||||
offset(pr, &|v| {
|
||||
let c = v.as_vec3();
|
||||
KV::Vec3([c[0] + shift[0], c[1] + shift[1], c[2]])
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -497,6 +497,33 @@ pub(crate) fn layers_p(s: &Session, p: &Value) -> Result<(ItemId, Vec<LayerId>)>
|
|||
Ok((cid, s.state.selected_layers.iter().copied().filter(|l| comp.layer(*l).is_some()).collect()))
|
||||
}
|
||||
|
||||
/// Whether a layer can be picked by the user (Select All, Ctrl+Up/Down, a Timeline click): not
|
||||
/// locked, and not hidden by the composition's Hide Shy Layers.
|
||||
pub(crate) fn selectable(comp: &Comp, l: &Layer) -> bool {
|
||||
!(l.switches.locked || (comp.hide_shy && l.switches.shy))
|
||||
}
|
||||
|
||||
/// The layers of `ids` a command may edit: locked layers are left alone, as in After Effects.
|
||||
/// An error when every one of them is locked.
|
||||
pub(crate) fn unlocked(s: &Session, cid: ItemId, ids: Vec<LayerId>, cmd: &str) -> Result<Vec<LayerId>> {
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let locked = |id: &LayerId| comp.layer(*id).is_some_and(|l| l.switches.locked);
|
||||
match ids.iter().find(|id| locked(id)) {
|
||||
Some(first) if ids.iter().all(locked) => {
|
||||
let name = comp.layer(*first).map(|l| l.name.clone()).unwrap_or_default();
|
||||
Err(bad(cmd, format!("“{name}” is locked (Layer ▸ Switches ▸ Unlock All Layers)")))
|
||||
}
|
||||
_ => Ok(ids.into_iter().filter(|id| !locked(id)).collect()),
|
||||
}
|
||||
}
|
||||
|
||||
/// `visible: [{layer, prop}]`: the properties the Timeline shows (J / K and Select All
|
||||
/// Keyframes use only those, as in After Effects). `None` when not given.
|
||||
pub(crate) fn visible_p(p: &Value) -> Option<Vec<(LayerId, effectcraft_project::Uid)>> {
|
||||
let a = p.get("visible")?.as_array()?;
|
||||
Some(a.iter().filter_map(|v| Some((LayerId(v.get("layer")?.as_u64()?), v.get("prop")?.as_u64()?))).collect())
|
||||
}
|
||||
|
||||
/// `time` (seconds) or `frame` param, else the CTI.
|
||||
pub(crate) fn time_p(s: &Session, p: &Value, comp: Option<&Comp>) -> Tick {
|
||||
if let Some(t) = f_p(p, "time") {
|
||||
|
|
|
|||
|
|
@ -450,16 +450,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
vec![
|
||||
cmd!("path.rotoBezier", "RotoBezier", ["Layer", "Mask and Shape Path"], None, "{layer?, mask?, value?}", has_layers, roto_bezier),
|
||||
cmd!("path.convertToBezier", "Convert To Bezier Path", ["Layer", "Mask and Shape Path"], None, "{layer?, mask?}", has_layers, convert_to_bezier),
|
||||
cmd!("path.groupShapes", "Group Shapes", ["Layer", "Mask and Shape Path"], Some("Cmd+G"), "{layer?, items?: [uid]}", has_shape_items, group_shapes),
|
||||
cmd!(
|
||||
"path.ungroupShapes",
|
||||
"Ungroup Shapes",
|
||||
["Layer", "Mask and Shape Path"],
|
||||
Some("Cmd+Shift+G"),
|
||||
"{layer?, items?: [group uid]}",
|
||||
has_shape_items,
|
||||
ungroup_shapes
|
||||
),
|
||||
cmd!("path.groupShapes", "Group Shapes", ["Layer"], Some("Cmd+G"), "{layer?, items?: [uid]}", has_shape_items, group_shapes),
|
||||
cmd!("path.ungroupShapes", "Ungroup Shapes", ["Layer"], Some("Cmd+Shift+G"), "{layer?, items?: [group uid]}", has_shape_items, ungroup_shapes),
|
||||
cmd!(
|
||||
"path.setFirstVertex",
|
||||
"Set First Vertex",
|
||||
|
|
|
|||
|
|
@ -335,7 +335,19 @@ fn select_keys(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
sel.push(KeyRef { layer: l, prop: pr.uid, time: kt });
|
||||
}
|
||||
}
|
||||
if b_p(p, "add").unwrap_or(false) {
|
||||
// Keys of locked layers can't be selected (so nothing edits them).
|
||||
sel.retain(|k| comp.layer(k.layer).is_some_and(|l| !l.switches.locked));
|
||||
if b_p(p, "toggle").unwrap_or(false) {
|
||||
// Shift+click: in and out of the selection.
|
||||
for k in sel {
|
||||
match s.state.selected_keys.iter().position(|x| *x == k) {
|
||||
Some(i) => {
|
||||
s.state.selected_keys.remove(i);
|
||||
}
|
||||
None => s.state.selected_keys.push(k),
|
||||
}
|
||||
}
|
||||
} else if b_p(p, "add").unwrap_or(false) {
|
||||
for k in sel {
|
||||
if !s.state.selected_keys.contains(&k) {
|
||||
s.state.selected_keys.push(k);
|
||||
|
|
@ -400,14 +412,72 @@ pub(crate) fn edit_keys(s: &mut Session, label: &str, merge: Option<&str>, f: im
|
|||
})
|
||||
}
|
||||
|
||||
/// Move the selected keys by `d` comp seconds, snapped to the comp's frames in comp time (keys
|
||||
/// of time-stretched layers move with the pointer, not by the layer-time distance). Keys of
|
||||
/// locked layers stay. A key landing on another key of its property replaces it.
|
||||
pub(crate) fn shift_keys(s: &mut Session, label: &str, merge: Option<&str>, d: Tick) -> Result<Value> {
|
||||
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
|
||||
// A drag (steps with one merge key) starts again from the keys before it each step.
|
||||
let gesture = merge.is_some_and(|m| s.history.merge_key.as_deref() == Some(m)) && s.key_move.as_ref().map(|g| g.merge.as_str()) == merge;
|
||||
let (from, total, base) = match (&s.key_move, s.history.undo.last()) {
|
||||
(Some(g), Some((_, base))) if gesture => (g.from.clone(), g.total + d, Some(base.clone())),
|
||||
_ => (s.state.selected_keys.clone(), d, None),
|
||||
};
|
||||
s.key_move = merge.map(|m| crate::KeyMove { merge: m.to_string(), from: from.clone(), total });
|
||||
s.edit(label, merge, |proj, st| {
|
||||
if let Some(b) = base {
|
||||
*proj = (*b).clone();
|
||||
}
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let fr = comp.frame_rate;
|
||||
let mut groups: std::collections::BTreeMap<(LayerId, Uid), Vec<Tick>> = Default::default();
|
||||
for k in &from {
|
||||
groups.entry((k.layer, k.prop)).or_default().push(k.time);
|
||||
}
|
||||
let mut sel = vec![];
|
||||
for ((lid, uid), times) in groups {
|
||||
let Some(l) = comp.layer_mut(lid) else { continue };
|
||||
let to = |t: Tick| l.layer_time(fr.snap_nearest(l.comp_time(t) + total));
|
||||
let moves: Vec<(Tick, Tick)> = times.iter().map(|t| (*t, to(*t))).collect();
|
||||
if l.switches.locked {
|
||||
sel.extend(times.iter().map(|t| KeyRef { layer: lid, prop: uid, time: *t }));
|
||||
continue;
|
||||
}
|
||||
let Some(pr) = l.props.find_mut(uid) else { continue };
|
||||
// Take the moving keys out, then put them back at their new times.
|
||||
let mut moved = vec![];
|
||||
for (old, new) in &moves {
|
||||
if let Some(i) = key_at(&pr.keys, *old) {
|
||||
let mut k = pr.keys.remove(i);
|
||||
k.time = *new;
|
||||
moved.push(k);
|
||||
}
|
||||
}
|
||||
for k in moved {
|
||||
sel.push(KeyRef { layer: lid, prop: uid, time: k.time });
|
||||
match pr.keys.binary_search_by(|x| x.time.cmp(&k.time)) {
|
||||
Ok(j) => pr.keys[j] = k,
|
||||
Err(j) => pr.keys.insert(j, k),
|
||||
}
|
||||
}
|
||||
// Roving keys follow their neighbours: keep the selection on them.
|
||||
let before: Vec<Tick> = pr.keys.iter().map(|k| k.time).collect();
|
||||
if effectcraft_keyframe::retime_roving(&mut pr.keys, pr.spatial) {
|
||||
for r in sel.iter_mut().filter(|r| r.layer == lid && r.prop == uid) {
|
||||
if let Some(i) = before.iter().position(|t| *t == r.time) {
|
||||
r.time = pr.keys[i].time;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
st.selected_keys = sel;
|
||||
Ok(json!(st.selected_keys.len()))
|
||||
})
|
||||
}
|
||||
|
||||
fn move_keys(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let d = Tick::from_seconds_f64(f_p(p, "delta").ok_or_else(|| bad("keys.move", "missing `delta` (seconds)"))?);
|
||||
let fr = s.active_comp().map(|c| c.frame_rate);
|
||||
edit_keys(s, "Move Keyframes", merge_p(p), move |_, _, t| {
|
||||
let nt = *t + d;
|
||||
*t = fr.map(|r| r.snap_nearest(nt)).unwrap_or(nt);
|
||||
true
|
||||
})
|
||||
shift_keys(s, "Move Keyframes", merge_p(p), d)
|
||||
}
|
||||
|
||||
fn delete_keys(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
|
@ -543,7 +613,14 @@ fn interpolation(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
fn toggle_hold(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
edit_keys(s, "Toggle Hold Keyframe", None, |keys, i, _| {
|
||||
let k = &mut keys[i];
|
||||
k.out_interp = if k.out_interp == Interp::Hold { Interp::Linear } else { Interp::Hold };
|
||||
// Off again: back to Bezier when the key eases (its other side, auto / continuous, or
|
||||
// an outgoing ease), else Linear.
|
||||
let bezier = k.auto_bezier || k.continuous || k.in_interp == Interp::Bezier || !k.out_ease.is_empty();
|
||||
k.out_interp = match k.out_interp {
|
||||
Interp::Hold if bezier => Interp::Bezier,
|
||||
Interp::Hold => Interp::Linear,
|
||||
_ => Interp::Hold,
|
||||
};
|
||||
false
|
||||
})
|
||||
}
|
||||
|
|
@ -667,7 +744,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
has_layers,
|
||||
convert_expr_to_keys
|
||||
),
|
||||
cmd!("keys.select", "Select Keyframes", [], None, "{keys: [{layer, prop: uid | path (or `path`), time (layer s)}], add?}", has_comp, select_keys),
|
||||
cmd!(
|
||||
"keys.select",
|
||||
"Select Keyframes",
|
||||
[],
|
||||
None,
|
||||
"{keys: [{layer, prop: uid | path (or `path`), time (layer s)}], add?, toggle?: bool (Shift+click: in or out of the selection)}",
|
||||
has_comp,
|
||||
select_keys
|
||||
),
|
||||
cmd!("keys.move", "Move Keyframes", [], None, "{delta (s), merge?}", has_keys, move_keys),
|
||||
cmd!("keys.delete", "Delete Keyframes", [], None, "{}", has_keys, delete_keys),
|
||||
cmd!("keys.easyEase", "Easy Ease", ["Animation", "Keyframe Assistant"], Some("F9"), "{which?: both|in|out}", has_keys, ease),
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ fn comp_info(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(json!({
|
||||
"id": cid.0, "name": name, "width": c.width, "height": c.height, "frameRate": c.frame_rate.as_f64(), "duration": c.duration.seconds(),
|
||||
"workArea": [c.work_area.0.seconds(), c.work_area.1.seconds()], "background": c.background,
|
||||
"preserveFrameRate": c.preserve_frame_rate, "preserveResolution": c.preserve_resolution,
|
||||
"layers": c.layers.iter().enumerate().map(|(i, l)| layer_json(l, i + 1)).collect::<Vec<_>>(),
|
||||
}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@ fn apply(s: &mut Session, cid: ItemId, lid: LayerId, mode: Mode, cuts: &[Tick])
|
|||
let i = cm.layers.iter().position(|l| l.id == lid).ok_or(EngineError::NoComp)?;
|
||||
let mut ids = vec![lid];
|
||||
for t in cuts {
|
||||
let cur = cm.layers.iter().position(|l| l.id == *ids.last().expect("piece")).unwrap_or(i);
|
||||
let cur = ids.last().and_then(|last| cm.layers.iter().position(|l| l.id == *last)).unwrap_or(i);
|
||||
let l = &mut cm.layers[cur];
|
||||
if !(*t > l.in_point && *t < l.out_point) {
|
||||
continue;
|
||||
|
|
|
|||
|
|
@ -72,14 +72,11 @@ fn animator_uid(s: &Session, l: &Layer, p: &Value, cmd: &str) -> Result<u64> {
|
|||
/// Add the properties of `kind` to an animator's Properties group (skipping ones it has).
|
||||
fn add_props(ids: &mut Ids, anim: &mut PropGroup, kind: &str, three_d: bool) -> usize {
|
||||
let list = build::text_anim_props(ids, kind, three_d);
|
||||
let props = match anim.sub_mut("properties") {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
let g = ids.group("properties", "Properties");
|
||||
anim.children.push(g.into());
|
||||
anim.sub_mut("properties").expect("just added")
|
||||
}
|
||||
};
|
||||
if anim.sub("properties").is_none() {
|
||||
let g = ids.group("properties", "Properties");
|
||||
anim.children.push(g.into());
|
||||
}
|
||||
let Some(props) = anim.sub_mut("properties") else { return 0 };
|
||||
let mut n = 0;
|
||||
for pr in list {
|
||||
if props.get(&pr.match_id).is_none() {
|
||||
|
|
@ -161,14 +158,11 @@ pub(crate) fn add_selector(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let mut ids = Ids(&mut next);
|
||||
let l = layer_mut(proj, cid, lid)?;
|
||||
let anim = l.props.find_group_mut(auid).ok_or_else(|| bad(C, "no such animator"))?;
|
||||
let sels = match anim.sub_mut("selectors") {
|
||||
Some(g) => g,
|
||||
None => {
|
||||
let g = ids.group("selectors", "Selectors");
|
||||
anim.children.insert(0, g.into());
|
||||
anim.sub_mut("selectors").expect("just added")
|
||||
}
|
||||
};
|
||||
if anim.sub("selectors").is_none() {
|
||||
let g = ids.group("selectors", "Selectors");
|
||||
anim.children.insert(0, g.into());
|
||||
}
|
||||
let sels = anim.sub_mut("selectors").ok_or_else(|| bad(C, "the animator has no Selectors group"))?;
|
||||
let (m, label) = match kind.as_str() {
|
||||
"range" => ("rangeSelector", "Range Selector"),
|
||||
"wiggly" => ("wigglySelector", "Wiggly Selector"),
|
||||
|
|
|
|||
|
|
@ -455,8 +455,9 @@ pub(crate) fn sanitize(s: &mut Session) {
|
|||
match layer_doc(s, e.layer) {
|
||||
Some(d) => {
|
||||
let n = d.char_len();
|
||||
if e.anchor > n || e.caret > n {
|
||||
let ed = s.state.text_edit.as_mut().expect("checked");
|
||||
if (e.anchor > n || e.caret > n)
|
||||
&& let Some(ed) = s.state.text_edit.as_mut()
|
||||
{
|
||||
ed.anchor = ed.anchor.min(n);
|
||||
ed.caret = ed.caret.min(n);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ fn go(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let t = s.time();
|
||||
let to = str_p(p, "to").unwrap_or("start");
|
||||
let sel_layer = s.state.selected_layers.first().and_then(|id| comp.layer(*id));
|
||||
let visible = super::visible_p(p);
|
||||
let target = match to {
|
||||
"start" => Tick::ZERO,
|
||||
"end" => comp.duration - comp.frame_duration(),
|
||||
|
|
@ -78,8 +79,25 @@ fn go(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
"workEnd" => comp.work_area.1 - comp.frame_duration(),
|
||||
"layerIn" => sel_layer.map(|l| l.in_point).unwrap_or(t),
|
||||
"layerOut" => sel_layer.map(|l| l.out_point - comp.frame_duration()).unwrap_or(t),
|
||||
"nextKey" | "prevKey" if visible.is_some() => {
|
||||
// What the Timeline shows: keys of its revealed properties, layer and comp markers
|
||||
// and the work area (After Effects' "visible items").
|
||||
let mut times: Vec<Tick> = comp.markers.iter().map(|m| m.time).collect();
|
||||
times.extend([comp.work_area.0, comp.work_area.1 - comp.frame_duration()]);
|
||||
for l in comp.layers.iter().filter(|l| !(comp.hide_shy && l.switches.shy)) {
|
||||
times.extend(l.markers.iter().map(|m| l.comp_time(m.time)));
|
||||
}
|
||||
for (lid, uid) in visible.iter().flatten() {
|
||||
if let Some(l) = comp.layer(*lid)
|
||||
&& let Some(pr) = l.props.find(*uid)
|
||||
{
|
||||
times.extend(pr.keys.iter().map(|k| l.comp_time(k.time)));
|
||||
}
|
||||
}
|
||||
step_to(times, t, comp.frame_duration(), to == "nextKey")
|
||||
}
|
||||
"nextKey" | "prevKey" => {
|
||||
// Keyframes (of visible/selected layers) and markers, in comp time.
|
||||
// Keyframes (of visible/selected layers), markers and the work area, in comp time.
|
||||
let mut times: Vec<Tick> = comp.markers.iter().map(|m| m.time).collect();
|
||||
let layers: Vec<_> = if s.state.selected_layers.is_empty() {
|
||||
comp.layers.iter().collect()
|
||||
|
|
@ -90,6 +108,9 @@ fn go(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let only = p.get("prop").and_then(Value::as_u64);
|
||||
if only.is_some() {
|
||||
times.clear();
|
||||
} else {
|
||||
// The work area's start and end too, as in After Effects.
|
||||
times.extend([comp.work_area.0, comp.work_area.1 - comp.frame_duration()]);
|
||||
}
|
||||
for l in layers {
|
||||
l.props.walk("", &mut |_, pr| {
|
||||
|
|
@ -104,14 +125,7 @@ fn go(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
times.extend(l.markers.iter().map(|m| l.comp_time(m.time)));
|
||||
}
|
||||
}
|
||||
times.sort();
|
||||
times.dedup();
|
||||
let half = comp.frame_duration().0 / 2;
|
||||
if to == "nextKey" {
|
||||
times.into_iter().find(|x| x.0 > t.0 + half).unwrap_or(t)
|
||||
} else {
|
||||
times.into_iter().rev().find(|x| x.0 < t.0 - half).unwrap_or(t)
|
||||
}
|
||||
step_to(times, t, comp.frame_duration(), to == "nextKey")
|
||||
}
|
||||
_ => return Err(super::bad("time.go", "to: start|end|workStart|workEnd|layerIn|layerOut|nextKey|prevKey")),
|
||||
};
|
||||
|
|
@ -119,6 +133,22 @@ fn go(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(report(s))
|
||||
}
|
||||
|
||||
/// The nearest time in `times` after (or before) `t`, more than half a frame away; `t` itself
|
||||
/// when there is none.
|
||||
fn step_to(mut times: Vec<Tick>, t: Tick, frame: Tick, next: bool) -> Tick {
|
||||
times.sort();
|
||||
times.dedup();
|
||||
let half = frame.0 / 2;
|
||||
if next { times.into_iter().find(|x| x.0 > t.0 + half).unwrap_or(t) } else { times.into_iter().rev().find(|x| x.0 < t.0 - half).unwrap_or(t) }
|
||||
}
|
||||
|
||||
/// `p` with `to` set (J / K pass their other parameters on).
|
||||
fn with_to(p: &Value, to: &str) -> Value {
|
||||
let mut p = if p.is_object() { p.clone() } else { json!({}) };
|
||||
p["to"] = json!(to);
|
||||
p
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("time.set", "Go to Time...", ["View"], Some("Alt+Shift+J"), "{time? (s) | frame? | timecode?}", has_comp, set),
|
||||
|
|
@ -131,8 +161,27 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
cmd!("time.end", "Go to End", [], Some("End"), "{}", has_comp, |s, _| go(s, &json!({"to": "end"}))),
|
||||
cmd!("time.layerIn", "Go to Layer In Point", [], Some("I"), "{}", has_comp, |s, _| go(s, &json!({"to": "layerIn"}))),
|
||||
cmd!("time.layerOut", "Go to Layer Out Point", [], Some("O"), "{}", has_comp, |s, _| go(s, &json!({"to": "layerOut"}))),
|
||||
cmd!("time.nextKey", "Go to Next Keyframe or Marker", [], Some("K"), "{}", has_comp, |s, _| go(s, &json!({"to": "nextKey"}))),
|
||||
cmd!("time.previousKey", "Go to Previous Keyframe or Marker", [], Some("J"), "{}", has_comp, |s, _| go(s, &json!({"to": "prevKey"}))),
|
||||
cmd!("time.go", "Go To", [], None, "{to: start|end|workStart|workEnd|layerIn|layerOut|nextKey|prevKey, prop?: uid}", has_comp, go),
|
||||
cmd!(
|
||||
"time.nextKey",
|
||||
"Go to Next Keyframe or Marker",
|
||||
[],
|
||||
Some("K"),
|
||||
"{visible?: [{layer, prop}] (only these properties' keys, as the Timeline shows them)}",
|
||||
has_comp,
|
||||
|s, p| go(s, &with_to(p, "nextKey"))
|
||||
),
|
||||
cmd!("time.previousKey", "Go to Previous Keyframe or Marker", [], Some("J"), "{visible?: [{layer, prop}]}", has_comp, |s, p| go(
|
||||
s,
|
||||
&with_to(p, "prevKey")
|
||||
)),
|
||||
cmd!(
|
||||
"time.go",
|
||||
"Go To",
|
||||
[],
|
||||
None,
|
||||
"{to: start|end|workStart|workEnd|layerIn|layerOut|nextKey|prevKey, prop?: uid, visible?: [{layer, prop}]}",
|
||||
has_comp,
|
||||
go
|
||||
),
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -214,7 +214,7 @@ fn create(s: &mut Session, p: &V, cmd: &str) -> Result<V> {
|
|||
let mut ids = Ids(&mut next);
|
||||
let g = tracking::tracker(&mut ids, &name, st, &pts);
|
||||
let uid = g.uid;
|
||||
l.motion_trackers_mut(&mut ids).children.push(g.into());
|
||||
l.motion_trackers_mut(&mut ids).ok_or_else(|| bad(cmd, "the layer has no Motion Trackers group"))?.children.push(g.into());
|
||||
proj.next_id = next;
|
||||
state.current_track = Some((lid, uid));
|
||||
state.selected_layers = vec![lid];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,65 @@
|
|||
//! The last-resort guard: a panic that escapes a command (a bug, never an expected error) is
|
||||
//! turned into an [`EngineError`] instead of taking the app, and the user's open project, down
|
||||
//! with it. Edits work on a copy of the project (see [`crate::Session::edit`]), so an edit that
|
||||
//! panics leaves the project as it was. This is a safety net, not a substitute for returning
|
||||
//! errors (AGENTS.md, "Never crash").
|
||||
|
||||
use crate::{EngineError, Result};
|
||||
|
||||
/// Run `f`; a panic inside it becomes an error naming `what`. The panic itself is logged by
|
||||
/// the panic hook ([`crate::logging::install_panic_hook`]).
|
||||
pub fn guarded<T>(what: &str, f: impl FnOnce() -> Result<T>) -> Result<T> {
|
||||
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) {
|
||||
Ok(r) => r,
|
||||
Err(payload) => {
|
||||
let msg = payload
|
||||
.downcast_ref::<&str>()
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| payload.downcast_ref::<String>().cloned())
|
||||
.unwrap_or_else(|| "unknown error".into());
|
||||
log::error!("{what} panicked: {msg}");
|
||||
Err(EngineError::Other(format!("{what} failed with an internal error ({msg}); it was stopped and your project is still open")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_panic_becomes_an_error() {
|
||||
let r: Result<()> = guarded("command `test.panic`", || panic!("boom"));
|
||||
let e = r.unwrap_err().to_string();
|
||||
assert!(e.contains("test.panic") && e.contains("boom"), "{e}");
|
||||
let r: Result<u8> = guarded("x", || {
|
||||
let v: Vec<u8> = vec![];
|
||||
Ok(v[3])
|
||||
});
|
||||
assert!(r.is_err());
|
||||
assert_eq!(guarded("ok", || Ok(7)).unwrap(), 7);
|
||||
assert!(matches!(guarded::<()>("err", || Err(EngineError::NoComp)), Err(EngineError::NoComp)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_session_survives_a_panicking_edit() {
|
||||
let mut s = crate::Session::new();
|
||||
let before = s.project.clone();
|
||||
let r: Result<()> = guarded("command `test.edit`", || {
|
||||
s.edit("Broken", None, |p, _| {
|
||||
p.next_id += 1;
|
||||
panic!("bug in the middle of an edit")
|
||||
})
|
||||
});
|
||||
assert!(r.is_err());
|
||||
assert!(std::sync::Arc::ptr_eq(&before, &s.project), "the project is untouched");
|
||||
// Later edits still work.
|
||||
let next = s.project.next_id;
|
||||
s.edit("Fine", None, |p, _| {
|
||||
p.next_id += 1;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(s.project.next_id, next + 1);
|
||||
}
|
||||
}
|
||||
|
|
@ -8,12 +8,15 @@
|
|||
//! The project is an `Arc<Project>` edited copy-on-write; undo keeps whole-project snapshots
|
||||
//! (compositions are `Arc`s, so untouched comps are shared).
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod autosave;
|
||||
pub mod camera_track;
|
||||
pub mod commands;
|
||||
pub mod config;
|
||||
pub mod demo;
|
||||
pub mod footage_check;
|
||||
pub mod guard;
|
||||
pub mod history;
|
||||
pub mod jobs;
|
||||
pub mod learn;
|
||||
|
|
@ -131,6 +134,17 @@ pub struct KeyRef {
|
|||
pub time: Tick,
|
||||
}
|
||||
|
||||
/// A keyframe drag in progress (`keys.move` steps sharing a merge key): the selection it
|
||||
/// started from and the total offset so far, so every step is applied to the keys as they were
|
||||
/// before the drag. A key passed over on the way is never overwritten; only the drop position
|
||||
/// counts, as in After Effects.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct KeyMove {
|
||||
pub merge: String,
|
||||
pub from: Vec<KeyRef>,
|
||||
pub total: Tick,
|
||||
}
|
||||
|
||||
/// Keyframes of one property on the keyframe clipboard.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct KeyClip {
|
||||
|
|
@ -370,6 +384,10 @@ pub struct Session {
|
|||
/// The last physical-memory reading (Settings ▸ Memory & CPU budgets); `None` = unknown
|
||||
/// (headless sessions don't query it, see [`Session::memory_tick`]).
|
||||
pub sys_memory: Option<sysinfo::SysMemory>,
|
||||
/// The keyframe drag in progress (`keys.move` with a merge key).
|
||||
pub key_move: Option<KeyMove>,
|
||||
/// Reads the system's memory in the background for [`Session::memory_tick`].
|
||||
pub memory_watch: sysinfo::MemoryWatch,
|
||||
/// Switches Affect Nested Comps as last applied (a change empties the layer cache).
|
||||
pub applied_nested_switches: Option<bool>,
|
||||
/// Runs renders and analyses off the UI thread where there are no threads (the web app's
|
||||
|
|
@ -462,6 +480,8 @@ impl Default for Session {
|
|||
plugin_loader: None,
|
||||
script_ui: scriptui::ScriptUi::default(),
|
||||
sys_memory: None,
|
||||
key_move: None,
|
||||
memory_watch: Default::default(),
|
||||
applied_nested_switches: None,
|
||||
offload: None,
|
||||
offloaded: vec![],
|
||||
|
|
@ -495,7 +515,8 @@ impl Session {
|
|||
return Err(EngineError::Disabled(id.to_string(), why));
|
||||
}
|
||||
}
|
||||
let r = (spec.run)(self, ¶ms)?;
|
||||
// Last-resort guard: a panicking command reports an error instead of crashing the app.
|
||||
let r = guard::guarded(&format!("command `{id}`"), || (spec.run)(self, ¶ms))?;
|
||||
self.learn_observe(id, ¶ms);
|
||||
if spec.journal {
|
||||
self.journal.push((id.to_string(), params));
|
||||
|
|
@ -631,6 +652,13 @@ impl Session {
|
|||
self.saved_revision = self.revision;
|
||||
}
|
||||
|
||||
/// The project has changes its file doesn't (a session restored with unsaved changes): it
|
||||
/// stays modified until it is saved, whatever is undone.
|
||||
pub fn mark_unsaved(&mut self) {
|
||||
self.saved_project = Arc::new(Project::default());
|
||||
self.bump();
|
||||
}
|
||||
|
||||
pub fn active_comp(&self) -> Option<&Comp> {
|
||||
self.project.comp(self.state.active_comp?)
|
||||
}
|
||||
|
|
@ -926,6 +954,8 @@ mod tests_fidelity;
|
|||
#[cfg(test)]
|
||||
mod tests_frame_export;
|
||||
#[cfg(test)]
|
||||
mod tests_keylight;
|
||||
#[cfg(test)]
|
||||
mod tests_lottie;
|
||||
#[cfg(test)]
|
||||
mod tests_m137;
|
||||
|
|
@ -940,6 +970,8 @@ mod tests_menu_cmds;
|
|||
#[cfg(test)]
|
||||
mod tests_model3d;
|
||||
#[cfg(test)]
|
||||
mod tests_motion_blur_depth;
|
||||
#[cfg(test)]
|
||||
mod tests_project_items;
|
||||
#[cfg(test)]
|
||||
mod tests_proxy;
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ pub const ISSUES: &str = "https://github.com/storytold/effectcraft/issues";
|
|||
/// Sibling apps of the ArtCraft family: (name, what it is, GitHub repo, app page).
|
||||
pub const SIBLINGS: &[(&str, &str)] = &[
|
||||
("PhotoCraft", "photocraft"),
|
||||
("DrawCraft", "drawcraft"),
|
||||
("VectorCraft", "vectorcraft"),
|
||||
("FilmCraft", "filmcraft"),
|
||||
("LightCraft", "lightcraft"),
|
||||
("PrintCraft", "printcraft"),
|
||||
|
|
|
|||
|
|
@ -62,6 +62,16 @@ pub fn install() {
|
|||
}
|
||||
}
|
||||
|
||||
/// Log every panic (message and location) through the logger, then run the previous hook
|
||||
/// (which prints it). Panics in commands are then caught by [`crate::guard::guarded`].
|
||||
pub fn install_panic_hook() {
|
||||
let previous = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
log::error!("panic: {info}");
|
||||
previous(info);
|
||||
}));
|
||||
}
|
||||
|
||||
/// The log file inside `config_dir` (the settings folder), or the temporary folder.
|
||||
pub fn log_path(config_dir: Option<&Path>) -> PathBuf {
|
||||
config_dir.map(Path::to_path_buf).unwrap_or_else(std::env::temp_dir).join("Logs").join("EffectCraft Log.txt")
|
||||
|
|
|
|||
|
|
@ -439,8 +439,7 @@ impl Session {
|
|||
}
|
||||
self.bump();
|
||||
}
|
||||
if finished {
|
||||
let mut job = self.mask_job.take().expect("job");
|
||||
if finished && let Some(mut job) = self.mask_job.take() {
|
||||
if let Some(t) = job.thread.take() {
|
||||
let _ = t.join();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -196,7 +196,13 @@ pub fn top_level() -> Vec<&'static str> {
|
|||
/// The menu bar for this platform.
|
||||
pub fn menu_bar() -> &'static [MenuNode] {
|
||||
static BAR: OnceLock<Vec<MenuNode>> = OnceLock::new();
|
||||
BAR.get_or_init(|| parse(TREE, cfg!(target_os = "macos")).expect("menu tree parses"))
|
||||
BAR.get_or_init(|| {
|
||||
// The tree is a constant (a test parses it on every platform); never panic over it.
|
||||
parse(TREE, cfg!(target_os = "macos")).unwrap_or_else(|e| {
|
||||
log::error!("menu tree: {e}");
|
||||
Vec::new()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Every entry with its submenu path (`["Layer", "Blending Mode"]`), depth first.
|
||||
|
|
@ -344,6 +350,15 @@ pub fn checked(s: &Session, command: &str, params: &Value) -> Option<bool> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Labels that name the platform's own tools: "Reveal in Finder" is "Reveal in Explorer" on
|
||||
/// Windows (as in After Effects) and "Reveal in File Manager" elsewhere.
|
||||
fn platform_label(label: &str, mac: bool) -> String {
|
||||
match label.strip_prefix("Reveal in Finder") {
|
||||
Some(rest) if !mac => format!("Reveal in {}{rest}", if cfg!(target_os = "windows") { "Explorer" } else { "File Manager" }),
|
||||
_ => label.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the tree text. `mac` selects `[mac]` / `[!mac]` lines.
|
||||
pub fn parse(text: &str, mac: bool) -> Result<Vec<MenuNode>, String> {
|
||||
struct Line<'a> {
|
||||
|
|
@ -378,7 +393,7 @@ pub fn parse(text: &str, mac: bool) -> Result<Vec<MenuNode>, String> {
|
|||
}
|
||||
lines.push(Line { depth, body });
|
||||
}
|
||||
fn build(lines: &[Line], i: &mut usize, depth: usize) -> Result<Vec<MenuNode>, String> {
|
||||
fn build(lines: &[Line], i: &mut usize, depth: usize, mac: bool) -> Result<Vec<MenuNode>, String> {
|
||||
let mut out = vec![];
|
||||
while *i < lines.len() {
|
||||
let l = &lines[*i];
|
||||
|
|
@ -402,7 +417,7 @@ pub fn parse(text: &str, mac: bool) -> Result<Vec<MenuNode>, String> {
|
|||
continue;
|
||||
}
|
||||
let mut parts = l.body.split(" | ");
|
||||
let label = parts.next().unwrap_or_default().trim().to_string();
|
||||
let label = platform_label(parts.next().unwrap_or_default().trim(), mac);
|
||||
match parts.next() {
|
||||
Some(cmd) => {
|
||||
let cmd = cmd.trim();
|
||||
|
|
@ -416,7 +431,7 @@ pub fn parse(text: &str, mac: bool) -> Result<Vec<MenuNode>, String> {
|
|||
out.push(MenuNode::Item(MenuEntry { label, command, params, shortcut }));
|
||||
}
|
||||
None => {
|
||||
let children = build(lines, i, depth + 1)?;
|
||||
let children = build(lines, i, depth + 1, mac)?;
|
||||
if children.is_empty() {
|
||||
return Err(format!("`{label}` has neither a command nor children"));
|
||||
}
|
||||
|
|
@ -427,7 +442,7 @@ pub fn parse(text: &str, mac: bool) -> Result<Vec<MenuNode>, String> {
|
|||
Ok(out)
|
||||
}
|
||||
let mut i = 0;
|
||||
build(&lines, &mut i, 0)
|
||||
build(&lines, &mut i, 0, mac)
|
||||
}
|
||||
|
||||
/// Effect ▸ category submenus from the effect registry.
|
||||
|
|
@ -744,8 +759,6 @@ Layer
|
|||
RotoBezier | path.rotoBezier
|
||||
Closed | mask.setClosed
|
||||
Convert To Bezier Path | path.convertToBezier
|
||||
Group Shapes | path.groupShapes
|
||||
Ungroup Shapes | path.ungroupShapes
|
||||
Set First Vertex | path.setFirstVertex
|
||||
Free Transform Points | path.freeTransform
|
||||
Quality
|
||||
|
|
@ -782,7 +795,7 @@ Layer
|
|||
---
|
||||
Flip Horizontal | layer.transform {"op":"flipH"}
|
||||
Flip Vertical | layer.transform {"op":"flipV"}
|
||||
Center In View | layer.transform {"op":"center"}
|
||||
Center In View | layer.transform {"op":"center"} | Cmd+Home
|
||||
Center Anchor Point in Layer Content | layer.centerAnchor
|
||||
Fit to Comp | layer.transform {"op":"fit"} | Cmd+Alt+F
|
||||
Fit to Comp Width | layer.transform {"op":"fitWidth"} | Cmd+Alt+Shift+H
|
||||
|
|
@ -883,6 +896,8 @@ Layer
|
|||
Color Overlay | layer.style.colorOverlay
|
||||
Gradient Overlay | layer.style.gradientOverlay
|
||||
Stroke | layer.style.stroke
|
||||
Group Shapes | path.groupShapes
|
||||
Ungroup Shapes | path.ungroupShapes
|
||||
---
|
||||
Arrange
|
||||
Bring Layer to Front | layer.arrange {"to":"front"} | Cmd+Shift+]
|
||||
|
|
@ -1151,6 +1166,7 @@ Window
|
|||
Effects & Presets | window.panel {"panel":"effectsPresets"} | Cmd+5
|
||||
Essential Graphics | window.panel {"panel":"essentialGraphics"}
|
||||
Info | window.panel {"panel":"info"} | Cmd+2
|
||||
Learn | help.inAppTutorials
|
||||
Lumetri Scopes | window.panel {"panel":"lumetriScopes"}
|
||||
Mask Interpolation | window.panel {"panel":"maskInterpolation"}
|
||||
Media Browser | window.panel {"panel":"mediaBrowser"}
|
||||
|
|
@ -1169,7 +1185,7 @@ Window
|
|||
---
|
||||
Composition | window.panel {"panel":"composition"}
|
||||
Effect Controls | window.panel {"panel":"effectControls"} | F3
|
||||
Flowchart | window.panel {"panel":"flowchart"}
|
||||
Flowchart | window.panel {"panel":"flowchart"} | Cmd+F11
|
||||
Footage | window.panel {"panel":"footage"}
|
||||
Layer | window.panel {"panel":"layer"}
|
||||
Project | window.panel {"panel":"project"} | Cmd+0
|
||||
|
|
|
|||
|
|
@ -155,3 +155,27 @@ fn parse_rejects_bad_trees() {
|
|||
assert!(parse("File\n Odd | file.save\n", true).is_err());
|
||||
assert!(parse("File\n Bad | file.save {nope}\n", true).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn m3_13_entries_follow_after_effects() {
|
||||
let find = |mac: bool, path: &[&str], label: &str| platform_entries(mac).into_iter().find(|(p, e)| p == path && e.label == label).map(|(_, e)| e);
|
||||
// Group / Ungroup Shapes sit in the Layer menu itself.
|
||||
for mac in [true, false] {
|
||||
assert_eq!(find(mac, &["Layer"], "Group Shapes").unwrap().shortcut.as_deref(), Some("Cmd+G"));
|
||||
assert_eq!(find(mac, &["Layer"], "Ungroup Shapes").unwrap().shortcut.as_deref(), Some("Cmd+Shift+G"));
|
||||
assert!(find(mac, &["Layer", "Mask and Shape Path"], "Group Shapes").is_none());
|
||||
// Composition Flowchart Ctrl+Shift+F11, Window ▸ Flowchart Ctrl+F11; Window ▸ Learn.
|
||||
assert_eq!(find(mac, &["Composition"], "Composition Flowchart").unwrap().shortcut.as_deref(), Some("Cmd+Shift+F11"));
|
||||
assert_eq!(find(mac, &["Window"], "Flowchart").unwrap().shortcut.as_deref(), Some("Cmd+F11"));
|
||||
assert_eq!(find(mac, &["Window"], "Learn").unwrap().command, "help.inAppTutorials");
|
||||
assert_eq!(find(mac, &["Layer", "Transform"], "Center In View").unwrap().shortcut.as_deref(), Some("Cmd+Home"));
|
||||
}
|
||||
// "Reveal in Finder" names the platform's file browser.
|
||||
assert!(find(true, &["File"], "Reveal in Finder").is_some());
|
||||
let other = if cfg!(target_os = "windows") { "Reveal in Explorer" } else { "Reveal in File Manager" };
|
||||
for path in [&["File"][..], &["Layer"], &["Layer", "Reveal"]] {
|
||||
assert!(find(false, path, other).is_some(), "{path:?}");
|
||||
assert!(find(false, path, "Reveal in Finder").is_none(), "{path:?}");
|
||||
}
|
||||
assert_eq!(platform_label("Reveal Layer Source in Project", false), "Reveal Layer Source in Project");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,10 @@
|
|||
//! frame). The UI-side numbers (first UI frame, timeline and Project panel draw) live in
|
||||
//! `effectcraft-ui-egui`'s `bench` module. See `docs/architecture.md` ▸ Performance.
|
||||
|
||||
// A benchmark harness (`effectcraft-cli bench --ops`, tests): a failed step should fail loudly,
|
||||
// as in tests and benches (AGENTS.md, "Never crash"). Nothing here runs in the app.
|
||||
#![allow(clippy::expect_used)]
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_keyframe::Value as KValue;
|
||||
|
|
|
|||
|
|
@ -513,8 +513,8 @@ impl Session {
|
|||
off.cancel(r.id);
|
||||
}
|
||||
self.drain_remote_render();
|
||||
let j = self.render_job.as_ref().expect("job");
|
||||
let r = j.remote.as_ref().expect("remote");
|
||||
let Some(j) = self.render_job.as_ref() else { return true };
|
||||
let Some(r) = j.remote.as_ref() else { return true };
|
||||
let finished: Vec<u64> =
|
||||
lock(&j.shared.updates).iter().filter_map(|u| if let ItemUpdate::Finished { id, .. } = u { Some(*id) } else { None }).collect();
|
||||
for id in &r.ids {
|
||||
|
|
@ -570,8 +570,7 @@ impl Session {
|
|||
}
|
||||
self.bump();
|
||||
}
|
||||
if finished {
|
||||
let mut job = self.render_job.take().expect("job");
|
||||
if finished && let Some(mut job) = self.render_job.take() {
|
||||
if let Some(t) = job.thread.take() {
|
||||
let _ = t.join();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -587,7 +587,7 @@ impl Session {
|
|||
if !job.is_finished() {
|
||||
return false;
|
||||
}
|
||||
let mut job = self.roto_job.take().expect("job");
|
||||
let Some(mut job) = self.roto_job.take() else { return false };
|
||||
if let Some(t) = job.thread.take() {
|
||||
let _ = t.join();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,11 +75,13 @@ impl Session {
|
|||
self.footage.set_cache_budget(b.media);
|
||||
}
|
||||
|
||||
/// Re-read the system's memory (desktop frontends call this every few seconds) and re-apply
|
||||
/// the cache budgets when the reading changes them. Returns the effective budgets.
|
||||
/// Take the latest reading of the system's memory and start the next one in the background
|
||||
/// (desktop frontends call this every few seconds; it never waits for the system), then
|
||||
/// re-apply the cache budgets when the reading changes them. Returns the effective budgets.
|
||||
pub fn memory_tick(&mut self) -> crate::prefs::CacheBudgets {
|
||||
let before = self.prefs.cache_budgets(self.sys_memory);
|
||||
self.sys_memory = crate::sysinfo::memory().or(self.sys_memory);
|
||||
self.sys_memory = self.memory_watch.latest().or(self.sys_memory);
|
||||
self.memory_watch.poll();
|
||||
let after = self.prefs.cache_budgets(self.sys_memory);
|
||||
if after != before {
|
||||
self.apply_cache_budgets();
|
||||
|
|
|
|||
|
|
@ -4,7 +4,11 @@
|
|||
//! Pure Rust and dependency-free: the values come from the operating system's own reporting
|
||||
//! (`/proc/meminfo` on Linux, `sysctl`/`vm_stat` on macOS, `wmic`/PowerShell on Windows). Every
|
||||
//! query may fail (sandboxes, the web): callers treat `None` as "unknown" and keep their
|
||||
//! configured budgets.
|
||||
//! configured budgets. The periodic reading behind the cache budgets runs on a background
|
||||
//! thread ([`MemoryWatch`]): on Windows it starts PowerShell, which takes about a second.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
|
|
@ -23,10 +27,51 @@ impl SysMemory {
|
|||
}
|
||||
}
|
||||
|
||||
/// The system's memory, read in the background: [`MemoryWatch::poll`] starts a reading (unless
|
||||
/// one is running) and [`MemoryWatch::latest`] returns the last one without waiting.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct MemoryWatch {
|
||||
latest: Arc<Mutex<Option<SysMemory>>>,
|
||||
busy: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl MemoryWatch {
|
||||
/// Start a new reading in the background (no threads on the web: read inline, it is
|
||||
/// unknown there anyway).
|
||||
pub fn poll(&self) {
|
||||
if self.busy.swap(true, Ordering::AcqRel) {
|
||||
return;
|
||||
}
|
||||
let (latest, busy) = (self.latest.clone(), self.busy.clone());
|
||||
let read = move || {
|
||||
if let Some(m) = memory() {
|
||||
*latest.lock().unwrap_or_else(PoisonError::into_inner) = Some(m);
|
||||
}
|
||||
busy.store(false, Ordering::Release);
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if std::thread::Builder::new().name("ec-memory".into()).spawn(read).is_err() {
|
||||
self.busy.store(false, Ordering::Release);
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
read();
|
||||
}
|
||||
|
||||
/// The last reading (`None` until one finished).
|
||||
pub fn latest(&self) -> Option<SysMemory> {
|
||||
*self.latest.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[allow(dead_code)]
|
||||
fn run(cmd: &str, args: &[&str]) -> Option<String> {
|
||||
let out = std::process::Command::new(cmd).args(args).output().ok()?;
|
||||
let mut c = std::process::Command::new(cmd);
|
||||
c.args(args);
|
||||
// A console program started from the windowed app would flash a console window.
|
||||
#[cfg(target_os = "windows")]
|
||||
std::os::windows::process::CommandExt::creation_flags(&mut c, 0x0800_0000); // CREATE_NO_WINDOW
|
||||
let out = c.output().ok()?;
|
||||
out.status.success().then(|| String::from_utf8_lossy(&out.stdout).to_string())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -301,6 +301,17 @@ fn comp_settings_anchor_start_timecode_renderer() {
|
|||
assert_eq!(c.renderer, crate::project::Renderer::Advanced3D);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comp_rejects_zero_and_negative_frame_rates() {
|
||||
let mut s = Session::default();
|
||||
for fps in [0.0, -30.0, 0.0001] {
|
||||
assert!(s.execute("comp.new", json!({"frameRate": fps})).is_err(), "{fps}");
|
||||
}
|
||||
s.execute("comp.new", json!({"frameRate": 30, "duration": 1})).unwrap();
|
||||
assert!(s.execute("comp.settings", json!({"frameRate": 0})).is_err());
|
||||
assert_eq!(s.active_comp().unwrap().frame_rate, crate::time::FrameRate::FPS_30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_queue_add_accepts_documented_settings_when_checked() {
|
||||
let mut s = demo();
|
||||
|
|
|
|||
|
|
@ -185,7 +185,7 @@ fn opening_an_old_project_upgrades_effect_instances() {
|
|||
{
|
||||
let comp = std::sync::Arc::make_mut(match &mut old.items.get_mut(&cid).unwrap().kind {
|
||||
effectcraft_project::ItemKind::Comp(c) => c,
|
||||
_ => unreachable!(),
|
||||
_ => panic!("not a composition"),
|
||||
});
|
||||
let l = comp.layers.iter_mut().find(|l| l.id == LayerId(a)).unwrap();
|
||||
for n in &mut l.props.sub_mut("effects").unwrap().children {
|
||||
|
|
|
|||
|
|
@ -175,3 +175,18 @@ fn modified_mark_follows_undo_and_no_op_edits_record_nothing() {
|
|||
assert!(!s.is_dirty(), "rolled back");
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_session_restored_with_unsaved_changes_stays_modified() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "H", "width": 64, "height": 64, "duration": 2})).unwrap();
|
||||
let p = (*s.project).clone();
|
||||
// The web app reopens the last visit's project, which had unsaved changes.
|
||||
s.replace_project(p, None);
|
||||
assert!(!s.is_dirty());
|
||||
s.mark_unsaved();
|
||||
assert!(s.is_dirty());
|
||||
solid(&mut s, "A");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(s.is_dirty(), "undo doesn't reach a saved state");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,135 @@
|
|||
//! Key Light end to end: a green-screen plate (uneven screen, a subject with green spill and a
|
||||
//! soft edge) keyed through the commands an agent uses: `effect.apply` by its After Effects name,
|
||||
//! `effect.pickColor` on the screen (the effect's input, as the eyedropper does) and a render.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_project::{Footage, FootageKind, ItemId};
|
||||
use effectcraft_raster::Image;
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::render::{FootageSource, RenderOpts};
|
||||
|
||||
const W: u32 = 96;
|
||||
const H: u32 = 64;
|
||||
const SKIN: [f32; 3] = [0.86, 0.62, 0.52];
|
||||
|
||||
/// The plate's straight colour and alpha (opaque everywhere) at a pixel.
|
||||
fn plate(x: u32, y: u32) -> [f32; 3] {
|
||||
// Screen: a slightly uneven, noisy green.
|
||||
let noise = (((x * 7919 + y * 104_729) % 97) as f32 / 97.0 - 0.5) * 0.02;
|
||||
let screen = [0.18 + noise, 0.66 + (x as f32 / W as f32 - 0.5) * 0.04 + noise, 0.30 - noise];
|
||||
let r = ((x as f32 + 0.5 - 48.0).powi(2) + (y as f32 + 0.5 - 32.0).powi(2)).sqrt();
|
||||
// Subject: skin, with green spill in its outer 3 px and a 3 px soft edge.
|
||||
let mut subject = SKIN;
|
||||
if r > 13.0 {
|
||||
subject[1] += 0.12;
|
||||
}
|
||||
let a = ((19.0 - r) / 3.0).clamp(0.0, 1.0);
|
||||
[0, 1, 2].map(|i| subject[i] * a + screen[i] * (1.0 - a))
|
||||
}
|
||||
|
||||
struct Plate;
|
||||
|
||||
impl FootageSource for Plate {
|
||||
fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<Image>> {
|
||||
let mut img = Image::new(W, H);
|
||||
for y in 0..H {
|
||||
for x in 0..W {
|
||||
let c = plate(x, y);
|
||||
img.set(x, y, [c[0], c[1], c[2], 1.0]);
|
||||
}
|
||||
}
|
||||
Some(Arc::new(img))
|
||||
}
|
||||
}
|
||||
|
||||
struct Probe;
|
||||
impl crate::Importer for Probe {
|
||||
fn probe(&self, path: &str) -> Result<Footage, String> {
|
||||
Ok(Footage {
|
||||
path: path.into(),
|
||||
kind: FootageKind::Still,
|
||||
width: W,
|
||||
height: H,
|
||||
has_video: true,
|
||||
frame_rate: FrameRate::from_f64(24.0),
|
||||
duration: Tick::ZERO,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn keyed() -> (Session, ItemId) {
|
||||
let mut s = Session { footage: Arc::new(Plate), importer: Some(Arc::new(Probe)), ..Default::default() };
|
||||
let item = s.execute("file.import", json!({"paths": ["/plates/greenscreen.png"]})).unwrap()["items"][0].as_u64().unwrap();
|
||||
s.execute("comp.new", json!({"name": "Key", "width": W, "height": H, "frameRate": 24, "duration": 1})).unwrap();
|
||||
let c = s.active_comp_id().unwrap();
|
||||
s.execute("layer.addItem", json!({"item": item})).unwrap();
|
||||
// After Effects' name finds it; MCP's add_effect / list_effects go through the same lookup.
|
||||
let r = s.execute_checked("effect.apply", json!({"effect": "Keylight (1.2)"})).unwrap();
|
||||
assert_eq!(r["effect"], "ec.keying.keylight");
|
||||
(s, c)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_light_removes_a_green_screen() {
|
||||
let (mut s, c) = keyed();
|
||||
let steps = s.history.undo.len();
|
||||
// The default screen colour (pure green) already keys the screen, so the shown frame is
|
||||
// transparent there; the eyedropper samples the effect's input instead.
|
||||
let picked = s.execute_checked("effect.pickColor", json!({"effect": 1, "param": "screenColour", "x": 6, "y": 6, "average": true})).unwrap();
|
||||
let col: Vec<f64> = picked["color"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
|
||||
let want = plate(6, 6);
|
||||
assert!((0..3).all(|i| (col[i] - want[i] as f64).abs() < 0.02), "{col:?} vs {want:?}");
|
||||
let tree = s.execute("layer.tree", json!({})).unwrap().to_string();
|
||||
assert!(tree.contains("Screen Colour"), "{tree}");
|
||||
// One undo step.
|
||||
assert_eq!(s.history.undo.len(), steps + 1);
|
||||
// Clean the matte as one does in Screen Matte view: Clip Black for the uneven screen, Clip
|
||||
// White for the spill's partial transparency.
|
||||
for (path, v) in [("effects/#1/screenMatte/clipBlack", 10), ("effects/#1/screenMatte/clipWhite", 85)] {
|
||||
s.execute_checked("prop.set", json!({"path": path, "value": v})).unwrap();
|
||||
}
|
||||
|
||||
let img = s.render(c, Tick::ZERO, RenderOpts::default());
|
||||
let px = |x, y| img.get(x, y);
|
||||
// The whole screen goes (corners, both ends of the gradient, noise).
|
||||
for (x, y) in [(1, 1), (94, 1), (1, 62), (94, 62), (20, 32), (76, 32)] {
|
||||
assert!(px(x, y)[3] < 0.03, "screen at {x},{y}: {:?}", px(x, y));
|
||||
}
|
||||
// The subject stays, with its colour.
|
||||
let fg = px(48, 32);
|
||||
assert!(fg[3] > 0.99 && (0..3).all(|i| (fg[i] - SKIN[i]).abs() < 0.02), "{fg:?}");
|
||||
// Spill on the subject's edge is suppressed: no green cast left.
|
||||
let sp = px(48, 32 - 14);
|
||||
assert!(sp[3] > 0.99 && sp[1] <= 0.5 * (sp[0] + sp[2]) + 0.01, "spill {sp:?}");
|
||||
// The soft edge keeps partial alpha, and its recovered colour has no green fringe.
|
||||
let e = px(48, 32 - 18);
|
||||
assert!(e[3] > 0.15 && e[3] < 0.85, "edge {e:?}");
|
||||
let a = e[3];
|
||||
assert!(e[1] / a <= 0.5 * (e[0] + e[2]) / a + 0.02, "edge fringe {e:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_color_rejects_bad_targets_and_screen_matte_view_shows_the_key() {
|
||||
let (mut s, c) = keyed();
|
||||
// Not a colour parameter / outside the layer.
|
||||
assert!(s.execute_checked("effect.pickColor", json!({"effect": 1, "param": "screenGain", "x": 6, "y": 6})).is_err());
|
||||
assert!(s.execute_checked("effect.pickColor", json!({"effect": 1, "param": "screenColour", "x": -50, "y": 6})).is_err());
|
||||
assert!(s.execute_checked("effect.pickColor", json!({"effect": 1, "param": "screenColour", "x": 1e300, "y": -1e300, "average": true})).is_err());
|
||||
assert!(s.execute_checked("effect.pickColor", json!({"effect": 1, "param": "screenColour"})).is_err());
|
||||
s.execute_checked("effect.pickColor", json!({"effect": 1, "param": "screenColour", "x": 90, "y": 60})).unwrap();
|
||||
// View ▸ Screen Matte: black screen (once Clip Black takes the uneven screen), white subject.
|
||||
for (path, v) in [("effects/#1/view", 4), ("effects/#1/screenMatte/clipBlack", 25)] {
|
||||
s.execute_checked("prop.set", json!({"path": path, "value": v})).unwrap();
|
||||
}
|
||||
let img = s.render(c, Tick::ZERO, RenderOpts::default());
|
||||
assert!(img.get(2, 2)[0] < 0.03 && img.get(48, 32)[0] > 0.97, "{:?} {:?}", img.get(2, 2), img.get(48, 32));
|
||||
// The Effects & Presets / list_effects search finds it by After Effects' name too.
|
||||
let list = s.execute("effect.list", json!({"filter": "Keylight (1.2)"})).unwrap();
|
||||
assert_eq!(list.as_array().map(|a| a.len()), Some(1), "{list}");
|
||||
assert_eq!(list[0]["id"], "ec.keying.keylight");
|
||||
}
|
||||
|
|
@ -263,9 +263,35 @@ fn label_group_purge_and_edit_original() {
|
|||
s.execute("layer.select", json!({"layers": [a]})).unwrap();
|
||||
s.execute("edit.selectLabelGroup", json!({})).unwrap();
|
||||
assert_eq!(s.state.selected_layers.len(), 2);
|
||||
// A footage source that counts purges (the media pool drops its decoded frames).
|
||||
struct Counting(std::sync::atomic::AtomicUsize);
|
||||
impl crate::render::FootageSource for Counting {
|
||||
fn frame(
|
||||
&self,
|
||||
_: effectcraft_project::ItemId,
|
||||
_: &effectcraft_project::Footage,
|
||||
_: effectcraft_time::Tick,
|
||||
) -> Option<std::sync::Arc<crate::render::Image>> {
|
||||
None
|
||||
}
|
||||
fn purge(&self) {
|
||||
self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
let src = std::sync::Arc::new(Counting(Default::default()));
|
||||
s.footage = src.clone();
|
||||
s.drain_events();
|
||||
s.execute("edit.purge", json!({"what": "memory"})).unwrap();
|
||||
assert!(s.drain_events().contains(&Event::PurgeCaches));
|
||||
assert_eq!(src.0.load(std::sync::atomic::Ordering::SeqCst), 1, "decoded footage frames are dropped");
|
||||
// The disk cache and the snapshot purge alone: the RAM preview stays.
|
||||
for what in ["disk", "snapshot"] {
|
||||
s.execute("edit.purge", json!({"what": what})).unwrap();
|
||||
assert!(!s.drain_events().contains(&Event::PurgeCaches), "{what}");
|
||||
}
|
||||
assert_eq!(src.0.load(std::sync::atomic::Ordering::SeqCst), 1);
|
||||
s.execute("edit.purge", json!({"what": "3d"})).unwrap();
|
||||
assert!(s.drain_events().contains(&Event::PurgeCaches));
|
||||
assert!(!s.is_enabled("edit.editOriginal"));
|
||||
}
|
||||
|
||||
|
|
@ -492,3 +518,539 @@ fn frontend_commands_emit_events_and_stubs_are_disabled() {
|
|||
assert!(matches!(s.execute(id, json!({})), Err(EngineError::Disabled(..))), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
fn solid_of(s: &Session, l: u64) -> (u64, effectcraft_project::Solid, String) {
|
||||
let effectcraft_project::LayerSource::Solid { item } = layer(s, l).source else { panic!("not a solid") };
|
||||
let it = s.project.item(item).unwrap();
|
||||
let ItemKind::Solid(so) = &it.kind else { panic!("not a solid item") };
|
||||
(item.0, so.clone(), it.name.clone())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_settings_edit_the_solid_or_give_the_layer_its_own() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
s.execute("layer.select", json!({"layers": [a]})).unwrap();
|
||||
let b = s.execute("edit.duplicate", json!({})).unwrap()[0].as_u64().unwrap();
|
||||
assert_eq!(solid_of(&s, a).0, solid_of(&s, b).0, "the duplicate shares the solid");
|
||||
// Nothing given: nothing recorded.
|
||||
let steps = s.history.undo.len();
|
||||
s.execute("layer.settings", json!({"layer": b})).unwrap();
|
||||
assert_eq!(s.history.undo.len(), steps);
|
||||
// Affect all off: the duplicate gets its own solid; the original keeps its colour.
|
||||
let r = s.execute("layer.settings", json!({"layer": b, "color": "#0000ff", "name": "Blue", "affectAll": false})).unwrap();
|
||||
let (bi, bs, bn) = solid_of(&s, b);
|
||||
assert_eq!(r, json!({"layer": b, "solid": bi}));
|
||||
assert_ne!(bi, solid_of(&s, a).0);
|
||||
assert_eq!((bs.color, bn.as_str(), layer(&s, b).name.as_str()), ([0.0, 0.0, 1.0], "Blue", "Blue"));
|
||||
assert_eq!(solid_of(&s, a).1.color, [1.0, 0.0, 0.0]);
|
||||
assert_eq!(
|
||||
s.project.item(effectcraft_project::ItemId(bi)).unwrap().parent,
|
||||
s.project.item(effectcraft_project::ItemId(solid_of(&s, a).0)).unwrap().parent,
|
||||
"same folder"
|
||||
);
|
||||
// Affect all (the default) changes the shared solid in place; size and pixel aspect too.
|
||||
s.execute("layer.select", json!({"layers": [a]})).unwrap();
|
||||
let c = s.execute("edit.duplicate", json!({})).unwrap()[0].as_u64().unwrap();
|
||||
s.execute("layer.settings", json!({"layer": a, "width": 320, "height": 90, "pixelAspect": 2.0})).unwrap();
|
||||
assert_eq!(solid_of(&s, a).0, solid_of(&s, c).0);
|
||||
let so = solid_of(&s, c).1;
|
||||
assert_eq!((so.width, so.height, so.pixel_aspect), (320, 90, 2.0));
|
||||
assert!(s.execute("layer.settings", json!({"layer": a, "pixelAspect": 0})).is_err());
|
||||
// One undo step each.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(solid_of(&s, a).1.width, 200);
|
||||
// A new solid takes a pixel aspect too.
|
||||
let d = s.execute("layer.newSolid", json!({"color": "#00ff00", "pixelAspect": 0.9091})).unwrap()["layer"].as_u64().unwrap();
|
||||
assert_eq!(solid_of(&s, d).1.pixel_aspect, 0.9091);
|
||||
// Text and shape layers have no settings.
|
||||
assert!(s.is_enabled("layer.settings"));
|
||||
s.execute("layer.newText", json!({"text": "T"})).unwrap();
|
||||
assert!(!s.is_enabled("layer.settings"));
|
||||
}
|
||||
|
||||
fn order(s: &Session) -> Vec<u64> {
|
||||
s.active_comp().unwrap().layers.iter().map(|l| l.id.0).collect()
|
||||
}
|
||||
|
||||
fn pos(s: &Session, l: u64) -> [f64; 3] {
|
||||
let ly = layer(s, l);
|
||||
let tr = ly.transform().unwrap();
|
||||
match tr.get("positionX") {
|
||||
Some(x) => [x.value.as_f64(), tr.get("positionY").unwrap().value.as_f64(), tr.get("positionZ").map(|z| z.value.as_f64()).unwrap_or(0.0)],
|
||||
None => tr.get("position").unwrap().value.as_vec3(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_layers_are_not_selected_deleted_or_moved() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
let b = solid(&mut s, "#00ff00");
|
||||
s.execute("layer.setSwitch", json!({"layers": [b], "switch": "lock", "value": true})).unwrap();
|
||||
assert!(layer(&s, b).switches.locked);
|
||||
s.execute("edit.selectAll", json!({})).unwrap();
|
||||
assert_eq!(s.state.selected_layers.iter().map(|l| l.0).collect::<Vec<_>>(), vec![a]);
|
||||
// Ctrl+Up / Down step over it.
|
||||
s.execute("layer.select", json!({"layers": [a]})).unwrap();
|
||||
s.execute("layer.selectPrevious", json!({})).unwrap();
|
||||
assert_eq!(s.state.selected_layers[0].0, a, "nothing above the locked layer to step to");
|
||||
// Deleting, arranging or transforming only the locked layer is refused; with others, it stays.
|
||||
let e = s.execute("edit.clear", json!({"layers": [b]})).unwrap_err().to_string();
|
||||
assert!(e.contains("locked"), "{e}");
|
||||
assert!(s.execute("layer.arrange", json!({"layers": [b], "to": "back"})).is_err());
|
||||
assert!(s.execute("layer.transform", json!({"layers": [b], "op": "center"})).is_err());
|
||||
s.execute("edit.clear", json!({"layers": [a, b]})).unwrap();
|
||||
assert_eq!(order(&s), vec![b]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_parent_keeps_its_children_in_place() {
|
||||
let mut s = comp();
|
||||
let parent = solid(&mut s, "#ff0000");
|
||||
s.execute("prop.set", json!({"layer": parent, "path": "transform/position", "value": [300, 200]})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": parent, "path": "transform/rotation", "value": 90})).unwrap();
|
||||
let child = solid(&mut s, "#00ff00");
|
||||
s.execute("prop.set", json!({"layer": child, "path": "transform/position", "value": [210, 160]})).unwrap();
|
||||
s.execute("layer.setParent", json!({"layers": [child], "parent": parent})).unwrap();
|
||||
assert_ne!(pos(&s, child)[..2], [210.0, 160.0], "expressed in the parent's space");
|
||||
s.execute("edit.clear", json!({"layers": [parent]})).unwrap();
|
||||
let p = pos(&s, child);
|
||||
assert!((p[0] - 210.0).abs() < 1e-6 && (p[1] - 160.0).abs() < 1e-6, "{p:?}");
|
||||
assert_eq!(layer(&s, child).parent, None);
|
||||
let r = layer(&s, child).transform().unwrap().get("rotation").unwrap().value.as_f64();
|
||||
assert!(r.abs() < 1e-6, "{r}");
|
||||
// One undo step restores both.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(layer(&s, child).parent.map(|l| l.0), Some(parent));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bring_forward_and_send_backward_move_each_layer_one_step() {
|
||||
let mut s = comp();
|
||||
let ids: Vec<u64> = (0..4).map(|_| solid(&mut s, "#ffffff")).collect();
|
||||
let top = order(&s);
|
||||
assert_eq!(top, ids.iter().rev().copied().collect::<Vec<_>>());
|
||||
let (l0, l1, l2, l3) = (top[0], top[1], top[2], top[3]);
|
||||
// A non-contiguous selection: each moves down one step.
|
||||
s.execute("layer.arrange", json!({"layers": [l0, l2], "to": "backward"})).unwrap();
|
||||
assert_eq!(order(&s), vec![l1, l0, l3, l2]);
|
||||
// Forward again; a layer already at the top stays there.
|
||||
s.execute("layer.arrange", json!({"layers": [l0, l2], "to": "forward"})).unwrap();
|
||||
assert_eq!(order(&s), vec![l0, l1, l2, l3]);
|
||||
s.execute("layer.arrange", json!({"layers": [l0, l2], "to": "forward"})).unwrap();
|
||||
assert_eq!(order(&s), vec![l0, l2, l1, l3]);
|
||||
// Front / Back keep the layers' order.
|
||||
s.execute("layer.arrange", json!({"layers": [l0, l1], "to": "back"})).unwrap();
|
||||
assert_eq!(order(&s), vec![l2, l3, l0, l1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_commands_keep_depth_and_follow_separated_dimensions() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
s.execute("layer.setSwitch", json!({"layers": [a], "switch": "threeD", "value": true})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": a, "path": "transform/position", "value": [10, 20, -300]})).unwrap();
|
||||
s.execute("layer.transform", json!({"layers": [a], "op": "center"})).unwrap();
|
||||
assert_eq!(pos(&s, a), [320.0, 180.0, -300.0]);
|
||||
s.execute("layer.transform", json!({"layers": [a], "op": "fit"})).unwrap();
|
||||
assert_eq!(pos(&s, a), [320.0, 180.0, -300.0]);
|
||||
let b = solid(&mut s, "#00ff00");
|
||||
s.execute("prop.set", json!({"layer": b, "path": "transform/position", "value": [10, 20]})).unwrap();
|
||||
s.execute("prop.separateDimensions", json!({"layer": b, "value": true})).unwrap();
|
||||
s.execute("layer.transform", json!({"layers": [b], "op": "center"})).unwrap();
|
||||
assert_eq!(pos(&s, b)[..2], [320.0, 180.0]);
|
||||
s.execute("layer.transform", json!({"layers": [b], "op": "reset"})).unwrap();
|
||||
assert_eq!(pos(&s, b)[..2], [320.0, 180.0]);
|
||||
// Center Anchor Point moves separated X / Y Position too, so the layer stays put.
|
||||
s.execute("prop.set", json!({"layer": b, "path": "transform/anchor", "value": [0, 0]})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": b, "path": "transform/positionX", "value": 100})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": b, "path": "transform/positionY", "value": 50})).unwrap();
|
||||
s.execute("layer.centerAnchor", json!({"layers": [b]})).unwrap();
|
||||
assert_eq!(pos(&s, b)[..2], [200.0, 100.0], "the 200×100 solid's centre");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicated_and_pasted_layers_keep_their_links() {
|
||||
let mut s = comp();
|
||||
let parent = solid(&mut s, "#ff0000");
|
||||
let matte = solid(&mut s, "#ffffff");
|
||||
let child = solid(&mut s, "#00ff00");
|
||||
s.execute("layer.setParent", json!({"layers": [child], "parent": parent})).unwrap();
|
||||
s.execute("layer.setTrackMatte", json!({"layer": child, "matte": matte, "kind": "alpha"})).unwrap();
|
||||
let m = layer(&s, child).track_matte.unwrap().layer;
|
||||
assert_eq!(m.0, matte);
|
||||
// Duplicating the child alone keeps the original parent and matte.
|
||||
let d = s.execute("edit.duplicate", json!({"layers": [child]})).unwrap()[0].as_u64().unwrap();
|
||||
assert_eq!((layer(&s, d).parent.map(|l| l.0), layer(&s, d).track_matte.map(|t| t.layer)), (Some(parent), Some(m)));
|
||||
// Duplicating parent and child together: the copy follows the copied parent.
|
||||
let r = s.execute("edit.duplicate", json!({"layers": [parent, child]})).unwrap();
|
||||
let (p2, c2) = (r[0].as_u64().unwrap(), r[1].as_u64().unwrap());
|
||||
assert_eq!(layer(&s, c2).parent.map(|l| l.0), Some(p2));
|
||||
// Copy / paste in the same composition keeps the links; in another one they are dropped.
|
||||
s.execute("layer.select", json!({"layers": [child]})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
let pasted = s.execute("edit.paste", json!({})).unwrap()[0].as_u64().unwrap();
|
||||
assert_eq!(layer(&s, pasted).parent.map(|l| l.0), Some(parent));
|
||||
assert_eq!(layer(&s, pasted).track_matte.map(|t| t.layer), Some(m));
|
||||
s.execute("comp.new", json!({"name": "Other", "width": 64, "height": 64})).unwrap();
|
||||
let other = s.execute("edit.paste", json!({})).unwrap()[0].as_u64().unwrap();
|
||||
assert_eq!((layer(&s, other).parent, layer(&s, other).track_matte), (None, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn precompose_takes_the_compositions_settings() {
|
||||
let mut s = Session::default();
|
||||
s.execute(
|
||||
"comp.new",
|
||||
json!({"name": "Main", "width": 640, "height": 360, "pixelAspect": 2.0, "shutterAngle": 90, "motionBlurSamples": 8, "renderer": "advanced3D", "background": [0.2, 0.3, 0.4]}),
|
||||
)
|
||||
.unwrap();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
let r = s.execute("layer.precompose", json!({"layers": [a], "name": "Inner"})).unwrap();
|
||||
let inner = s.project.comp(effectcraft_project::ItemId(r["comp"].as_u64().unwrap())).unwrap();
|
||||
assert_eq!((inner.pixel_aspect, inner.shutter_angle, inner.motion_blur_samples), (2.0, 90.0, 8));
|
||||
assert_eq!((inner.renderer, inner.background), (effectcraft_project::Renderer::Advanced3D, [0.2, 0.3, 0.4]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_comp_from_selection_is_one_undo_step_with_the_dialog_options() {
|
||||
let mut s = Session::default();
|
||||
let still = |s: &mut Session, name: &str, w: u32, par: f64| {
|
||||
let f = effectcraft_project::Footage {
|
||||
kind: effectcraft_project::FootageKind::Still,
|
||||
width: w,
|
||||
height: 100,
|
||||
pixel_aspect: par,
|
||||
has_video: true,
|
||||
..Default::default()
|
||||
};
|
||||
std::sync::Arc::make_mut(&mut s.project).add_item(name, effectcraft_color::Label::None, None, ItemKind::Footage(f))
|
||||
};
|
||||
let a = still(&mut s, "a.png", 200, 1.0);
|
||||
let b = still(&mut s, "b.png", 300, 2.0);
|
||||
s.state.project_selection = vec![a, b];
|
||||
// Multiple compositions (the default): one per item, its size and pixel aspect; one undo step.
|
||||
let steps = s.history.undo.len();
|
||||
let r = s.execute("file.newCompFromSelection", json!({"duration": 4})).unwrap();
|
||||
let comps: Vec<u64> = r["comps"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
|
||||
assert_eq!(comps.len(), 2);
|
||||
let cb = s.project.comp(effectcraft_project::ItemId(comps[1])).unwrap();
|
||||
assert_eq!((cb.width, cb.pixel_aspect, cb.duration.seconds()), (300, 2.0, 4.0));
|
||||
assert_eq!(s.history.undo.len(), steps + 1);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.project.comps().count(), 0);
|
||||
// One composition holding both, sized like the second, sequenced with a 1 s overlap, queued.
|
||||
s.state.project_selection = vec![a, b];
|
||||
let r = s
|
||||
.execute(
|
||||
"file.newCompFromSelection",
|
||||
json!({"single": true, "dimensionsFrom": 1, "duration": 3, "sequence": true, "overlap": true, "overlapDuration": 1, "addToRenderQueue": true}),
|
||||
)
|
||||
.unwrap();
|
||||
let c = s.project.comp(effectcraft_project::ItemId(r["comps"][0].as_u64().unwrap())).unwrap();
|
||||
assert_eq!((c.width, c.pixel_aspect, c.duration.seconds()), (300, 2.0, 5.0));
|
||||
let names: Vec<&str> = c.layers.iter().map(|l| l.name.as_str()).collect();
|
||||
assert_eq!(names, ["a.png", "b.png"], "first selected on top");
|
||||
// Stills have no rate: the comp is 29.97 fps and the second layer starts on the frame nearest 2 s.
|
||||
assert_eq!(c.layers[0].in_point.seconds(), 0.0);
|
||||
assert!((c.layers[1].in_point.seconds() - 2.0).abs() < 1.0 / 29.97, "{}", c.layers[1].in_point.seconds());
|
||||
assert_eq!(s.project.render_queue.len(), 1);
|
||||
assert_eq!(s.history.undo.len(), steps + 1);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(s.project.render_queue.is_empty() && s.project.comps().count() == 0);
|
||||
s.state.project_selection = vec![a, b];
|
||||
assert!(s.execute("file.newCompFromSelection", json!({"single": true, "dimensionsFrom": 2})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_files_keep_their_format_warn_about_newer_versions_and_never_save_unreadable() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
let path = tmp("Safe.ecproj");
|
||||
s.execute("file.saveAs", json!({"path": path})).unwrap();
|
||||
let saved = std::fs::read_to_string(&path).unwrap();
|
||||
assert_eq!(effectcraft_project::saved_by(&saved).as_deref(), Some(effectcraft_project::APP_VERSION));
|
||||
// Save a Copy and Increment and Save keep an XML project XML.
|
||||
let copy = tmp("Copy.ecprojx");
|
||||
s.execute("file.saveCopy", json!({"path": copy})).unwrap();
|
||||
assert!(std::fs::read_to_string(©).unwrap().starts_with("<?xml"));
|
||||
s.execute("file.saveAs", json!({"path": tmp("Xml.ecprojx")})).unwrap();
|
||||
let r = s.execute("file.incrementAndSave", json!({})).unwrap();
|
||||
let inc = r["path"].as_str().unwrap().to_string();
|
||||
assert!(inc.ends_with("Xml 2.ecprojx"), "{inc}");
|
||||
assert!(std::fs::read_to_string(&inc).unwrap().starts_with("<?xml"));
|
||||
let r = s.execute("file.open", json!({"path": inc})).unwrap();
|
||||
assert_eq!(r["savedBy"], json!(effectcraft_project::APP_VERSION));
|
||||
// A project from a newer version opens with a warning.
|
||||
let newer = tmp("Newer.ecproj");
|
||||
std::fs::write(&newer, saved.replacen(effectcraft_project::APP_VERSION, "999.0.0", 1)).unwrap();
|
||||
s.drain_events();
|
||||
let r = s.execute("file.open", json!({"path": newer})).unwrap();
|
||||
assert_eq!(r["savedBy"], json!("999.0.0"));
|
||||
let warned = s.drain_events().into_iter().any(|e| matches!(e, Event::Toast { message, .. } if message.contains("newer than this version")));
|
||||
assert!(warned);
|
||||
// A value a project file can't hold: Save fails and the file on disk is left as it was.
|
||||
s.execute("file.open", json!({"path": path})).unwrap();
|
||||
let cid = s.state.active_comp.unwrap();
|
||||
std::sync::Arc::make_mut(&mut s.project)
|
||||
.comp_mut(cid)
|
||||
.unwrap()
|
||||
.layer_mut(effectcraft_project::LayerId(a))
|
||||
.unwrap()
|
||||
.props
|
||||
.prop_mut("transform/opacity")
|
||||
.unwrap()
|
||||
.value = KV::Scalar(f64::NAN);
|
||||
let e = s.execute("file.save", json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains("can't be saved"), "{e}");
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), saved);
|
||||
// Not a project: a clear message.
|
||||
let junk = tmp("Junk.ecproj");
|
||||
std::fs::write(&junk, "{ not json").unwrap();
|
||||
let e = s.execute("file.open", json!({"path": junk})).unwrap_err().to_string();
|
||||
assert!(e.contains("is not a project EffectCraft can open"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comp_settings_preserve_frame_rate_and_resolution() {
|
||||
let mut s = comp();
|
||||
let info = s.execute("comp.info", json!({})).unwrap();
|
||||
assert_eq!((info["preserveFrameRate"].clone(), info["preserveResolution"].clone()), (json!(false), json!(false)));
|
||||
s.execute_checked("comp.settings", json!({"preserveFrameRate": true, "preserveResolution": true})).unwrap();
|
||||
let info = s.execute("comp.info", json!({})).unwrap();
|
||||
assert_eq!((info["preserveFrameRate"].clone(), info["preserveResolution"].clone()), (json!(true), json!(true)));
|
||||
// Pre-compose keeps them; one undo step clears them.
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
let r = s.execute("layer.precompose", json!({"layers": [a], "name": "Inner"})).unwrap();
|
||||
let inner = s.project.comp(effectcraft_project::ItemId(r["comp"].as_u64().unwrap())).unwrap();
|
||||
assert!(inner.preserve_frame_rate && inner.preserve_resolution);
|
||||
// Undo Pre-compose and the solid; then one more step undoes the settings.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(s.active_comp().unwrap().preserve_frame_rate);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(!s.active_comp().unwrap().preserve_frame_rate && !s.active_comp().unwrap().preserve_resolution);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_comp_from_selection_survives_hostile_sequence_numbers() {
|
||||
let mut s = Session::default();
|
||||
let still = |s: &mut Session, name: &str| {
|
||||
let f = effectcraft_project::Footage {
|
||||
kind: effectcraft_project::FootageKind::Still,
|
||||
width: 64,
|
||||
height: 64,
|
||||
pixel_aspect: 1.0,
|
||||
has_video: true,
|
||||
..Default::default()
|
||||
};
|
||||
std::sync::Arc::make_mut(&mut s.project).add_item(name, effectcraft_color::Label::None, None, ItemKind::Footage(f))
|
||||
};
|
||||
let a = still(&mut s, "a.png");
|
||||
let b = still(&mut s, "b.png");
|
||||
let c = still(&mut s, "c.png");
|
||||
for overlap in [json!(1e30), json!(-5), json!(f64::MAX)] {
|
||||
s.state.project_selection = vec![a, b, c];
|
||||
// A huge overlap overflowed the duration arithmetic (a panic with overflow checks, a
|
||||
// wrapped, too long comp without them).
|
||||
let r = s
|
||||
.execute("file.newCompFromSelection", json!({"single": true, "sequence": true, "overlap": true, "overlapDuration": overlap, "duration": 2}))
|
||||
.unwrap();
|
||||
let c = s.project.comp(effectcraft_project::ItemId(r["comps"][0].as_u64().unwrap())).unwrap();
|
||||
assert!(c.duration.seconds() <= 6.0 && c.duration > effectcraft_time::Tick::ZERO, "{overlap}: {}", c.duration.seconds());
|
||||
}
|
||||
s.state.project_selection = vec![a, b];
|
||||
assert!(s.execute("file.newCompFromSelection", json!({"single": true, "dimensionsFrom": 99})).is_err());
|
||||
}
|
||||
|
||||
fn key_times(s: &Session, l: u64) -> Vec<f64> {
|
||||
layer(s, l).props.prop("transform/opacity").unwrap().keys.iter().map(|k| (k.time.seconds() * 30.0).round() / 30.0).collect()
|
||||
}
|
||||
|
||||
fn opacity_keys(s: &mut Session, l: u64, times: &[f64]) {
|
||||
for (i, t) in times.iter().enumerate() {
|
||||
s.execute("prop.addKey", json!({"layer": l, "path": "transform/opacity", "time": t, "value": i as f64 * 10.0})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_a_key_past_another_keeps_it() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
opacity_keys(&mut s, a, &[1.0, 2.0]);
|
||||
s.execute("keys.select", json!({"keys": [{"layer": a, "path": "transform/opacity", "time": 1.0}]})).unwrap();
|
||||
// One drag, frame by frame, from 1 s over the 2 s key to 3 s.
|
||||
for _ in 0..60 {
|
||||
s.execute("keys.move", json!({"delta": 1.0 / 30.0, "merge": "key-drag"})).unwrap();
|
||||
}
|
||||
assert_eq!(key_times(&s, a), vec![2.0, 3.0], "the 2 s key passed over is still there");
|
||||
// Dropped exactly on a key: it replaces it, as in After Effects.
|
||||
s.history.merge_key = None;
|
||||
s.execute("keys.move", json!({"delta": -1.0, "merge": "key-drag-2"})).unwrap();
|
||||
assert_eq!(key_times(&s, a), vec![2.0]);
|
||||
// The whole drag was one undo step.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(key_times(&s, a), vec![1.0, 2.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_of_a_stretched_layer_move_with_the_pointer() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
opacity_keys(&mut s, a, &[0.0, 1.0]);
|
||||
s.execute_checked("layer.timeStretch", json!({"layers": [a], "percent": 200})).unwrap();
|
||||
assert_eq!(layer(&s, a).stretch, 200.0);
|
||||
let comp_time = |s: &Session, i: usize| {
|
||||
let l = layer(s, a);
|
||||
l.comp_time(l.props.prop("transform/opacity").unwrap().keys[i].time).seconds()
|
||||
};
|
||||
let before = comp_time(&s, 1);
|
||||
s.execute("keys.select", json!({"keys": [{"layer": a, "path": "transform/opacity", "time": 1.0}]})).unwrap();
|
||||
s.execute("keys.move", json!({"delta": 0.5})).unwrap();
|
||||
assert!((comp_time(&s, 1) - (before + 0.5)).abs() < 1.0 / 30.0, "{} → {}", before, comp_time(&s, 1));
|
||||
s.execute("keys.nudge", json!({"frames": 3})).unwrap();
|
||||
assert!((comp_time(&s, 1) - (before + 0.6)).abs() < 1.0 / 60.0, "three comp frames");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_selection_toggles_and_skips_locked_layers_and_hold_restores_bezier() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
opacity_keys(&mut s, a, &[0.0, 1.0]);
|
||||
let k = |t: f64| json!({"layer": a, "path": "transform/opacity", "time": t});
|
||||
s.execute("keys.select", json!({"keys": [k(0.0)]})).unwrap();
|
||||
s.execute("keys.select", json!({"keys": [k(1.0)], "toggle": true})).unwrap();
|
||||
assert_eq!(s.state.selected_keys.len(), 2);
|
||||
s.execute("keys.select", json!({"keys": [k(0.0)], "toggle": true})).unwrap();
|
||||
assert_eq!(s.state.selected_keys.len(), 1, "toggled out");
|
||||
// Hold off returns an eased key to Bezier.
|
||||
s.execute("keys.easyEase", json!({})).unwrap();
|
||||
s.execute("keys.toggleHold", json!({})).unwrap();
|
||||
s.execute("keys.toggleHold", json!({})).unwrap();
|
||||
let kf = layer(&s, a).props.prop("transform/opacity").unwrap().keys[1].clone();
|
||||
assert_eq!(kf.out_interp, effectcraft_keyframe::Interp::Bezier);
|
||||
// Locked: its keys can't be selected.
|
||||
s.execute("layer.setSwitch", json!({"layers": [a], "switch": "lock", "value": true})).unwrap();
|
||||
s.execute("keys.select", json!({"keys": [k(0.0)]})).unwrap();
|
||||
assert!(s.state.selected_keys.is_empty());
|
||||
s.execute("keys.selectAll", json!({"layers": [a]})).unwrap();
|
||||
assert!(s.state.selected_keys.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_drags_apply_their_whole_offset_to_the_keys_they_started_with() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
opacity_keys(&mut s, a, &[0.0, 1.0, 2.0]);
|
||||
s.execute("keys.selectAll", json!({"layers": [a]})).unwrap();
|
||||
// Squeezed onto one frame on the way, then back: the three keys survive.
|
||||
for k in [0.5, 0.01, 0.0001, 1.0] {
|
||||
s.execute("keys.transform", json!({"timeScale": k, "timeAnchor": 0.0, "merge": "box", "fromStart": true})).unwrap();
|
||||
}
|
||||
assert_eq!(key_times(&s, a), vec![0.0, 1.0, 2.0]);
|
||||
// A slow move in tiny steps still arrives (each step is the whole offset).
|
||||
s.history.merge_key = None;
|
||||
for i in 1..=30 {
|
||||
s.execute("keys.transform", json!({"timeOffset": i as f64 * 0.1 / 30.0, "merge": "move", "fromStart": true})).unwrap();
|
||||
}
|
||||
assert_eq!(key_times(&s, a), vec![0.1, 1.1, 2.1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_equal_previous_and_following_keyframes() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
for (t, v) in [(0.0, 20.0), (1.0, 50.0), (2.0, 20.0), (3.0, 70.0)] {
|
||||
s.execute("prop.addKey", json!({"layer": a, "path": "transform/opacity", "time": t, "value": v})).unwrap();
|
||||
}
|
||||
let pick = |s: &mut Session, t: f64| s.execute("keys.select", json!({"keys": [{"layer": a, "path": "transform/opacity", "time": t}]})).unwrap();
|
||||
let times = |s: &Session| {
|
||||
let mut v: Vec<f64> = s.state.selected_keys.iter().map(|k| k.time.seconds()).collect();
|
||||
v.sort_by(f64::total_cmp);
|
||||
v
|
||||
};
|
||||
pick(&mut s, 0.0);
|
||||
s.execute("keys.selectEqual", json!({})).unwrap();
|
||||
assert_eq!(times(&s), vec![0.0, 2.0]);
|
||||
pick(&mut s, 2.0);
|
||||
s.execute("keys.selectPrevious", json!({})).unwrap();
|
||||
assert_eq!(times(&s), vec![0.0, 1.0, 2.0]);
|
||||
pick(&mut s, 1.0);
|
||||
s.execute("keys.selectFollowing", json!({})).unwrap();
|
||||
assert_eq!(times(&s), vec![1.0, 2.0, 3.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_from_several_layers_paste_in_order_and_paste_reversed_respects_stretch() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
let b = solid(&mut s, "#00ff00");
|
||||
opacity_keys(&mut s, a, &[0.0, 1.0]);
|
||||
s.execute("prop.addKey", json!({"layer": b, "path": "transform/opacity", "time": 0.0, "value": 77})).unwrap();
|
||||
let c = solid(&mut s, "#0000ff");
|
||||
let d = solid(&mut s, "#ffffff");
|
||||
// Copy A's and B's Opacity keys, paste onto C and D: A → C, B → D (in order).
|
||||
s.execute("keys.selectAll", json!({"layers": [a, b]})).unwrap();
|
||||
s.execute("keys.copy", json!({})).unwrap();
|
||||
s.execute("time.set", json!({"time": 2.0})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [c, d]})).unwrap();
|
||||
s.execute("keys.paste", json!({})).unwrap();
|
||||
assert_eq!(key_times(&s, c), vec![2.0, 3.0], "A's two keys");
|
||||
assert_eq!(key_times(&s, d), vec![2.0], "B's one key");
|
||||
// Paste Reversed onto a layer stretched to 200 %: the reversed keys land 2 s apart there too.
|
||||
let e = solid(&mut s, "#808080");
|
||||
s.execute_checked("layer.timeStretch", json!({"layers": [e], "percent": 200})).unwrap();
|
||||
s.execute("keys.selectAll", json!({"layers": [a]})).unwrap();
|
||||
s.execute("keys.copy", json!({})).unwrap();
|
||||
s.execute("time.set", json!({"time": 0.0})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [e]})).unwrap();
|
||||
s.execute("edit.pasteReversedKeyframes", json!({})).unwrap();
|
||||
let pr = layer(&s, e).props.prop("transform/opacity").unwrap().clone();
|
||||
let l = layer(&s, e);
|
||||
let ct: Vec<f64> = pr.keys.iter().map(|k| l.comp_time(k.time).seconds()).collect();
|
||||
let vals: Vec<f64> = pr.keys.iter().map(|k| k.value.as_f64()).collect();
|
||||
assert!(ct.len() == 2 && (ct[1] - ct[0] - 1.0).abs() < 1e-6, "keys 1 s apart in comp time: {ct:?}");
|
||||
assert_eq!(vals, vec![10.0, 0.0], "reversed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn j_and_k_stop_at_keys_markers_and_the_work_area() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
opacity_keys(&mut s, a, &[2.0]);
|
||||
s.execute("comp.workArea", json!({"start": 1.0, "end": 3.0})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [a]})).unwrap();
|
||||
let mut stops = vec![];
|
||||
for _ in 0..4 {
|
||||
s.execute("time.nextKey", json!({})).unwrap();
|
||||
stops.push((s.time().seconds() * 30.0).round() / 30.0);
|
||||
}
|
||||
let last = (3.0 * 30.0 - 1.0) / 30.0;
|
||||
assert_eq!(stops, vec![1.0, 2.0, (last * 30.0f64).round() / 30.0, (last * 30.0f64).round() / 30.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn j_k_and_select_all_use_only_the_properties_the_timeline_shows() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
opacity_keys(&mut s, a, &[1.0]);
|
||||
s.execute("prop.addKey", json!({"layer": a, "path": "transform/rotation", "time": 2.0, "value": 45})).unwrap();
|
||||
let op = layer(&s, a).props.prop("transform/opacity").unwrap().uid;
|
||||
// Only Opacity is revealed: K skips the hidden Rotation key at 2 s, stopping at 1 s and then
|
||||
// at the work area's end.
|
||||
let visible = json!([{"layer": a, "prop": op}]);
|
||||
s.execute_checked("time.nextKey", json!({"visible": visible})).unwrap();
|
||||
assert_eq!(s.time().seconds(), 1.0);
|
||||
s.execute_checked("time.nextKey", json!({"visible": visible})).unwrap();
|
||||
assert!(s.time().seconds() > 3.9, "work area end, not the hidden key: {}", s.time().seconds());
|
||||
// Without `visible` (agents), every key of the layers counts, as before.
|
||||
s.execute("time.set", json!({"time": 1.0})).unwrap();
|
||||
s.execute("time.nextKey", json!({})).unwrap();
|
||||
assert_eq!(s.time().seconds(), 2.0);
|
||||
// Select All Keyframes: the shown properties' keys only.
|
||||
s.execute_checked("keys.selectAll", json!({"visible": visible})).unwrap();
|
||||
assert_eq!(s.state.selected_keys.len(), 1);
|
||||
assert_eq!(s.state.selected_keys[0].prop, op);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,122 @@
|
|||
//! Motion blur of collapsed precomps (the precomp layer's own motion; the containing comp's
|
||||
//! shutter) and of animated shape content, rendered through the commands.
|
||||
|
||||
use effectcraft_raster::Image;
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::render::RenderOpts;
|
||||
|
||||
const W: u32 = 160;
|
||||
const H: u32 = 90;
|
||||
|
||||
fn id(v: &Value, k: &str) -> u64 {
|
||||
v[k].as_u64().unwrap()
|
||||
}
|
||||
|
||||
fn render(s: &Session, comp: u64) -> Image {
|
||||
s.render(effectcraft_project::ItemId(comp), Tick::from_seconds_f64(0.5), RenderOpts::default())
|
||||
}
|
||||
|
||||
/// Pixels with partial alpha (motion-blur smear).
|
||||
fn partial(img: &Image) -> usize {
|
||||
img.data.iter().filter(|p| p[3] > 0.05 && p[3] < 0.95).count()
|
||||
}
|
||||
|
||||
fn max_diff(a: &Image, b: &Image) -> f32 {
|
||||
a.data.iter().zip(&b.data).flat_map(|(p, q)| (0..4).map(move |c| (p[c] - q[c]).abs())).fold(0.0, f32::max)
|
||||
}
|
||||
|
||||
/// "Main" holding "Pre" (a 20×20 white square in the middle), the precomp layer moving 480 px/s
|
||||
/// (20 px a frame at 24 fps) with motion blur on. `inner_move`: the square moves inside Pre too
|
||||
/// (with its own motion blur switch), and Pre's shutter is closed (0°).
|
||||
fn scene(collapse: bool, inner_move: bool) -> (Session, u64) {
|
||||
let mut s = Session::default();
|
||||
let pre = id(&s.execute("comp.new", json!({"name": "Pre", "width": W, "height": H, "frameRate": 24, "duration": 2})).unwrap(), "comp");
|
||||
let sq = id(&s.execute("layer.newSolid", json!({"name": "Square", "color": "#ffffff", "width": 20, "height": 20})).unwrap(), "layer");
|
||||
if inner_move {
|
||||
s.execute("comp.settings", json!({"shutterAngle": 0})).unwrap();
|
||||
s.execute("comp.setSwitch", json!({"switch": "motionBlur", "value": true})).unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [sq], "switch": "motionBlur", "value": true})).unwrap();
|
||||
for (t, x) in [(0.0, -160.0), (1.0, 320.0)] {
|
||||
s.execute("prop.addKey", json!({"layer": sq, "path": "transform/position", "time": t, "value": [x, 45.0]})).unwrap();
|
||||
}
|
||||
}
|
||||
let main = id(&s.execute("comp.new", json!({"name": "Main", "width": W, "height": H, "frameRate": 24, "duration": 2})).unwrap(), "comp");
|
||||
s.execute("comp.setSwitch", json!({"switch": "motionBlur", "value": true})).unwrap();
|
||||
let l = id(&s.execute("layer.addItem", json!({"item": pre})).unwrap(), "layer");
|
||||
s.execute("layer.setSwitch", json!({"layers": [l], "switch": "motionBlur", "value": true})).unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [l], "switch": "collapse", "value": collapse})).unwrap();
|
||||
if !inner_move {
|
||||
for (t, x) in [(0.0, -160.0), (1.0, 320.0)] {
|
||||
s.execute("prop.addKey", json!({"layer": l, "path": "transform/position", "time": t, "value": [x, 45.0]})).unwrap();
|
||||
}
|
||||
}
|
||||
(s, main)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_collapsed_precomp_layer_blurs_with_its_own_motion() {
|
||||
let (s, main) = scene(true, false);
|
||||
let collapsed = render(&s, main);
|
||||
// The square sits at x = 80 at 0.5 s and smears over 10 px (180° of 20 px).
|
||||
assert!(partial(&collapsed) > 100, "smeared: {}", partial(&collapsed));
|
||||
// As blurred as the same precomp rendered flat.
|
||||
let (s, main) = scene(false, false);
|
||||
let flat = render(&s, main);
|
||||
assert!(max_diff(&collapsed, &flat) < 0.08, "{}", max_diff(&collapsed, &flat));
|
||||
// Motion blur off for the render: sharp.
|
||||
let sharp = s.render(effectcraft_project::ItemId(main), Tick::from_seconds_f64(0.5), RenderOpts { motion_blur: false, ..Default::default() });
|
||||
assert!(partial(&sharp) < 10, "{}", partial(&sharp));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collapsed_layers_use_the_containing_comps_shutter() {
|
||||
// Pre's own shutter is closed; drawn into Main (180°) the square's own motion blurs.
|
||||
let (s, main) = scene(true, true);
|
||||
assert!(partial(&render(&s, main)) > 100, "{}", partial(&render(&s, main)));
|
||||
// Rendered flat, Pre uses its own (closed) shutter.
|
||||
let (s, main) = scene(false, true);
|
||||
assert!(partial(&render(&s, main)) < 10, "{}", partial(&render(&s, main)));
|
||||
}
|
||||
|
||||
/// A shape layer whose rectangle moves inside its contents (not its transform): motion blur
|
||||
/// smears it like the same move made with the layer's Position.
|
||||
#[test]
|
||||
fn animated_shape_content_is_motion_blurred() {
|
||||
let shape = |content: bool, blur: bool| {
|
||||
let mut s = Session::default();
|
||||
let c = id(&s.execute("comp.new", json!({"name": "S", "width": W, "height": H, "frameRate": 24, "duration": 2})).unwrap(), "comp");
|
||||
s.execute("comp.setSwitch", json!({"switch": "motionBlur", "value": true})).unwrap();
|
||||
let l = id(&s.execute("layer.newShape", json!({"kind": "rect", "size": [20, 20], "fill": "#ffffff", "position": [80, 45]})).unwrap(), "layer");
|
||||
s.execute("layer.setSwitch", json!({"layers": [l], "switch": "motionBlur", "value": blur})).unwrap();
|
||||
let (target, base) = if content {
|
||||
let layer = s.active_comp().unwrap().layer(effectcraft_project::LayerId(l)).unwrap().clone();
|
||||
let mut found = None;
|
||||
layer.props.group("contents").unwrap().walk("contents", &mut |_, p| {
|
||||
if found.is_none() && p.match_id == "position" {
|
||||
found = Some((json!({"prop": p.uid}), p.value.as_vec2()));
|
||||
}
|
||||
});
|
||||
found.unwrap()
|
||||
} else {
|
||||
(json!({"path": "transform/position"}), [80.0, 45.0])
|
||||
};
|
||||
for (t, dx) in [(0.0, -240.0), (1.0, 240.0)] {
|
||||
let mut p = target.clone();
|
||||
p["layer"] = json!(l);
|
||||
p["time"] = json!(t);
|
||||
p["value"] = json!([base[0] + dx, base[1]]);
|
||||
s.execute("prop.addKey", p).unwrap();
|
||||
}
|
||||
render(&s, c)
|
||||
};
|
||||
let content = shape(true, true);
|
||||
assert!(partial(&content) > 100, "smeared: {}", partial(&content));
|
||||
// Like the layer moved by its transform…
|
||||
let moved = shape(false, true);
|
||||
assert!(max_diff(&content, &moved) < 0.15, "{}", max_diff(&content, &moved));
|
||||
// …and sharp with the layer's motion blur switch off.
|
||||
assert!(partial(&shape(true, false)) < 40, "{}", partial(&shape(true, false)));
|
||||
}
|
||||
|
|
@ -182,7 +182,7 @@ fn interpret_proxy_settings_apply_to_the_proxy() {
|
|||
assert!(px.footage.invert_alpha);
|
||||
let f = match &s.project.item(full).unwrap().kind {
|
||||
ItemKind::Footage(f) => f.clone(),
|
||||
_ => unreachable!(),
|
||||
_ => panic!("not footage"),
|
||||
};
|
||||
assert_eq!(f.alpha, AlphaMode::Straight, "the main footage keeps its interpretation");
|
||||
}
|
||||
|
|
@ -243,7 +243,7 @@ fn guess_alpha_invert_and_linear_light() {
|
|||
let h = import(&mut s, "/m/half.png");
|
||||
let foot = |s: &Session, i: ItemId| match &s.project.item(i).unwrap().kind {
|
||||
ItemKind::Footage(f) => f.clone(),
|
||||
_ => unreachable!(),
|
||||
_ => panic!("not footage"),
|
||||
};
|
||||
s.execute("file.interpretFootage", json!({"items": [b.0, w.0, h.0], "alpha": "guess"})).unwrap();
|
||||
assert_eq!(foot(&s, b).alpha, AlphaMode::Premultiplied);
|
||||
|
|
|
|||
|
|
@ -464,3 +464,25 @@ fn open_recent_increment_and_revert() {
|
|||
assert_eq!(autosave::increment_path("/x/v1.ecproj", |p| p.ends_with("v1 2.ecproj")), "/x/v1 3.ecproj");
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_tick_never_waits_for_the_system() {
|
||||
let mut s = Session::default();
|
||||
let t0 = std::time::Instant::now();
|
||||
s.memory_tick();
|
||||
s.memory_tick();
|
||||
// Reading the system's memory starts PowerShell on Windows (about a second): the UI thread
|
||||
// only takes the last reading.
|
||||
assert!(t0.elapsed() < std::time::Duration::from_millis(200), "{:?}", t0.elapsed());
|
||||
if crate::sysinfo::memory().is_some() {
|
||||
let mut got = None;
|
||||
for _ in 0..200 {
|
||||
got = s.memory_watch.latest();
|
||||
if got.is_some() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
}
|
||||
assert!(got.is_some_and(|m| m.total > 0), "the reading arrives in the background");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -407,8 +407,7 @@ impl Session {
|
|||
}
|
||||
self.bump();
|
||||
}
|
||||
if finished {
|
||||
let mut job = self.track_job.take().expect("job");
|
||||
if finished && let Some(mut job) = self.track_job.take() {
|
||||
if let Some(t) = job.thread.take() {
|
||||
let _ = t.join();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -375,7 +375,7 @@ impl Session {
|
|||
if !job.is_finished() {
|
||||
return false;
|
||||
}
|
||||
let mut job = self.warp_job.take().expect("job");
|
||||
let Some(mut job) = self.warp_job.take() else { return false };
|
||||
if let Some(t) = job.thread.take() {
|
||||
let _ = t.join();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@
|
|||
//! H.264 requires even dimensions: odd output sizes are cropped by one pixel (padded when the
|
||||
//! output is a single pixel wide/high).
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod encode;
|
||||
mod hevc_av1;
|
||||
mod out;
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@
|
|||
//! the project through memoized host requests (see [`host`]), so nothing borrowed ever enters
|
||||
//! the JS heap. Expressions reading each other nest up to [`MAX_DEPTH`] levels.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod host;
|
||||
mod noise;
|
||||
pub mod rewrite;
|
||||
|
|
@ -182,6 +184,7 @@ pub fn eval_property(project: &Project, comp: ItemId, layer: LayerId, path: &str
|
|||
|
||||
/// Syntax-check an expression (after the array-maths rewrite) without evaluating it.
|
||||
pub fn check_syntax(text: &str) -> Result<(), String> {
|
||||
rewrite::check_nesting(text)?;
|
||||
runtime::check(text)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,15 @@ function __begin(t, v, c, l, path, uid, idx, fd, ti, tt, sv) {
|
|||
}
|
||||
|
||||
function __finish(r) {
|
||||
// Rethrow from this frame so the runtime's stack unwinds (see `Runtime::script`).
|
||||
try {
|
||||
return __result(r);
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
function __result(r) {
|
||||
r = __v(r);
|
||||
if (r !== null && typeof r === 'object' && r.__isLayer) return r.index;
|
||||
if (r !== null && typeof r === 'object' && r.__isTextStyle) return r.__out();
|
||||
|
|
|
|||
|
|
@ -304,8 +304,11 @@ fn build(toks: Vec<Tok>) -> Vec<Item> {
|
|||
for t in toks {
|
||||
if t.kind == Kind::Punct && matches!(t.text.as_str(), "(" | "[" | "{") {
|
||||
stack.push((t, std::mem::take(&mut cur)));
|
||||
} else if t.kind == Kind::Punct && matches!(t.text.as_str(), ")" | "]" | "}") && stack.last().is_some_and(|(o, _)| closer(&o.text) == t.text) {
|
||||
let (open, parent) = stack.pop().unwrap_or_else(|| unreachable!());
|
||||
} else if t.kind == Kind::Punct
|
||||
&& matches!(t.text.as_str(), ")" | "]" | "}")
|
||||
&& stack.last().is_some_and(|(o, _)| closer(&o.text) == t.text)
|
||||
&& let Some((open, parent)) = stack.pop()
|
||||
{
|
||||
let inner = std::mem::replace(&mut cur, parent);
|
||||
cur.push(Item::Group { open, inner, close: Some(t) });
|
||||
} else {
|
||||
|
|
@ -545,17 +548,67 @@ fn push_piece(out: &mut String, p: Piece) {
|
|||
out.push_str(&p.body);
|
||||
}
|
||||
|
||||
/// Rewrite an expression's source for array-aware arithmetic. Line breaks are preserved.
|
||||
pub fn rewrite(src: &str) -> String {
|
||||
/// The deepest nesting (brackets, plus chains of prefix operators) an expression may have,
|
||||
/// before and after the rewrite (which nests a call per arithmetic operator). The JavaScript
|
||||
/// parser recurses per level and a 2 MiB thread overflows its stack (aborting the app) at
|
||||
/// about 60 levels, so deeper expressions are rejected with an error instead.
|
||||
pub const MAX_NESTING: usize = 32;
|
||||
|
||||
fn nesting(toks: &[Tok]) -> usize {
|
||||
let (mut depth, mut prefix, mut max) = (0usize, 0usize, 0usize);
|
||||
for t in toks {
|
||||
if t.kind == Kind::Punct && matches!(t.text.as_str(), "(" | "[" | "{") {
|
||||
depth += 1;
|
||||
} else if t.kind == Kind::Punct && matches!(t.text.as_str(), ")" | "]" | "}") {
|
||||
depth = depth.saturating_sub(1);
|
||||
}
|
||||
let is_prefix = (t.kind == Kind::Punct && matches!(t.text.as_str(), "!" | "~" | "-" | "+")) || t.is_kw(&["typeof", "void", "delete"]);
|
||||
prefix = if is_prefix { prefix + 1 } else { 0 };
|
||||
max = max.max(depth + prefix);
|
||||
}
|
||||
max
|
||||
}
|
||||
|
||||
/// An error when `src` nests deeper than [`MAX_NESTING`] levels (before or after the rewrite).
|
||||
pub fn check_nesting(src: &str) -> Result<(), String> {
|
||||
let (toks, tail) = tokenize(src);
|
||||
if nesting(&toks) > MAX_NESTING {
|
||||
return Err(too_deep());
|
||||
}
|
||||
let mut out = rewrite_seq(&build(toks));
|
||||
out.push_str(&tail);
|
||||
if nesting(&tokenize(&out).0) > MAX_NESTING {
|
||||
return Err(too_deep());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn too_deep() -> String {
|
||||
format!("Error: expression is nested too deeply (more than {MAX_NESTING} levels of brackets or chained operators)")
|
||||
}
|
||||
|
||||
/// Rewrite an expression's source for array-aware arithmetic. Line breaks are preserved.
|
||||
/// An expression nested deeper than [`MAX_NESTING`] becomes a `throw` of that error.
|
||||
pub fn rewrite(src: &str) -> String {
|
||||
let (toks, tail) = tokenize(src);
|
||||
if nesting(&toks) > MAX_NESTING {
|
||||
return throw_too_deep();
|
||||
}
|
||||
let mut out = rewrite_seq(&build(toks));
|
||||
out.push_str(&tail);
|
||||
if nesting(&tokenize(&out).0) > MAX_NESTING {
|
||||
return throw_too_deep();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn throw_too_deep() -> String {
|
||||
format!("throw new RangeError({:?})", too_deep().trim_start_matches("Error: "))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::rewrite;
|
||||
use super::{MAX_NESTING, check_nesting, rewrite};
|
||||
|
||||
/// Compare ignoring spaces (newlines are significant).
|
||||
fn n(s: &str) -> String {
|
||||
|
|
@ -599,4 +652,25 @@ mod tests {
|
|||
assert_eq!(n(&rewrite("i++ + 1")), n("__add(i++, 1)"));
|
||||
assert_eq!(n(&rewrite("2 ** 3 * 2")), n("__mul(2 ** 3, 2)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_nesting_is_an_error_not_a_stack_overflow() {
|
||||
// Each level costs the JavaScript parser stack: ~60 nested brackets overflowed a 2 MiB
|
||||
// thread and aborted the app; 100 000 overflowed the rewriter itself.
|
||||
for n in [MAX_NESTING + 1, 100, 100_000] {
|
||||
let parens = "(".repeat(n) + "1" + &")".repeat(n);
|
||||
let unclosed = "[".repeat(n);
|
||||
let sum = vec!["x"; n + 1].join(" + ");
|
||||
let nots = "!".repeat(n) + "x";
|
||||
for src in [parens, unclosed, sum, nots] {
|
||||
assert!(rewrite(&src).starts_with("throw new RangeError("), "{n}");
|
||||
assert!(check_nesting(&src).is_err());
|
||||
assert!(crate::check_syntax(&src).unwrap_err().contains("nested too deeply"));
|
||||
}
|
||||
}
|
||||
let ok = "(".repeat(MAX_NESTING - 2) + "a + b" + &")".repeat(MAX_NESTING - 2);
|
||||
assert!(check_nesting(&ok).is_ok());
|
||||
assert!(crate::check_syntax(&ok).is_ok());
|
||||
assert!(check_nesting(&vec!["x"; 20].join(" + ")).is_ok());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -238,8 +238,11 @@ impl Runtime {
|
|||
self.scripts.clear();
|
||||
}
|
||||
// A block keeps `let`/`const` local to one evaluation; `{` shares line 1 with the code so
|
||||
// error line numbers match the user's text.
|
||||
let src = format!("{{{}\n}}", crate::rewrite::rewrite(text));
|
||||
// error line numbers match the user's text. Errors are rethrown from the script's own
|
||||
// frame: boa leaves the stack of the inner frames behind when an exception leaves the
|
||||
// script from a nested call (every host request does), and the thread's runtime would
|
||||
// fail every expression with "reached the maximum stack size" after a few hundred.
|
||||
let src = format!("try{{{}\n}}catch(__e){{throw __e}}", crate::rewrite::rewrite(text));
|
||||
let s = Script::parse(Source::from_bytes(src.as_bytes()), None, &mut self.ctx).map_err(|e| format_error(&e.to_string(), line_count(text)));
|
||||
self.scripts.insert(text.to_string(), s.clone());
|
||||
s
|
||||
|
|
|
|||
|
|
@ -388,6 +388,22 @@ fn circular_references_terminate() {
|
|||
assert!(matches!(r, Ok(Value::Scalar(_))) || r.is_err(), "{r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_and_property_reads_keep_the_runtime_usable() {
|
||||
// Errors thrown inside helpers, and the aborts every property read makes until the host
|
||||
// answers, must not use up the thread's runtime (it used to fail every expression after a
|
||||
// few hundred with "reached the maximum stack size").
|
||||
let f = fx();
|
||||
for _ in 0..3000 {
|
||||
assert!(f.ev(f.b, ROT, "thisComp.layer('Ghost').position", 0.0).is_err());
|
||||
}
|
||||
for i in 0..1000 {
|
||||
let t = i as f64 / 1000.0;
|
||||
assert_close!(f.ev(f.b, ROT, "thisComp.layer('A').transform.rotation", t).unwrap().as_f64(), 90.0 * t);
|
||||
}
|
||||
assert_close!(f.num("time * 2", 1.0), 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effect_slider_reference() {
|
||||
let f = fx();
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
//! clockwise on screen, 3D uses a left-handed system with +z pointing into the screen, and a 3D
|
||||
//! layer's rotation is Orientation (X, Y, Z) followed by X Rotation, Y Rotation, Z Rotation.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
use std::ops::{Add, AddAssign, Div, Mul, MulAssign, Neg, Sub, SubAssign};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
|
|
|||
|
|
@ -507,9 +507,12 @@ impl GpuContext {
|
|||
.map_err(|e| format!("no device: {e}"))?;
|
||||
device.on_uncaptured_error(Arc::new(|e| {
|
||||
// Under test a validation error (a WGSL typo invalidates every kernel) fails loudly.
|
||||
if cfg!(test) {
|
||||
panic!("wgpu: {e}");
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::panic)] // test builds only
|
||||
{
|
||||
panic!("wgpu: {e}")
|
||||
}
|
||||
#[cfg(not(test))]
|
||||
log::error!("wgpu: {e}")
|
||||
}));
|
||||
GpuContext::new(&adapter, device, queue)
|
||||
|
|
|
|||
|
|
@ -51,6 +51,8 @@
|
|||
//! skip readback entirely with [`Gpu::render_display`], which leaves an RGBA8 texture for
|
||||
//! egui-wgpu to draw.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod adv3d;
|
||||
mod bokeh;
|
||||
mod classic3d;
|
||||
|
|
|
|||
|
|
@ -380,7 +380,9 @@ impl<'a> FrameCoder<'a> {
|
|||
return cost;
|
||||
}
|
||||
}
|
||||
let (cost, e, snap, mode) = best.expect("a coding unit decision");
|
||||
// A coding unit always has at least one candidate mode (intra, or skip/merge in P
|
||||
// slices); without one, report an infinite cost.
|
||||
let Some((cost, e, snap, mode)) = best else { return f64::INFINITY };
|
||||
self.restore(&snap);
|
||||
*est = e;
|
||||
decs.push(CuDec { x: x as u32, y: y as u32, log2, mode });
|
||||
|
|
@ -452,7 +454,8 @@ impl<'a> FrameCoder<'a> {
|
|||
let mv = match mode {
|
||||
CuMode::Skip { idx } | CuMode::Merge { idx } => self.merge_list(x, y, n)[idx as usize],
|
||||
CuMode::Amvp { mv, .. } => mv,
|
||||
CuMode::Intra { .. } => unreachable!(),
|
||||
// Handled by the arm above.
|
||||
CuMode::Intra { .. } => [0, 0],
|
||||
};
|
||||
let amvp = if let CuMode::Amvp { .. } = mode { self.amvp(x, y, n) } else { [[0; 2]; 2] };
|
||||
let skip = matches!(mode, CuMode::Skip { .. });
|
||||
|
|
@ -503,7 +506,8 @@ impl<'a> FrameCoder<'a> {
|
|||
self.write_tt(s, x, y, x, y, log2, 0, 0, [true, true], false, false, &mut ti);
|
||||
}
|
||||
}
|
||||
CuMode::Intra { .. } => unreachable!(),
|
||||
// Handled by the arm above.
|
||||
CuMode::Intra { .. } => {}
|
||||
}
|
||||
(eff, self.cu_dist(x, y, n))
|
||||
}
|
||||
|
|
@ -864,7 +868,8 @@ impl<'a> FrameCoder<'a> {
|
|||
}
|
||||
|
||||
fn predict_inter(&mut self, x: usize, y: usize, n: usize, mv: [i16; 2]) {
|
||||
let refp = self.refp.expect("reference picture");
|
||||
// Inter modes are only tried in P slices, which have a reference picture.
|
||||
let Some(refp) = self.refp else { return };
|
||||
for c in 0..3 {
|
||||
let (px, py, pn, pw, ph) = if c == 0 { (x, y, n, self.w, self.h) } else { (x / 2, y / 2, n / 2, self.cw, self.ch) };
|
||||
let pr = PlaneRef { data: &refp[c], w: pw, h: ph };
|
||||
|
|
@ -882,7 +887,7 @@ impl<'a> FrameCoder<'a> {
|
|||
|
||||
/// Best merge index by SATD of the (approximate) luma prediction.
|
||||
fn best_merge(&self, x: usize, y: usize, n: usize, list: &[[i16; 2]; MAX_MERGE_CAND]) -> (usize, u64) {
|
||||
let me = self.me.expect("motion search reference");
|
||||
let Some(me) = self.me else { return (0, u64::MAX / 4) };
|
||||
let mut buf = vec![0u16; n * n];
|
||||
let mut best = (0, u64::MAX);
|
||||
for (i, &mv) in list.iter().enumerate() {
|
||||
|
|
@ -901,7 +906,7 @@ impl<'a> FrameCoder<'a> {
|
|||
|
||||
/// Integer then fractional motion search. Returns (mv, mvp index, SATD cost).
|
||||
fn motion_search(&self, x: usize, y: usize, n: usize, amvp: &[[i16; 2]; 2], merge: &[[i16; 2]; MAX_MERGE_CAND]) -> ([i16; 2], u8, u64) {
|
||||
let me = self.me.expect("motion search reference");
|
||||
let Some(me) = self.me else { return (amvp[0], 0, u64::MAX / 4) };
|
||||
let (xi, yi) = (x as i32, y as i32);
|
||||
let src = &self.src[0][y * self.w + x..];
|
||||
let ls = self.lambda_sad;
|
||||
|
|
@ -1168,7 +1173,8 @@ fn residual_coding<S: Sink>(s: &mut S, lv: &[i32], log2: u32, c: usize, scan_idx
|
|||
}
|
||||
}
|
||||
}
|
||||
let (last_sb, last_pos) = last.expect("residual_coding of an all-zero block");
|
||||
// Callers only code blocks with a nonzero level (coded_block_flag = 1).
|
||||
let Some((last_sb, last_pos)) = last else { return };
|
||||
let lx = sb_scan[last_sb].0 as u32 * 4 + pos_scan[last_pos].0 as u32;
|
||||
let ly = sb_scan[last_sb].1 as u32 * 4 + pos_scan[last_pos].1 as u32;
|
||||
let (cx, cy) = if scan_idx == 2 { (ly, lx) } else { (lx, ly) };
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
//! let _hvcc = enc.hvcc();
|
||||
//! ```
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
// Index loops over fixed-size blocks read more clearly than iterator chains in codec code.
|
||||
#![allow(clippy::needless_range_loop, clippy::too_many_arguments)]
|
||||
|
||||
|
|
@ -287,7 +288,7 @@ impl Encoder {
|
|||
for y in 0..ch {
|
||||
let row = &src[y.min(h - 1) * stride..][..w];
|
||||
plane.extend(row.iter().map(|&v| (v as u32).min(max) as u16));
|
||||
let last = *plane.last().unwrap();
|
||||
let last = plane.last().copied().unwrap_or(0);
|
||||
plane.extend(std::iter::repeat_n(last, cw - w));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
//! Render Queue export through `effectcraft-export` (FilmCraft's encoders). Frontends (desktop,
|
||||
//! CLI, MCP, web) start here.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_engine::project::render_queue::OutputFormat;
|
||||
|
|
|
|||
|
|
@ -254,7 +254,7 @@ pub fn export(project: &Project, comp: ItemId, opts: &ExportOptions, prerendered
|
|||
bins: HashMap::new(),
|
||||
stack: vec![],
|
||||
};
|
||||
let seq = ex.sequence(comp);
|
||||
let seq = ex.sequence(comp).ok_or(Error::NoComp(comp.0))?;
|
||||
let fc_opts = filmcraft_interchange::ExportOptions { relative_to: opts.relative_to.clone(), name: Some(name), ..Default::default() };
|
||||
let (bytes, report) = filmcraft_interchange::export(&ex.fp, seq, opts.format.fc(), &fc_opts)?;
|
||||
for e in report.entries {
|
||||
|
|
@ -390,12 +390,12 @@ impl Exporter<'_> {
|
|||
id
|
||||
}
|
||||
|
||||
/// The sequence for a comp (created once).
|
||||
fn sequence(&mut self, cid: ItemId) -> fp::ItemId {
|
||||
/// The sequence for a comp (created once); None if `cid` is not a composition.
|
||||
fn sequence(&mut self, cid: ItemId) -> Option<fp::ItemId> {
|
||||
if let Some(s) = self.seqs.get(&cid) {
|
||||
return *s;
|
||||
return Some(*s);
|
||||
}
|
||||
let comp = self.p.comp_arc(cid).expect("comp");
|
||||
let comp = self.p.comp_arc(cid)?;
|
||||
let it = self.p.item(cid);
|
||||
let name = it.map(|i| i.name.clone()).unwrap_or_default();
|
||||
let settings = fp::SequenceSettings {
|
||||
|
|
@ -430,12 +430,13 @@ impl Exporter<'_> {
|
|||
Plan::Render(why) => match self.pre.get(&(cid, l.id)).cloned() {
|
||||
Some(r) => {
|
||||
let item = self.prerendered_item(&format!("{} (pre-rendered)", l.name), &r);
|
||||
let mut ti = self.track_item(item, &l.name, fp::TrackKind::Video, &tm, rate);
|
||||
ti.source_in = filmcraft_time::Tick::ZERO;
|
||||
ti.speed = 1.0;
|
||||
ti.reverse = false;
|
||||
self.out.prerendered += 1;
|
||||
Some(ti)
|
||||
self.track_item(item, &l.name, fp::TrackKind::Video, &tm, rate).map(|mut ti| {
|
||||
ti.source_in = filmcraft_time::Tick::ZERO;
|
||||
ti.speed = 1.0;
|
||||
ti.reverse = false;
|
||||
self.out.prerendered += 1;
|
||||
ti
|
||||
})
|
||||
}
|
||||
None => {
|
||||
self.warn(format!("layer \"{}\" was left out ({why}; pre-rendering is off or unavailable)", l.name));
|
||||
|
|
@ -457,16 +458,16 @@ impl Exporter<'_> {
|
|||
Some(ItemKind::Solid(s)) => Some(self.solid_item(*item, s.color, &comp)),
|
||||
_ => None,
|
||||
},
|
||||
LayerSource::Comp { item } if !self.stack.contains(item) => Some(self.sequence(*item)),
|
||||
LayerSource::Comp { item } if !self.stack.contains(item) => self.sequence(*item),
|
||||
_ => None,
|
||||
};
|
||||
item.map(|item| {
|
||||
let mut ti = self.track_item(item, &l.name, fp::TrackKind::Video, &tm, rate);
|
||||
item.and_then(|item| {
|
||||
let mut ti = self.track_item(item, &l.name, fp::TrackKind::Video, &tm, rate)?;
|
||||
self.motion(&mut ti, l, &comp, &tm);
|
||||
if let Some(h) = l.props.get("timeRemap").filter(|p| !p.is_animated()) {
|
||||
ti.frame_hold = Some(tick_out(Tick::from_seconds_f64(h.value.as_f64())));
|
||||
}
|
||||
ti
|
||||
Some(ti)
|
||||
})
|
||||
}
|
||||
};
|
||||
|
|
@ -486,14 +487,14 @@ impl Exporter<'_> {
|
|||
_ => None,
|
||||
},
|
||||
LayerSource::Comp { item } if self.p.comp(*item).is_some_and(|c| comp_has_audio(self.p, c)) && !self.stack.contains(item) => {
|
||||
Some(self.sequence(*item))
|
||||
self.sequence(*item)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(item) = sound
|
||||
&& !matches!(pl, Plan::Skip(_))
|
||||
&& let Some(mut ti) = self.track_item(item, &l.name, fp::TrackKind::Audio, &tm, rate)
|
||||
{
|
||||
let mut ti = self.track_item(item, &l.name, fp::TrackKind::Audio, &tm, rate);
|
||||
ti.enabled = l.switches.audio;
|
||||
ti.link = has_video_clip.then_some(link);
|
||||
self.levels(&mut ti, l, &tm);
|
||||
|
|
@ -529,18 +530,18 @@ impl Exporter<'_> {
|
|||
seq.work_area = None;
|
||||
}
|
||||
self.stack.pop();
|
||||
sid
|
||||
Some(sid)
|
||||
}
|
||||
|
||||
fn track_item(&mut self, item: fp::ItemId, name: &str, kind: fp::TrackKind, tm: &Timing, rate: FrameRate) -> fp::TrackItem {
|
||||
fn track_item(&mut self, item: fp::ItemId, name: &str, kind: fp::TrackKind, tm: &Timing, rate: FrameRate) -> Option<fp::TrackItem> {
|
||||
let range = filmcraft_time::TimeRange::new(tick_out(tm.source_in), tick_out(Tick((tm.duration.0 as f64 * tm.speed).round() as i64)));
|
||||
let mut ti = self.fp.make_track_item(item, kind, tick_out(tm.start), range, rate_out(rate)).expect("item exists");
|
||||
let mut ti = self.fp.make_track_item(item, kind, tick_out(tm.start), range, rate_out(rate))?;
|
||||
ti.name = name.to_string();
|
||||
ti.duration = tick_out(tm.duration);
|
||||
ti.source_in = tick_out(tm.source_in);
|
||||
ti.speed = tm.speed;
|
||||
ti.reverse = tm.reverse;
|
||||
ti
|
||||
Some(ti)
|
||||
}
|
||||
|
||||
/// Transform → Motion + Opacity (clip-time keyframes).
|
||||
|
|
@ -554,7 +555,7 @@ impl Exporter<'_> {
|
|||
_ => (comp.width, comp.height),
|
||||
};
|
||||
let center = |x: f64, y: f64| -> fp::ParamValue { fp::ParamValue::Vec2(Vec2 { x, y }) };
|
||||
let mut m = ti.effect("motion").cloned().unwrap_or_else(|| fp::find_effect("motion").expect("motion").instance());
|
||||
let Some(mut m) = effect_or_new(ti, "motion") else { return };
|
||||
if let Some(p) = tr.get("position") {
|
||||
let def = [comp.width as f64 / 2.0, comp.height as f64 / 2.0];
|
||||
if p.is_animated() || differs(&p.value, def) {
|
||||
|
|
@ -594,8 +595,9 @@ impl Exporter<'_> {
|
|||
m.params.insert("rotation".into(), param_out(p, &to_clip, |v| fp::ParamValue::Float(v.as_f64())));
|
||||
}
|
||||
set_effect(ti, m);
|
||||
if let Some(p) = tr.get("opacity") {
|
||||
let mut o = ti.effect("opacity").cloned().unwrap_or_else(|| fp::find_effect("opacity").expect("opacity").instance());
|
||||
if let Some(p) = tr.get("opacity")
|
||||
&& let Some(mut o) = effect_or_new(ti, "opacity")
|
||||
{
|
||||
o.params.insert("opacity".into(), param_out(p, &to_clip, |v| fp::ParamValue::Float(v.as_f64())));
|
||||
if l.blend_mode != effectcraft_color::BlendMode::Normal
|
||||
&& let Some(i) = fp::effect::BLEND_MODES.iter().position(|m| m.eq_ignore_ascii_case(l.blend_mode.label()))
|
||||
|
|
@ -615,7 +617,7 @@ impl Exporter<'_> {
|
|||
if (p.value.as_vec2()[0] - p.value.as_vec2()[1]).abs() > 1e-9 {
|
||||
self.warn("separate left/right audio levels were exported as the left level");
|
||||
}
|
||||
let mut v = ti.effect("volume").cloned().unwrap_or_else(|| fp::find_effect("volume").expect("volume").instance());
|
||||
let Some(mut v) = effect_or_new(ti, "volume") else { return };
|
||||
v.params.insert("level".into(), param_out(p, &to_clip, |v| fp::ParamValue::Float(v.as_vec2()[0])));
|
||||
set_effect(ti, v);
|
||||
}
|
||||
|
|
@ -634,6 +636,11 @@ fn differs(v: &Value, def: [f64; 2]) -> bool {
|
|||
(a[0] - def[0]).abs() > 1e-6 || (a[1] - def[1]).abs() > 1e-6
|
||||
}
|
||||
|
||||
/// The clip's `id` effect, or a new instance of it (None if the registry has no such effect).
|
||||
fn effect_or_new(ti: &fp::TrackItem, id: &str) -> Option<fp::EffectInstance> {
|
||||
ti.effect(id).cloned().or_else(|| fp::find_effect(id).map(|e| e.instance()))
|
||||
}
|
||||
|
||||
fn set_effect(ti: &mut fp::TrackItem, e: fp::EffectInstance) {
|
||||
match ti.effects.iter_mut().find(|x| x.effect == e.effect) {
|
||||
Some(x) => *x = e,
|
||||
|
|
|
|||
|
|
@ -45,6 +45,83 @@ pub struct ImportResult {
|
|||
|
||||
/// Import a timeline document into `project`. `probe` reads a media file's footage metadata
|
||||
/// (None = missing or unreadable: the item becomes a placeholder with the document's metadata).
|
||||
/// The deepest element / array nesting accepted in XML and OTIO documents. Real timelines nest
|
||||
/// a few levels per nested sequence; parsing several thousand levels overflowed the stack.
|
||||
pub const MAX_DEPTH: usize = 256;
|
||||
|
||||
/// The deepest element nesting of an XML document (a quick scan that skips comments, CDATA,
|
||||
/// processing instructions, declarations and quoted attribute values).
|
||||
pub(crate) fn xml_depth(b: &[u8]) -> usize {
|
||||
let (mut i, mut depth, mut max) = (0usize, 0usize, 0usize);
|
||||
let skip_to = |from: usize, end: &[u8]| b.get(from..).and_then(|r| r.windows(end.len()).position(|w| w == end)).map_or(b.len(), |p| from + p + end.len());
|
||||
while let Some(&c) = b.get(i) {
|
||||
if c != b'<' {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let rest = b.get(i..).unwrap_or_default();
|
||||
if rest.starts_with(b"<!--") {
|
||||
i = skip_to(i + 4, b"-->");
|
||||
} else if rest.starts_with(b"<![CDATA[") {
|
||||
i = skip_to(i + 9, b"]]>");
|
||||
} else if rest.starts_with(b"<?") {
|
||||
i = skip_to(i + 2, b"?>");
|
||||
} else if rest.starts_with(b"<!") {
|
||||
i = skip_to(i + 2, b">");
|
||||
} else if rest.starts_with(b"</") {
|
||||
depth = depth.saturating_sub(1);
|
||||
i = skip_to(i + 2, b">");
|
||||
} else {
|
||||
// A start tag: find its end outside quotes; `/>` closes it at once.
|
||||
let mut j = i + 1;
|
||||
let mut quote = None;
|
||||
while let Some(&c) = b.get(j) {
|
||||
match quote {
|
||||
Some(q) if c == q => quote = None,
|
||||
Some(_) => {}
|
||||
None if c == b'"' || c == b'\'' => quote = Some(c),
|
||||
None if c == b'>' => break,
|
||||
None => {}
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
if b.get(j.wrapping_sub(1)) != Some(&b'/') {
|
||||
depth += 1;
|
||||
max = max.max(depth);
|
||||
}
|
||||
i = j + 1;
|
||||
}
|
||||
}
|
||||
max
|
||||
}
|
||||
|
||||
/// The deepest array / object nesting of a JSON document (strings skipped).
|
||||
pub(crate) fn json_depth(b: &[u8]) -> usize {
|
||||
let (mut depth, mut max, mut in_str, mut esc) = (0usize, 0usize, false, false);
|
||||
for &c in b {
|
||||
if in_str {
|
||||
if esc {
|
||||
esc = false;
|
||||
} else if c == b'\\' {
|
||||
esc = true;
|
||||
} else if c == b'"' {
|
||||
in_str = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match c {
|
||||
b'"' => in_str = true,
|
||||
b'[' | b'{' => {
|
||||
depth += 1;
|
||||
max = max.max(depth);
|
||||
}
|
||||
b']' | b'}' => depth = depth.saturating_sub(1),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
max
|
||||
}
|
||||
|
||||
pub fn import(
|
||||
project: &mut Project,
|
||||
bytes: &[u8],
|
||||
|
|
@ -56,6 +133,16 @@ pub fn import(
|
|||
Some(f) => f,
|
||||
None => TimelineFormat::detect(bytes, Some(&opts.name)).ok_or(Error::UnknownFormat)?,
|
||||
};
|
||||
// The XML and JSON parsers recurse per nesting level: a small file nested thousands of levels
|
||||
// deep overflowed the stack and aborted the app.
|
||||
let depth = match format {
|
||||
TimelineFormat::Fcp7Xml | TimelineFormat::Fcpxml => xml_depth(bytes),
|
||||
TimelineFormat::Otio => json_depth(bytes),
|
||||
_ => 0,
|
||||
};
|
||||
if depth > MAX_DEPTH {
|
||||
return Err(Error::TooDeep(MAX_DEPTH));
|
||||
}
|
||||
let fc_opts = filmcraft_interchange::ImportOptions {
|
||||
base_dir: opts.base_dir.clone(),
|
||||
edl_frame_rate: opts.edl_frame_rate.map(|f| {
|
||||
|
|
@ -434,7 +521,7 @@ impl Importer<'_> {
|
|||
}
|
||||
let fit = if ti.scale_to_frame && size.0 > 0 && size.1 > 0 { (comp.width as f64 / size.0 as f64).min(comp.height as f64 / size.1 as f64) } else { 1.0 };
|
||||
let lc = l.clone();
|
||||
let tr = l.transform_mut().expect("transform");
|
||||
let Some(tr) = l.transform_mut() else { return };
|
||||
// Position / anchor (NaN = centred).
|
||||
for (src, dst) in [("position", "position"), ("anchor", "anchor")] {
|
||||
let Some(p) = m.param(src) else { continue };
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@
|
|||
//! This crate is L3 and does no file I/O: media files are probed through a callback, pre-rendered
|
||||
//! media come in as paths, and documents are bytes.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod export;
|
||||
mod import;
|
||||
#[cfg(test)]
|
||||
|
|
@ -156,6 +158,8 @@ pub enum Error {
|
|||
UnknownFormat,
|
||||
#[error("no composition {0}")]
|
||||
NoComp(u64),
|
||||
#[error("the timeline document nests more than {0} levels deep")]
|
||||
TooDeep(usize),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
|
|
|||
|
|
@ -498,3 +498,42 @@ fn formats_parse() {
|
|||
assert_eq!(par_fraction(0.9), (9, 10));
|
||||
assert_eq!(par_fraction(1.0), (1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_survives_a_precomp_layer_pointing_at_a_non_comp() {
|
||||
// A damaged project: the precomp layer's source is a footage item (or a deleted item).
|
||||
let (mut p, cid) = ae_project();
|
||||
let footage = p.items.iter().find(|(_, i)| matches!(i.kind, ItemKind::Footage(_))).map(|(id, _)| *id).unwrap();
|
||||
for bad in [footage, ItemId(9_999)] {
|
||||
let mut c = (*p.comp_arc(cid).unwrap()).clone();
|
||||
for l in &mut c.layers {
|
||||
if matches!(l.source, LayerSource::Comp { .. }) {
|
||||
l.source = LayerSource::Comp { item: bad };
|
||||
}
|
||||
}
|
||||
p.item_mut(cid).unwrap().kind = ItemKind::Comp(Arc::new(c));
|
||||
for format in [TimelineFormat::Fcp7Xml, TimelineFormat::Fcpxml, TimelineFormat::Otio] {
|
||||
let out = export(&p, cid, &ExportOptions { format, prerender: PrerenderMode::None, ..Default::default() }, &HashMap::new()).unwrap();
|
||||
assert_eq!(out.sequences, 1);
|
||||
}
|
||||
}
|
||||
assert!(matches!(export(&p, footage, &ExportOptions::default(), &HashMap::new()), Err(Error::NoComp(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deeply_nested_documents_error_instead_of_overflowing_the_stack() {
|
||||
// A few hundred kilobytes nested 100 000 levels deep overflowed the parsers' stack.
|
||||
let n = 100_000;
|
||||
let docs = [
|
||||
(TimelineFormat::Fcp7Xml, format!("<?xml version=\"1.0\"?><xmeml version=\"5\">{}{}</xmeml>", "<a>".repeat(n), "</a>".repeat(n))),
|
||||
(TimelineFormat::Fcpxml, format!("<?xml version=\"1.0\"?><fcpxml version=\"1.9\">{}{}</fcpxml>", "<a>".repeat(n), "</a>".repeat(n))),
|
||||
(TimelineFormat::Otio, format!("{{\"OTIO_SCHEMA\":\"Timeline.1\",\"tracks\":{}{}}}", "[".repeat(n), "]".repeat(n))),
|
||||
];
|
||||
for (format, doc) in docs {
|
||||
let mut p = Project::default();
|
||||
let r = import(&mut p, doc.as_bytes(), Some(format), &ImportOptions::default(), &mut |_| None);
|
||||
assert!(matches!(r, Err(Error::TooDeep(_))), "{format:?}");
|
||||
}
|
||||
assert_eq!(super::import::xml_depth(b"<a><!-- <b><b> --><c x='>'/><![CDATA[<d>]]><e></e></a>"), 2);
|
||||
assert_eq!(super::import::json_depth(br#"{"a": "[[[{{", "b": [[1], {"c": "\\\"["}]}"#), 3);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -441,7 +441,9 @@ pub fn segment_value(keys: &[Keyframe], i: usize, t: Tick, spatial: bool) -> Val
|
|||
let dur = (t1 - t0).max(1e-12);
|
||||
let x = ((secs(t) - t0) / dur).clamp(0.0, 1.0);
|
||||
let lin_out = a.out_interp == Interp::Linear;
|
||||
let lin_in = b.in_interp == Interp::Linear;
|
||||
// A Hold in-side only matters for the segment it ends, which the out side above decides:
|
||||
// it shapes the incoming segment like a linear side, as in the Graph Editor and Lottie export.
|
||||
let lin_in = b.in_interp != Interp::Bezier;
|
||||
let is_spatial = spatial && matches!(a.value, Value::Vec2(_) | Value::Vec3(_));
|
||||
if is_spatial {
|
||||
let p0 = v3(&a.value);
|
||||
|
|
@ -584,6 +586,17 @@ mod tests {
|
|||
assert_eq!(evaluate(&held, s(1.999), false), Some(Value::Scalar(0.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hold_key_keeps_its_incoming_segment_linear() {
|
||||
// Hold on the second key (in and out, as KeyframeInterpolationType.HOLD sets it) holds
|
||||
// what comes after it, not the linear motion into it.
|
||||
let keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)), Keyframe::new(s(2.0), Value::Scalar(100.0)).hold()];
|
||||
assert_eq!(evaluate(&keys, s(0.5), false), Some(Value::Scalar(25.0)));
|
||||
let keys = vec![Keyframe::new(s(0.0), Value::Vec2([0.0, 0.0])), Keyframe::new(s(2.0), Value::Vec2([100.0, 0.0])).hold()];
|
||||
let p = evaluate(&keys, s(0.5), true).unwrap().as_vec2();
|
||||
assert!((p[0] - 25.0).abs() < 0.01, "{p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn easy_ease_is_symmetric_and_slow_at_ends() {
|
||||
let keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)).eased(), Keyframe::new(s(1.0), Value::Scalar(100.0)).eased()];
|
||||
|
|
|
|||
|
|
@ -372,5 +372,5 @@ pub(crate) fn to_bool(j: &Json) -> Option<Value> {
|
|||
|
||||
/// Lottie 1-based enum → 0-based popup index.
|
||||
pub(crate) fn to_enum1(j: &Json) -> Option<Value> {
|
||||
to_scalar(j).map(|v| Value::Enum((v.as_f64().round() as i64 - 1).max(0) as u32))
|
||||
to_scalar(j).map(|v| Value::Enum((v.as_f64().round() as i64).saturating_sub(1).max(0) as u32))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@
|
|||
//! Keyframe times in Lottie are frames of the layer's local time, which is EffectCraft's layer
|
||||
//! time; layer `ip`/`op`/`st` are composition frames and `sr` is the stretch factor.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod anim;
|
||||
mod base64;
|
||||
mod export;
|
||||
|
|
|
|||
|
|
@ -359,10 +359,10 @@ fn import_blend(j: &Json, g: &mut PropGroup) {
|
|||
fn import_stroke(im: &Im, j: &Json, g: &mut PropGroup) {
|
||||
set(im, g, "width", j.get("w"), &anim::to_scalar);
|
||||
if let Some(lc) = int(j, "lc") {
|
||||
set_enum(g, "cap", (lc - 1).clamp(0, 2) as u32);
|
||||
set_enum(g, "cap", lc.saturating_sub(1).clamp(0, 2) as u32);
|
||||
}
|
||||
if let Some(lj) = int(j, "lj") {
|
||||
set_enum(g, "join", (lj - 1).clamp(0, 2) as u32);
|
||||
set_enum(g, "join", lj.saturating_sub(1).clamp(0, 2) as u32);
|
||||
}
|
||||
if let Some(p) = g.get_mut("miter") {
|
||||
if let Some(ml) = j.get("ml").and_then(Json::as_f64) {
|
||||
|
|
@ -541,7 +541,7 @@ pub(crate) fn import_items(im: &mut Im, ids: &mut Ids, items: &[Json]) -> Vec<Pr
|
|||
"op" => {
|
||||
set(im, &mut g, "amount", j.get("a"), &anim::to_scalar);
|
||||
if let Some(lj) = int(j, "lj") {
|
||||
set_enum(&mut g, "join", (lj - 1).clamp(0, 2) as u32);
|
||||
set_enum(&mut g, "join", lj.saturating_sub(1).clamp(0, 2) as u32);
|
||||
}
|
||||
if let (Some(ml), Some(p)) = (j.get("ml").and_then(Json::as_f64), g.get_mut("miter")) {
|
||||
p.value = Value::Scalar(ml);
|
||||
|
|
@ -561,7 +561,7 @@ pub(crate) fn import_items(im: &mut Im, ids: &mut Ids, items: &[Json]) -> Vec<Pr
|
|||
}
|
||||
_ => {
|
||||
if let Some(m) = int(j, "mm") {
|
||||
set_enum(&mut g, "mode", (m - 1).clamp(0, 4) as u32);
|
||||
set_enum(&mut g, "mode", m.saturating_sub(1).clamp(0, 4) as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -725,3 +725,31 @@ fn text_style_runs_export_as_range_animators() {
|
|||
// Played back as an animator, it looks the same.
|
||||
compare_renders(&p, cid, &q, nc, 2.0 / 255.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extreme_enum_numbers_do_not_overflow() {
|
||||
// Line cap / join / merge mode / text-selector enums are 1-based; a huge negative number
|
||||
// (-1e308 rounds to i64::MIN) overflowed the `- 1`.
|
||||
let path = format!("{}/tests/fixtures/shapes-gradient.json", env!("CARGO_MANIFEST_DIR"));
|
||||
let mut doc: Json = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
|
||||
let mut hits = 0;
|
||||
fn patch(j: &mut Json, hits: &mut usize) {
|
||||
match j {
|
||||
Json::Object(o) => {
|
||||
for k in ["lc", "lj", "mm"] {
|
||||
if o.contains_key(k) {
|
||||
o.insert(k.into(), json!(-1e308));
|
||||
*hits += 1;
|
||||
}
|
||||
}
|
||||
o.values_mut().for_each(|v| patch(v, hits));
|
||||
}
|
||||
Json::Array(a) => a.iter_mut().for_each(|v| patch(v, hits)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
patch(&mut doc, &mut hits);
|
||||
assert!(hits > 0);
|
||||
let mut q = Project::default();
|
||||
import(&mut q, doc.to_string().as_bytes(), "x", "", &mut |_| None).unwrap();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -363,7 +363,7 @@ pub(crate) fn import_text(im: &mut Im, ids: &mut Ids, t: &Json, tg: &mut PropGro
|
|||
let mut en = |m: &str, key: &str, max: u32| {
|
||||
let v = s.get(key).and_then(|v| if v.is_object() { v.get("k") } else { Some(v) }).and_then(anim::nums).and_then(|n| n.first().copied());
|
||||
if let (Some(v), Some(p)) = (v, adv.get_mut(m)) {
|
||||
p.value = Value::Enum(((v.round() as i64 - 1).max(0) as u32).min(max));
|
||||
p.value = Value::Enum((((v.round() as i64).saturating_sub(1)).max(0) as u32).min(max));
|
||||
}
|
||||
};
|
||||
en("units", "r", 1);
|
||||
|
|
|
|||
|
|
@ -73,7 +73,9 @@ pub(crate) fn decode(path: &str, bytes: &[u8], footage: &Footage, op: AlphaOp) -
|
|||
if effectcraft_psd::is_psd(bytes) {
|
||||
let psd = effectcraft_psd::Psd::parse(bytes.to_vec()).map_err(|e| MediaError::Decode(format!("{path}: {e}")))?;
|
||||
// A smart object's embedded file: an embedded document's merged image, or an image.
|
||||
if let Some(uuid) = footage.layer.as_ref().and_then(|l| l.embedded.as_deref()) {
|
||||
if let Some(l) = footage.layer.as_ref()
|
||||
&& let Some(uuid) = l.embedded.as_deref()
|
||||
{
|
||||
let data = psd.linked_data(uuid).ok_or_else(|| MediaError::Decode(format!("{path}: no embedded file {uuid}")))?;
|
||||
let px = if effectcraft_psd::is_psd(data) {
|
||||
let inner = effectcraft_psd::Psd::parse(data.to_vec()).map_err(|e| MediaError::Decode(format!("{path} (smart object): {e}")))?;
|
||||
|
|
@ -84,7 +86,6 @@ pub(crate) fn decode(path: &str, bytes: &[u8], footage: &Footage, op: AlphaOp) -
|
|||
effectcraft_psd::Pixels { width: rgba.width(), height: rgba.height(), data: rgba.pixels().map(|p| p.0).collect() }
|
||||
};
|
||||
// A perspective quad or a warp: baked as placed.
|
||||
let l = footage.layer.as_ref().expect("embedded implies a layer");
|
||||
if l.placed
|
||||
&& let Some(so) = psd.layers.get(l.index as usize).and_then(|pl| pl.smart_object.as_ref())
|
||||
{
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@
|
|||
//! (PNG, JPEG, GIF, WebP, TIFF, BMP, OpenEXR); Photoshop documents (merged image or one layer) come
|
||||
//! from `effectcraft-psd` and SVG from `effectcraft-svg` (rasterised at any scale).
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod convert;
|
||||
pub mod exr_channels;
|
||||
mod layered;
|
||||
|
|
|
|||
|
|
@ -233,12 +233,16 @@ impl MediaPool {
|
|||
}
|
||||
}
|
||||
|
||||
/// Drop every cached frame and decoder.
|
||||
pub fn clear(&self) {
|
||||
/// Drop every cached frame (decoders stay open).
|
||||
pub fn clear_frames(&self) {
|
||||
let mut c = lock(&self.inner.cache);
|
||||
c.lru = Lru::default();
|
||||
c.last.clear();
|
||||
drop(c);
|
||||
}
|
||||
|
||||
/// Drop every cached frame and decoder.
|
||||
pub fn clear(&self) {
|
||||
self.clear_frames();
|
||||
lock(&self.inner.sources).clear();
|
||||
}
|
||||
|
||||
|
|
@ -602,6 +606,9 @@ impl FootageSource for MediaPool {
|
|||
fn set_cache_budget(&self, bytes: usize) {
|
||||
self.set_budget(bytes);
|
||||
}
|
||||
fn purge(&self) {
|
||||
self.clear_frames();
|
||||
}
|
||||
fn cache_budget(&self) -> Option<usize> {
|
||||
Some(self.budget())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@
|
|||
//! Models are in their own units with +Y up (glTF's convention); the renderer maps them into
|
||||
//! After Effects' Y-down pixel space.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod extrude;
|
||||
pub mod gltf;
|
||||
pub mod obj;
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
//! assert_eq!(voip.mode(), Mode::Silk);
|
||||
//! ```
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
// The bit-allocation code mirrors the normative integer arithmetic; keep C-like shift expressions.
|
||||
#![allow(clippy::precedence, clippy::int_plus_one)]
|
||||
|
||||
|
|
@ -317,23 +318,26 @@ impl OpusEncoder {
|
|||
}
|
||||
Mode::Silk => {
|
||||
let x = self.silk_input(pcm);
|
||||
let sp = self.silk.as_mut().expect("SILK state");
|
||||
let target = sp.target_bits + 0.25 * sp.reservoir;
|
||||
let mut enc = RangeEncoder::new();
|
||||
sp.enc.encode(&x, target.max(24.0), (MAX_FRAME_BYTES * 8) as i32, &mut enc);
|
||||
let bytes = (enc.tell() as usize).div_ceil(8).max(2);
|
||||
let lim = 4.0 * sp.target_bits;
|
||||
sp.reservoir = (sp.reservoir + sp.target_bits - (bytes * 8) as f64).clamp(-lim, lim);
|
||||
packet.extend_from_slice(&enc.done(bytes));
|
||||
// The SILK state exists whenever the mode uses SILK (set with the mode).
|
||||
if let Some(sp) = self.silk.as_mut() {
|
||||
let target = sp.target_bits + 0.25 * sp.reservoir;
|
||||
let mut enc = RangeEncoder::new();
|
||||
sp.enc.encode(&x, target.max(24.0), (MAX_FRAME_BYTES * 8) as i32, &mut enc);
|
||||
let bytes = (enc.tell() as usize).div_ceil(8).max(2);
|
||||
let lim = 4.0 * sp.target_bits;
|
||||
sp.reservoir = (sp.reservoir + sp.target_bits - (bytes * 8) as f64).clamp(-lim, lim);
|
||||
packet.extend_from_slice(&enc.done(bytes));
|
||||
}
|
||||
}
|
||||
Mode::Hybrid => {
|
||||
let x = self.silk_input(pcm);
|
||||
let len = self.frame_bytes;
|
||||
let total = (len * 8) as i32;
|
||||
let sp = self.silk.as_mut().expect("SILK state");
|
||||
let celt_min = (total / 5).max(80);
|
||||
let mut enc = RangeEncoder::new();
|
||||
sp.enc.encode(&x, sp.target_bits, total - celt_min, &mut enc);
|
||||
if let Some(sp) = self.silk.as_mut() {
|
||||
sp.enc.encode(&x, sp.target_bits, total - celt_min, &mut enc);
|
||||
}
|
||||
// No redundancy (RFC 6716 §4.5.1); the flag is present when there is room.
|
||||
if enc.tell() + 17 + 20 <= total {
|
||||
enc.bit_logp(false, 12);
|
||||
|
|
@ -349,7 +353,7 @@ impl OpusEncoder {
|
|||
/// Decimates the input to the SILK internal rate (16-bit scale), one vector per channel.
|
||||
fn silk_input(&mut self, pcm: &[f32]) -> Vec<Vec<f32>> {
|
||||
let c = self.channels;
|
||||
let sp = self.silk.as_mut().expect("SILK state");
|
||||
let Some(sp) = self.silk.as_mut() else { return vec![vec![]; c] };
|
||||
let mut out = Vec::with_capacity(c);
|
||||
let mut tmp = vec![0f32; N];
|
||||
for ch in 0..c {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
//! Curve-preserving boolean operations on filled [`BezPath`]s, built on `linesweeper`'s robust
|
||||
//! sweep-line topology (MIT OR Apache-2.0).
|
||||
//!
|
||||
//! Adapted from DrawCraft's `vectorcraft-pathops` (`crates/pathops/src/boolean.rs`; our own code,
|
||||
//! Adapted from VectorCraft's `vectorcraft-pathops` (`crates/pathops/src/boolean.rs`; our own code,
|
||||
//! MIT OR Apache-2.0), reworked to take and return kurbo paths directly.
|
||||
//!
|
||||
//! Inputs are closed implicitly (a filled open path is painted as if closed). The sweep splits
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
//! Adapted from DrawCraft's `vectorcraft-pathops` (`crates/pathops/src/fit.rs`; our own code,
|
||||
//! Adapted from VectorCraft's `vectorcraft-pathops` (`crates/pathops/src/fit.rs`; our own code,
|
||||
//! MIT OR Apache-2.0).
|
||||
//!
|
||||
//! Least-squares cubic Bézier fitting of point sequences (the classic "fit a cubic with fixed end
|
||||
|
|
@ -72,8 +72,7 @@ fn bern(t: f64) -> [f64; 4] {
|
|||
|
||||
/// Solve the 2x2 normal equations for the handle lengths along the fixed tangents.
|
||||
fn generate(pts: &[Point], u: &[f64], t0: Vec2, t1: Vec2) -> CubicBez {
|
||||
let p0 = pts[0];
|
||||
let p3 = *pts.last().unwrap();
|
||||
let (Some(&p0), Some(&p3)) = (pts.first(), pts.last()) else { return CubicBez::new(Point::ZERO, Point::ZERO, Point::ZERO, Point::ZERO) };
|
||||
let dir_in = -t1; // handle at the end points backwards
|
||||
let (mut c00, mut c01, mut c11, mut x0, mut x1) = (0.0, 0.0, 0.0, 0.0, 0.0);
|
||||
for (p, &t) in pts.iter().zip(u) {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
//! pucker & bloat, zig zag, twist, wiggle, offset, merge) live in [`ops`]; curve-preserving booleans
|
||||
//! (for Merge Paths) in [`boolean`] and curve offsetting in [`offset`].
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod boolean;
|
||||
pub mod feather;
|
||||
mod fit;
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue