Compare commits
No commits in common. "ci-packaging-lint" and "m4-preview" have entirely different histories.
ci-packagi
...
m4-preview
|
|
@ -221,9 +221,8 @@ jobs:
|
|||
- name: Install wasm-bindgen CLI
|
||||
run: |
|
||||
# Must match the wasm-bindgen version in Cargo.lock exactly (cargo xtask web checks it).
|
||||
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
|
||||
command -v wasm-bindgen >/dev/null && wasm-bindgen --version | grep -q "$WASM_BINDGEN_VERSION" \
|
||||
|| cargo install wasm-bindgen-cli --version "$WASM_BINDGEN_VERSION" --locked
|
||||
wasm-bindgen --version
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
|
|
|
|||
33
AGENTS.md
33
AGENTS.md
|
|
@ -3,39 +3,6 @@
|
|||
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,7 +10,6 @@ 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,10 +24,6 @@ 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.2.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"cpal",
|
||||
"effectcraft-engine",
|
||||
|
|
@ -1155,7 +1155,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-automation"
|
||||
version = "0.2.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"effectcraft-engine",
|
||||
"effectcraft-host",
|
||||
|
|
@ -1167,14 +1167,14 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-av1enc"
|
||||
version = "0.2.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"filmcraft-av1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-cli"
|
||||
version = "0.2.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"cpu-time",
|
||||
"effectcraft-automation",
|
||||
|
|
@ -1190,7 +1190,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-color"
|
||||
version = "0.2.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
|
|
@ -1198,7 +1198,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-effects"
|
||||
version = "0.2.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-geom",
|
||||
|
|
@ -1214,7 +1214,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-engine"
|
||||
version = "0.2.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-effects",
|
||||
|
|
@ -1246,7 +1246,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-export"
|
||||
version = "0.2.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"effectcraft-av1enc",
|
||||
"effectcraft-color",
|
||||
|
|
@ -1275,7 +1275,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-expr"
|
||||
version = "0.2.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"boa_engine",
|
||||
"effectcraft-color",
|
||||
|
|
@ -1292,7 +1292,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-geom"
|
||||
version = "0.2.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
|
|
@ -1300,7 +1300,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-gpu"
|
||||
version = "0.2.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"effectcraft-color",
|
||||
|
|
@ -1319,14 +1319,14 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-hevcenc"
|
||||
version = "0.2.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"filmcraft-hevc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-host"
|
||||
version = "0.2.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"effectcraft-engine",
|
||||
"effectcraft-export",
|
||||
|
|
@ -1348,7 +1348,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-interchange"
|
||||
version = "0.2.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-keyframe",
|
||||
|
|
@ -1367,7 +1367,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-keyframe"
|
||||
version = "0.2.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-geom",
|
||||
|
|
@ -1379,7 +1379,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-lottie"
|
||||
version = "0.2.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-effects",
|
||||
|
|
@ -1396,7 +1396,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-media"
|
||||
version = "0.2.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"effectcraft-model",
|
||||
"effectcraft-pdf",
|
||||
|
|
@ -1421,7 +1421,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-model"
|
||||
version = "0.2.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"effectcraft-geom",
|
||||
"image",
|
||||
|
|
@ -1432,14 +1432,14 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-opusenc"
|
||||
version = "0.2.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"filmcraft-opus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-path"
|
||||
version = "0.2.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"effectcraft-geom",
|
||||
"effectcraft-keyframe",
|
||||
|
|
@ -1451,7 +1451,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-pdf"
|
||||
version = "0.2.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"effectcraft-raster",
|
||||
"effectcraft-svg",
|
||||
|
|
@ -1465,7 +1465,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-plugin"
|
||||
version = "0.2.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"effectcraft-effects",
|
||||
"effectcraft-keyframe",
|
||||
|
|
@ -1477,7 +1477,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-project"
|
||||
version = "0.2.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-geom",
|
||||
|
|
@ -1490,14 +1490,14 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-psd"
|
||||
version = "0.2.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"miniz_oxide 0.8.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-raster"
|
||||
version = "0.2.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-geom",
|
||||
|
|
@ -1506,7 +1506,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-render"
|
||||
version = "0.2.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-effects",
|
||||
|
|
@ -1528,7 +1528,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-script"
|
||||
version = "0.2.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"boa_engine",
|
||||
"effectcraft-engine",
|
||||
|
|
@ -1543,7 +1543,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-svg"
|
||||
version = "0.2.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"effectcraft-geom",
|
||||
"effectcraft-path",
|
||||
|
|
@ -1555,7 +1555,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-text"
|
||||
version = "0.2.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"effectcraft-keyframe",
|
||||
"harfrust",
|
||||
|
|
@ -1567,7 +1567,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-time"
|
||||
version = "0.2.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
|
|
@ -1575,7 +1575,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-track"
|
||||
version = "0.2.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"effectcraft-raster",
|
||||
"rayon",
|
||||
|
|
@ -1586,7 +1586,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-ui-egui"
|
||||
version = "0.2.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-engine",
|
||||
|
|
@ -1614,11 +1614,11 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-vp9enc"
|
||||
version = "0.2.0"
|
||||
version = "0.1.1"
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-web"
|
||||
version = "0.2.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"effectcraft-engine",
|
||||
"effectcraft-export",
|
||||
|
|
@ -6318,7 +6318,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
|||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.2.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
|||
default-members = ["crates/*", "apps/*"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.0"
|
||||
version = "0.1.1"
|
||||
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 = "forbid"
|
||||
unsafe_code = "deny"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
dbg_macro = "warn"
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ once.
|
|||
| 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 | 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) |
|
||||
| M4 | Preview: precomps, motion blur, cached playback | Mostly done |
|
||||
| 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; in previews, content-keyed RAM preview frames ([parity.md](docs/parity.md#highest-value-gaps-in-order)).
|
||||
Brush and face tracking.
|
||||
|
||||
Come tell us what matters most to you on [Discord](https://discord.gg/artcraft).
|
||||
|
|
|
|||
|
|
@ -34,8 +34,6 @@
|
|||
//! `<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,8 +20,6 @@
|
|||
//!
|
||||
//! [`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.mark_unsaved();
|
||||
s.bump();
|
||||
}
|
||||
SNAP.with(|c| c.set((s.revision, 0.0)));
|
||||
log::info!("restored the last session ({})", meta.path.as_deref().unwrap_or("untitled"));
|
||||
|
|
|
|||
|
|
@ -5,8 +5,6 @@
|
|||
//! `--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")]
|
||||
|
|
@ -28,7 +26,6 @@ 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;
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
allow-unwrap-in-tests = true
|
||||
allow-expect-in-tests = true
|
||||
allow-panic-in-tests = true
|
||||
allow-indexing-slicing-in-tests = true
|
||||
|
|
@ -13,8 +13,6 @@
|
|||
//! 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,13 +378,7 @@ impl<'a> Decider<'a> {
|
|||
}
|
||||
}
|
||||
}
|
||||
// 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);
|
||||
};
|
||||
let (_, y_mode, tx_type_y, ty, recon) = best.expect("an intra mode");
|
||||
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);
|
||||
|
|
@ -420,17 +414,16 @@ 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 code_uv = |i: usize| {
|
||||
let mut tuv = Vec::with_capacity(2);
|
||||
for i in 0..2 {
|
||||
predict_intra(&ced[i], &ce[i], uv_mode, &mut pred);
|
||||
let t = self.code_tx(1 + i, c * 2, r * 2, clog2, uv_type, &pred[..cn * cn], true);
|
||||
tuv.push(self.code_tx(1 + i, c * 2, r * 2, clog2, uv_type, &pred[..cn * cn], true));
|
||||
self.mark_decoded(1 + i, r, c, n4);
|
||||
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 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");
|
||||
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)
|
||||
}
|
||||
|
|
@ -439,9 +432,8 @@ 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;
|
||||
|
|
@ -469,16 +461,7 @@ 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] = 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 [y, u, v]: [TxResult; 3] = tx.try_into().ok().expect("three planes");
|
||||
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)
|
||||
}
|
||||
|
|
@ -487,7 +470,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 Some(refr) = self.refr else { return ([0, 0], 0.0) };
|
||||
let refr = self.refr.expect("reference frame");
|
||||
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]];
|
||||
|
|
@ -596,7 +579,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 Some(refr) = self.refr else { return };
|
||||
let refr = self.refr.expect("reference frame");
|
||||
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,8 +8,6 @@
|
|||
//!
|
||||
//! 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;
|
||||
|
|
@ -446,16 +444,7 @@ 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 { .. } => 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),
|
||||
},
|
||||
RateControl::Bitrate { .. } => self.encode_rate_controlled(&src, key),
|
||||
};
|
||||
self.refr = Some(rf);
|
||||
self.frame_num += 1;
|
||||
|
|
@ -467,8 +456,9 @@ impl Encoder {
|
|||
Packet { data, keyframe: key }
|
||||
}
|
||||
|
||||
fn encode_rate_controlled(&mut self, src: &[Plane; 3], key: bool, rc: &mut RateState) -> (Vec<u8>, RefFrame) {
|
||||
fn encode_rate_controlled(&mut self, src: &[Plane; 3], key: bool) -> (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;
|
||||
|
|
@ -491,8 +481,8 @@ impl Encoder {
|
|||
q = (q + 30.0 * err).clamp(1.0, 255.0);
|
||||
}
|
||||
}
|
||||
// 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) };
|
||||
let (obu, rf, bits) = best.expect("encoded");
|
||||
let rc = self.rc.as_mut().expect("rate control state");
|
||||
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,11 +290,7 @@ 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),
|
||||
// The encoder only chooses the four types above.
|
||||
_ => {
|
||||
debug_assert!(false, "unsupported transform type {tx_type}");
|
||||
(Kind::Dct, Kind::Dct)
|
||||
}
|
||||
_ => unreachable!("unsupported transform type {tx_type}"),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -211,8 +211,7 @@ impl<'a> TileWriter<'a> {
|
|||
debug_assert_eq!(leaf.bsize, bsize);
|
||||
self.block(r, c, leaf);
|
||||
}
|
||||
// Blocks inside the frame are always decided; nothing is coded outside it.
|
||||
Node::Out => debug_assert!(false, "undecided block inside the frame"),
|
||||
Node::Out => unreachable!("decided block inside the frame"),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -461,11 +460,10 @@ impl<'a> TileWriter<'a> {
|
|||
if set > 0 && self.qidx > 0 {
|
||||
let sqr = TX_SIZE_SQR[tx_sz] as usize;
|
||||
if b.is_inter {
|
||||
// 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();
|
||||
let s = TX_TYPE_INTER_INV_SET3.iter().position(|&v| v as usize == t).expect("inter tx type in set");
|
||||
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).unwrap_or_default();
|
||||
let s = TX_TYPE_INTRA_INV_SET2.iter().position(|&v| v as usize == t).expect("intra tx type in set");
|
||||
self.sw.symbol(s, &mut self.cdf.intra_tx_type_set2[sqr][b.y_mode]);
|
||||
}
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -5,8 +5,6 @@
|
|||
//! 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,9 +610,6 @@ 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,8 +6,6 @@
|
|||
//! [`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;
|
||||
|
|
@ -504,25 +502,14 @@ 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(|| ALIASES.iter().find(|(_, names)| names.iter().any(|a| a.eq_ignore_ascii_case(name_or_id))).and_then(|(id, _)| find(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(|| {
|
||||
// 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))
|
||||
|
|
@ -564,8 +551,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.and_then(|path| nested_group(&mut g, ids, path)) {
|
||||
Some(sub) => sub.children.push(pr.into()),
|
||||
match path {
|
||||
Some(path) => nested_group(&mut g, ids, path).children.push(pr.into()),
|
||||
None => g.children.push(pr.into()),
|
||||
}
|
||||
// Layer parameters carry a source choice (Source / Masks / Effects & Masks) unless the
|
||||
|
|
@ -576,8 +563,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.and_then(|path| nested_group(&mut g, ids, path)) {
|
||||
Some(sub) => sub.children.push(src.into()),
|
||||
match path {
|
||||
Some(path) => nested_group(&mut g, ids, path).children.push(src.into()),
|
||||
None => g.children.push(src.into()),
|
||||
}
|
||||
}
|
||||
|
|
@ -778,7 +765,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) -> Option<&'a mut PropGroup> {
|
||||
fn nested_group<'a>(g: &'a mut PropGroup, ids: &mut Ids, path: &str) -> &'a mut PropGroup {
|
||||
let (first, rest) = match path.split_once('/') {
|
||||
Some((a, b)) => (a, Some(b)),
|
||||
None => (path, None),
|
||||
|
|
@ -786,10 +773,10 @@ fn nested_group<'a>(g: &'a mut PropGroup, ids: &mut Ids, path: &str) -> Option<&
|
|||
if g.sub(first).is_none() {
|
||||
g.children.push(ids.group(first, group_name(first)).into());
|
||||
}
|
||||
let sub = g.sub_mut(first)?;
|
||||
let sub = g.sub_mut(first).expect("just added");
|
||||
match rest {
|
||||
Some(r) => nested_group(sub, ids, r),
|
||||
None => Some(sub),
|
||||
None => 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) -> Option<&'a mut PropGroup> {
|
||||
fn group_at<'a>(g: &'a mut PropGroup, ids: &mut Ids, path: &str) -> &'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) -> Option<&'a m
|
|||
if g.sub(first).is_none() {
|
||||
g.children.push(ids.group(first, group_name(first)).into());
|
||||
}
|
||||
let sub = g.sub_mut(first)?;
|
||||
let sub = g.sub_mut(first).expect("just added");
|
||||
match rest {
|
||||
Some(r) => group_at(sub, ids, r),
|
||||
None => Some(sub),
|
||||
None => sub,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -288,8 +288,8 @@ pub fn upgrade_instance(spec: &EffectSpec, g: &mut PropGroup, ids: &mut Ids, lay
|
|||
pr
|
||||
}
|
||||
};
|
||||
match path.and_then(|path| group_at(g, ids, path)) {
|
||||
Some(sub) => sub.children.push(pr.into()),
|
||||
match path {
|
||||
Some(path) => group_at(g, ids, path).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.and_then(|path| group_at(g, ids, path)) {
|
||||
Some(sub) => sub.children.push(src.into()),
|
||||
match path {
|
||||
Some(path) => group_at(g, ids, path).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 { panic!("not a popup") };
|
||||
let ParamUi::Popup { options } = &popup.ui else { unreachable!() };
|
||||
{
|
||||
let pr = g.get_mut(popup.id).unwrap();
|
||||
pr.name = "Old Name".into();
|
||||
|
|
|
|||
|
|
@ -128,14 +128,10 @@ 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] = &[
|
||||
ACES2065_1,
|
||||
ACES_CG,
|
||||
ACES_CCT,
|
||||
sp("ACES2065-1", AP0, ACES_WHITE, Tf::Linear),
|
||||
sp("ACEScg", AP1, ACES_WHITE, Tf::Linear),
|
||||
sp("ACEScct", AP1, ACES_WHITE, Tf::AcesCct),
|
||||
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)),
|
||||
|
|
@ -151,7 +147,6 @@ 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))
|
||||
}
|
||||
|
|
@ -783,7 +778,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 = &ACES_CCT;
|
||||
let cct = space_by_name("ACEScct").expect("built-in");
|
||||
let (a, z) = if inv { (dst, src) } else { (src, dst) };
|
||||
let to_p = Xform::new(a, cct);
|
||||
let from_p = Xform::new(cct, z);
|
||||
|
|
@ -810,8 +805,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 => ACES_CG,
|
||||
ColorSpace::Aces2065 => ACES2065_1,
|
||||
ColorSpace::AcesCg => *space_by_name("ACEScg").expect("built-in"),
|
||||
ColorSpace::Aces2065 => *space_by_name("ACES2065-1").expect("built-in"),
|
||||
// HDR encodings are output spaces, never a working space.
|
||||
ColorSpace::Rec2100Pq | ColorSpace::Rec2100Hlg => sp("Rec.2020", REC2020, D65, Tf::Gamma(2.4)),
|
||||
};
|
||||
|
|
@ -821,8 +816,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 => (ACES_CG, true),
|
||||
_ => (ACES2065_1, true),
|
||||
5 => (*space_by_name("ACEScg").expect("built-in"), true),
|
||||
_ => (*space_by_name("ACES2065-1").expect("built-in"), true),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1094,7 +1089,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 = &ACES_CCT;
|
||||
let cct = space_by_name("ACEScct").expect("built-in");
|
||||
let (a, z) = if inv { (dst, src) } else { (src, dst) };
|
||||
if inv {
|
||||
list.reverse();
|
||||
|
|
@ -1162,7 +1157,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 pts.last().is_some_and(|l| x > l.0 + 1e-4) && x < 1.0 - 1e-4 {
|
||||
if x > pts.last().expect("non-empty").0 + 1e-4 && x < 1.0 - 1e-4 {
|
||||
pts.push((x, y));
|
||||
}
|
||||
}
|
||||
|
|
@ -1201,7 +1196,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 v.last().is_some_and(|l| x > l.0 + 1e-4) && x < 1.0 - 1e-4 {
|
||||
if x > v.last().expect("non-empty").0 + 1e-4 && x < 1.0 - 1e-4 {
|
||||
v.push((x, y));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -686,10 +686,13 @@ 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);
|
||||
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 };
|
||||
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
|
||||
}
|
||||
};
|
||||
for st in parse_glyph(c) {
|
||||
set.push(
|
||||
st.iter()
|
||||
|
|
|
|||
|
|
@ -382,7 +382,7 @@ impl Session {
|
|||
if !job.is_finished() {
|
||||
return false;
|
||||
}
|
||||
let Some(mut job) = self.camera_job.take() else { return false };
|
||||
let mut job = self.camera_job.take().expect("job");
|
||||
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::prop_ref;
|
||||
use super::prop::{edit_keys, 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,19 +73,11 @@ 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 (ti, lid) in targets.iter().enumerate() {
|
||||
for lid in &targets {
|
||||
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
|
||||
|
|
@ -139,14 +131,7 @@ 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 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() {
|
||||
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 }));
|
||||
|
|
@ -160,37 +145,6 @@ 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()))
|
||||
}
|
||||
|
|
@ -199,8 +153,11 @@ fn select_related(s: &mut Session, which: &str) -> 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.saturating_mul(frames));
|
||||
super::prop::shift_keys(s, "Nudge Keyframes", merge_p(p), d)
|
||||
let d = Tick(comp.frame_duration().0 * frames);
|
||||
edit_keys(s, "Nudge Keyframes", merge_p(p), move |_, _, t| {
|
||||
*t += d;
|
||||
true
|
||||
})
|
||||
}
|
||||
|
||||
/// Edit one key directly: move it in time and/or set its value (Graph Editor drags).
|
||||
|
|
@ -429,15 +386,7 @@ 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?, visible?: [{layer, prop}] (every key of these properties)}",
|
||||
keys_or_layers,
|
||||
select_all
|
||||
),
|
||||
cmd!("keys.selectAll", "Select All Keyframes", [], Some("Cmd+Alt+A"), "{layers?}", 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}))),
|
||||
|
|
@ -450,9 +399,6 @@ 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",
|
||||
|
|
|
|||
|
|
@ -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 Some(first) = frames.iter().find_map(|f| f.get(k)) else { continue };
|
||||
let first = frames.iter().find_map(|f| f.get(k)).expect("mask");
|
||||
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,16 +173,14 @@ fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
build::MASK_COLORS[(base + k) % build::MASK_COLORS.len()],
|
||||
);
|
||||
if frames.len() > 1 {
|
||||
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 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(op) = g.get_mut("opacity")
|
||||
&& frames.iter().any(|f| f.get(k).is_none())
|
||||
{
|
||||
let op = g.get_mut("opacity").expect("opacity");
|
||||
if 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,14 +12,6 @@ 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) {
|
||||
|
|
@ -98,16 +90,9 @@ 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)));
|
||||
|
|
@ -134,7 +119,6 @@ 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")
|
||||
|
|
@ -268,7 +252,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), preserveFrameRate?: bool, preserveResolution?: bool, 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), renderer? classic3D|advanced3D, anchor?, open?}",
|
||||
always,
|
||||
new_comp
|
||||
),
|
||||
|
|
@ -277,7 +261,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), 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}",
|
||||
"{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}",
|
||||
has_comp,
|
||||
settings
|
||||
),
|
||||
|
|
|
|||
|
|
@ -299,6 +299,46 @@ 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();
|
||||
|
|
@ -430,34 +470,8 @@ fn label(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// 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
|
||||
fn paste_reversed(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
paste_keys(s, true)
|
||||
}
|
||||
|
||||
/// Lift (leave a gap) or extract (close the gap) the work area from the selected layers (all
|
||||
|
|
@ -653,7 +667,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, "{layers?, prop?|path?, time?}", has_key_clip, paste_reversed),
|
||||
cmd!("edit.pasteReversedKeyframes", "Paste Reversed Keyframes", ["Edit"], None, "{}", 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, f_p, has_layers, layer_mut, layers_p, str_p};
|
||||
use super::{CommandSpec, b_p, bad, 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,52 +132,6 @@ 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")?;
|
||||
|
|
@ -401,11 +355,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_lowercase);
|
||||
let filter = str_p(p, "filter").map(str::to_ascii_lowercase);
|
||||
let v: Vec<Value> = effectcraft_effects::all()
|
||||
.into_iter()
|
||||
.filter(|e| {
|
||||
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))
|
||||
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))
|
||||
})
|
||||
.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<_>>()})
|
||||
|
|
@ -417,15 +371,6 @@ 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::{Expression, ItemId, Layer, Property};
|
||||
use effectcraft_project::{ItemId, Layer, Property};
|
||||
use effectcraft_render::EvalCtx;
|
||||
use effectcraft_time::Tick;
|
||||
use serde::Serialize;
|
||||
|
|
@ -45,13 +45,14 @@ 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, &Expression)> = vec![];
|
||||
let mut props: Vec<&Property> = vec![];
|
||||
l.props.walk("", &mut |_, p| {
|
||||
if let Some(e) = p.expr.as_ref().filter(|e| !e.text.trim().is_empty()) {
|
||||
props.push((p, e));
|
||||
if p.expr.as_ref().is_some_and(|e| !e.text.trim().is_empty()) {
|
||||
props.push(p);
|
||||
}
|
||||
});
|
||||
for (p, e) in props {
|
||||
for p in props {
|
||||
let e = p.expr.as_ref().expect("filtered");
|
||||
let msg = if e.enabled {
|
||||
match &ctx.expr {
|
||||
Some(h) => h.eval(&ctx, l, p, &p.value_at(l.layer_time(t))).err(),
|
||||
|
|
|
|||
|
|
@ -176,7 +176,7 @@ fn new_comp_from_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
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),
|
||||
"duration": f_p(p, "overlapDuration").unwrap_or(1.0),
|
||||
"transition": str_p(p, "transition").unwrap_or("off"),
|
||||
})
|
||||
});
|
||||
|
|
@ -187,7 +187,7 @@ fn new_comp_from_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
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 lead = proj.item(if single { g[from] } else { g[0] }).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 {
|
||||
|
|
@ -199,7 +199,7 @@ fn new_comp_from_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
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)),
|
||||
Some(_) if g.len() > 1 => g.iter().map(|i| duration(*i)).fold(Tick::ZERO, |a, d| a + d) - Tick(overlap.0 * (g.len() as i64 - 1)),
|
||||
_ => g.iter().map(|i| duration(*i)).max().unwrap_or(Tick::from_seconds_f64(still)),
|
||||
}
|
||||
.max(rate.frame_duration());
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Other ArtCraft Apps",
|
||||
[],
|
||||
None,
|
||||
"{app: photocraft|vectorcraft|filmcraft|lightcraft|printcraft|designcraft, kind?: page|github}",
|
||||
"{app: photocraft|drawcraft|filmcraft|lightcraft|printcraft|designcraft, kind?: page|github}",
|
||||
always,
|
||||
sibling
|
||||
),
|
||||
|
|
|
|||
|
|
@ -19,25 +19,11 @@ 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 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 comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let fr = comp.frame_rate;
|
||||
let sel = s.state.selected_keys.clone();
|
||||
let ts = f_p(p, "timeScale").unwrap_or(1.0);
|
||||
let toff = f_p(p, "timeOffset").unwrap_or(0.0);
|
||||
let vs = f_p(p, "valueScale").unwrap_or(1.0);
|
||||
|
|
@ -60,10 +46,7 @@ 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, |proj, st| {
|
||||
if let Some(b) = base {
|
||||
*proj = (*b).clone();
|
||||
}
|
||||
s.edit("Transform Keyframes", merge_p(p), |proj, st| {
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let mut groups: std::collections::BTreeMap<(LayerId, Uid), Vec<Tick>> = Default::default();
|
||||
for k in &sel {
|
||||
|
|
@ -153,7 +136,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Transform Keyframes",
|
||||
[],
|
||||
None,
|
||||
"{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)}",
|
||||
"{timeScale?, timeAnchor? (comp s), timeOffset? (s), valueScale?, valueAnchor?, valueOffset?, dim? | dims?: [d…], merge?}",
|
||||
has_keys,
|
||||
transform
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1031,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 {
|
||||
|
|
@ -1197,7 +1197,7 @@ fn layer_settings(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
/// 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")?;
|
||||
let l = s.active_comp().and_then(|c| c.layer(s.state.selected_layers[0])).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()),
|
||||
|
|
|
|||
|
|
@ -517,13 +517,6 @@ pub(crate) fn unlocked(s: &Session, cid: ItemId, ids: Vec<LayerId>, cmd: &str) -
|
|||
}
|
||||
}
|
||||
|
||||
/// `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") {
|
||||
|
|
|
|||
|
|
@ -335,19 +335,7 @@ fn select_keys(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
sel.push(KeyRef { layer: l, prop: pr.uid, time: kt });
|
||||
}
|
||||
}
|
||||
// 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) {
|
||||
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);
|
||||
|
|
@ -412,72 +400,14 @@ 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)"))?);
|
||||
shift_keys(s, "Move Keyframes", merge_p(p), d)
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
fn delete_keys(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
|
@ -613,14 +543,7 @@ 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];
|
||||
// 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,
|
||||
};
|
||||
k.out_interp = if k.out_interp == Interp::Hold { Interp::Linear } else { Interp::Hold };
|
||||
false
|
||||
})
|
||||
}
|
||||
|
|
@ -744,15 +667,7 @@ 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?, toggle?: bool (Shift+click: in or out of the selection)}",
|
||||
has_comp,
|
||||
select_keys
|
||||
),
|
||||
cmd!("keys.select", "Select Keyframes", [], None, "{keys: [{layer, prop: uid | path (or `path`), time (layer s)}], add?}", 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,7 +79,6 @@ 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 = ids.last().and_then(|last| cm.layers.iter().position(|l| l.id == *last)).unwrap_or(i);
|
||||
let cur = cm.layers.iter().position(|l| l.id == *ids.last().expect("piece")).unwrap_or(i);
|
||||
let l = &mut cm.layers[cur];
|
||||
if !(*t > l.in_point && *t < l.out_point) {
|
||||
continue;
|
||||
|
|
|
|||
|
|
@ -72,11 +72,14 @@ 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);
|
||||
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 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")
|
||||
}
|
||||
};
|
||||
let mut n = 0;
|
||||
for pr in list {
|
||||
if props.get(&pr.match_id).is_none() {
|
||||
|
|
@ -158,11 +161,14 @@ 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"))?;
|
||||
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 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")
|
||||
}
|
||||
};
|
||||
let (m, label) = match kind.as_str() {
|
||||
"range" => ("rangeSelector", "Range Selector"),
|
||||
"wiggly" => ("wigglySelector", "Wiggly Selector"),
|
||||
|
|
|
|||
|
|
@ -455,9 +455,8 @@ 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 Some(ed) = s.state.text_edit.as_mut()
|
||||
{
|
||||
if e.anchor > n || e.caret > n {
|
||||
let ed = s.state.text_edit.as_mut().expect("checked");
|
||||
ed.anchor = ed.anchor.min(n);
|
||||
ed.caret = ed.caret.min(n);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,7 +71,6 @@ 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(),
|
||||
|
|
@ -79,25 +78,8 @@ 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), markers and the work area, in comp time.
|
||||
// Keyframes (of visible/selected layers) and markers, 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()
|
||||
|
|
@ -108,9 +90,6 @@ 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| {
|
||||
|
|
@ -125,7 +104,14 @@ fn go(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
times.extend(l.markers.iter().map(|m| l.comp_time(m.time)));
|
||||
}
|
||||
}
|
||||
step_to(times, t, comp.frame_duration(), to == "nextKey")
|
||||
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)
|
||||
}
|
||||
}
|
||||
_ => return Err(super::bad("time.go", "to: start|end|workStart|workEnd|layerIn|layerOut|nextKey|prevKey")),
|
||||
};
|
||||
|
|
@ -133,22 +119,6 @@ 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),
|
||||
|
|
@ -161,27 +131,8 @@ 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"),
|
||||
"{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
|
||||
),
|
||||
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),
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).ok_or_else(|| bad(cmd, "the layer has no Motion Trackers group"))?.children.push(g.into());
|
||||
l.motion_trackers_mut(&mut ids).children.push(g.into());
|
||||
proj.next_id = next;
|
||||
state.current_track = Some((lid, uid));
|
||||
state.selected_layers = vec![lid];
|
||||
|
|
|
|||
|
|
@ -1,65 +0,0 @@
|
|||
//! 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,15 +8,12 @@
|
|||
//! 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;
|
||||
|
|
@ -134,17 +131,6 @@ 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 {
|
||||
|
|
@ -384,10 +370,6 @@ 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
|
||||
|
|
@ -480,8 +462,6 @@ 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![],
|
||||
|
|
@ -515,8 +495,7 @@ impl Session {
|
|||
return Err(EngineError::Disabled(id.to_string(), why));
|
||||
}
|
||||
}
|
||||
// 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))?;
|
||||
let r = (spec.run)(self, ¶ms)?;
|
||||
self.learn_observe(id, ¶ms);
|
||||
if spec.journal {
|
||||
self.journal.push((id.to_string(), params));
|
||||
|
|
@ -652,13 +631,6 @@ 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?)
|
||||
}
|
||||
|
|
@ -954,8 +926,6 @@ mod tests_fidelity;
|
|||
#[cfg(test)]
|
||||
mod tests_frame_export;
|
||||
#[cfg(test)]
|
||||
mod tests_keylight;
|
||||
#[cfg(test)]
|
||||
mod tests_lottie;
|
||||
#[cfg(test)]
|
||||
mod tests_m137;
|
||||
|
|
@ -970,8 +940,6 @@ 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"),
|
||||
("VectorCraft", "vectorcraft"),
|
||||
("DrawCraft", "drawcraft"),
|
||||
("FilmCraft", "filmcraft"),
|
||||
("LightCraft", "lightcraft"),
|
||||
("PrintCraft", "printcraft"),
|
||||
|
|
|
|||
|
|
@ -62,16 +62,6 @@ 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,7 +439,8 @@ impl Session {
|
|||
}
|
||||
self.bump();
|
||||
}
|
||||
if finished && let Some(mut job) = self.mask_job.take() {
|
||||
if finished {
|
||||
let mut job = self.mask_job.take().expect("job");
|
||||
if let Some(t) = job.thread.take() {
|
||||
let _ = t.join();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -196,13 +196,7 @@ 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(|| {
|
||||
// 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()
|
||||
})
|
||||
})
|
||||
BAR.get_or_init(|| parse(TREE, cfg!(target_os = "macos")).expect("menu tree parses"))
|
||||
}
|
||||
|
||||
/// Every entry with its submenu path (`["Layer", "Blending Mode"]`), depth first.
|
||||
|
|
|
|||
|
|
@ -3,10 +3,6 @@
|
|||
//! 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 Some(j) = self.render_job.as_ref() else { return true };
|
||||
let Some(r) = j.remote.as_ref() else { return true };
|
||||
let j = self.render_job.as_ref().expect("job");
|
||||
let r = j.remote.as_ref().expect("remote");
|
||||
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,7 +570,8 @@ impl Session {
|
|||
}
|
||||
self.bump();
|
||||
}
|
||||
if finished && let Some(mut job) = self.render_job.take() {
|
||||
if finished {
|
||||
let mut job = self.render_job.take().expect("job");
|
||||
if let Some(t) = job.thread.take() {
|
||||
let _ = t.join();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -587,7 +587,7 @@ impl Session {
|
|||
if !job.is_finished() {
|
||||
return false;
|
||||
}
|
||||
let Some(mut job) = self.roto_job.take() else { return false };
|
||||
let mut job = self.roto_job.take().expect("job");
|
||||
if let Some(t) = job.thread.take() {
|
||||
let _ = t.join();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,13 +75,11 @@ impl Session {
|
|||
self.footage.set_cache_budget(b.media);
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// 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.
|
||||
pub fn memory_tick(&mut self) -> crate::prefs::CacheBudgets {
|
||||
let before = self.prefs.cache_budgets(self.sys_memory);
|
||||
self.sys_memory = self.memory_watch.latest().or(self.sys_memory);
|
||||
self.memory_watch.poll();
|
||||
self.sys_memory = crate::sysinfo::memory().or(self.sys_memory);
|
||||
let after = self.prefs.cache_budgets(self.sys_memory);
|
||||
if after != before {
|
||||
self.apply_cache_budgets();
|
||||
|
|
|
|||
|
|
@ -4,11 +4,7 @@
|
|||
//! 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. 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};
|
||||
//! configured budgets.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
|
|
@ -27,51 +23,10 @@ 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 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()?;
|
||||
let out = std::process::Command::new(cmd).args(args).output().ok()?;
|
||||
out.status.success().then(|| String::from_utf8_lossy(&out.stdout).to_string())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -301,17 +301,6 @@ 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,
|
||||
_ => panic!("not a composition"),
|
||||
_ => unreachable!(),
|
||||
});
|
||||
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,18 +175,3 @@ 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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,135 +0,0 @@
|
|||
//! 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");
|
||||
}
|
||||
|
|
@ -811,246 +811,3 @@ fn project_files_keep_their_format_warn_about_newer_versions_and_never_save_unre
|
|||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,122 +0,0 @@
|
|||
//! 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(),
|
||||
_ => panic!("not footage"),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
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(),
|
||||
_ => panic!("not footage"),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
s.execute("file.interpretFootage", json!({"items": [b.0, w.0, h.0], "alpha": "guess"})).unwrap();
|
||||
assert_eq!(foot(&s, b).alpha, AlphaMode::Premultiplied);
|
||||
|
|
|
|||
|
|
@ -464,25 +464,3 @@ 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,7 +407,8 @@ impl Session {
|
|||
}
|
||||
self.bump();
|
||||
}
|
||||
if finished && let Some(mut job) = self.track_job.take() {
|
||||
if finished {
|
||||
let mut job = self.track_job.take().expect("job");
|
||||
if let Some(t) = job.thread.take() {
|
||||
let _ = t.join();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -375,7 +375,7 @@ impl Session {
|
|||
if !job.is_finished() {
|
||||
return false;
|
||||
}
|
||||
let Some(mut job) = self.warp_job.take() else { return false };
|
||||
let mut job = self.warp_job.take().expect("job");
|
||||
if let Some(t) = job.thread.take() {
|
||||
let _ = t.join();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,8 +28,6 @@
|
|||
//! 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,8 +24,6 @@
|
|||
//! 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;
|
||||
|
|
@ -184,7 +182,6 @@ 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,15 +34,6 @@ 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,11 +304,8 @@ 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 Some((open, parent)) = stack.pop()
|
||||
{
|
||||
} 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!());
|
||||
let inner = std::mem::replace(&mut cur, parent);
|
||||
cur.push(Item::Group { open, inner, close: Some(t) });
|
||||
} else {
|
||||
|
|
@ -548,67 +545,17 @@ fn push_piece(out: &mut String, p: Piece) {
|
|||
out.push_str(&p.body);
|
||||
}
|
||||
|
||||
/// 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::{MAX_NESTING, check_nesting, rewrite};
|
||||
use super::rewrite;
|
||||
|
||||
/// Compare ignoring spaces (newlines are significant).
|
||||
fn n(s: &str) -> String {
|
||||
|
|
@ -652,25 +599,4 @@ 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,11 +238,8 @@ 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. 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));
|
||||
// error line numbers match the user's text.
|
||||
let src = format!("{{{}\n}}", 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,22 +388,6 @@ 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,8 +5,6 @@
|
|||
//! 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,12 +507,9 @@ 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.
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::panic)] // test builds only
|
||||
{
|
||||
panic!("wgpu: {e}")
|
||||
if cfg!(test) {
|
||||
panic!("wgpu: {e}");
|
||||
}
|
||||
#[cfg(not(test))]
|
||||
log::error!("wgpu: {e}")
|
||||
}));
|
||||
GpuContext::new(&adapter, device, queue)
|
||||
|
|
|
|||
|
|
@ -51,8 +51,6 @@
|
|||
//! 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,9 +380,7 @@ impl<'a> FrameCoder<'a> {
|
|||
return cost;
|
||||
}
|
||||
}
|
||||
// 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 };
|
||||
let (cost, e, snap, mode) = best.expect("a coding unit decision");
|
||||
self.restore(&snap);
|
||||
*est = e;
|
||||
decs.push(CuDec { x: x as u32, y: y as u32, log2, mode });
|
||||
|
|
@ -454,8 +452,7 @@ 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,
|
||||
// Handled by the arm above.
|
||||
CuMode::Intra { .. } => [0, 0],
|
||||
CuMode::Intra { .. } => unreachable!(),
|
||||
};
|
||||
let amvp = if let CuMode::Amvp { .. } = mode { self.amvp(x, y, n) } else { [[0; 2]; 2] };
|
||||
let skip = matches!(mode, CuMode::Skip { .. });
|
||||
|
|
@ -506,8 +503,7 @@ impl<'a> FrameCoder<'a> {
|
|||
self.write_tt(s, x, y, x, y, log2, 0, 0, [true, true], false, false, &mut ti);
|
||||
}
|
||||
}
|
||||
// Handled by the arm above.
|
||||
CuMode::Intra { .. } => {}
|
||||
CuMode::Intra { .. } => unreachable!(),
|
||||
}
|
||||
(eff, self.cu_dist(x, y, n))
|
||||
}
|
||||
|
|
@ -868,8 +864,7 @@ impl<'a> FrameCoder<'a> {
|
|||
}
|
||||
|
||||
fn predict_inter(&mut self, x: usize, y: usize, n: usize, mv: [i16; 2]) {
|
||||
// Inter modes are only tried in P slices, which have a reference picture.
|
||||
let Some(refp) = self.refp else { return };
|
||||
let refp = self.refp.expect("reference picture");
|
||||
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 };
|
||||
|
|
@ -887,7 +882,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 Some(me) = self.me else { return (0, u64::MAX / 4) };
|
||||
let me = self.me.expect("motion search reference");
|
||||
let mut buf = vec![0u16; n * n];
|
||||
let mut best = (0, u64::MAX);
|
||||
for (i, &mv) in list.iter().enumerate() {
|
||||
|
|
@ -906,7 +901,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 Some(me) = self.me else { return (amvp[0], 0, u64::MAX / 4) };
|
||||
let me = self.me.expect("motion search reference");
|
||||
let (xi, yi) = (x as i32, y as i32);
|
||||
let src = &self.src[0][y * self.w + x..];
|
||||
let ls = self.lambda_sad;
|
||||
|
|
@ -1173,8 +1168,7 @@ fn residual_coding<S: Sink>(s: &mut S, lv: &[i32], log2: u32, c: usize, scan_idx
|
|||
}
|
||||
}
|
||||
}
|
||||
// Callers only code blocks with a nonzero level (coded_block_flag = 1).
|
||||
let Some((last_sb, last_pos)) = last else { return };
|
||||
let (last_sb, last_pos) = last.expect("residual_coding of an all-zero block");
|
||||
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,7 +14,6 @@
|
|||
//! 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)]
|
||||
|
||||
|
|
@ -288,7 +287,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().copied().unwrap_or(0);
|
||||
let last = *plane.last().unwrap();
|
||||
plane.extend(std::iter::repeat_n(last, cw - w));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,6 @@
|
|||
//! 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).ok_or(Error::NoComp(comp.0))?;
|
||||
let seq = ex.sequence(comp);
|
||||
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); None if `cid` is not a composition.
|
||||
fn sequence(&mut self, cid: ItemId) -> Option<fp::ItemId> {
|
||||
/// The sequence for a comp (created once).
|
||||
fn sequence(&mut self, cid: ItemId) -> fp::ItemId {
|
||||
if let Some(s) = self.seqs.get(&cid) {
|
||||
return Some(*s);
|
||||
return *s;
|
||||
}
|
||||
let comp = self.p.comp_arc(cid)?;
|
||||
let comp = self.p.comp_arc(cid).expect("comp");
|
||||
let it = self.p.item(cid);
|
||||
let name = it.map(|i| i.name.clone()).unwrap_or_default();
|
||||
let settings = fp::SequenceSettings {
|
||||
|
|
@ -430,13 +430,12 @@ 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);
|
||||
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
|
||||
})
|
||||
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)
|
||||
}
|
||||
None => {
|
||||
self.warn(format!("layer \"{}\" was left out ({why}; pre-rendering is off or unavailable)", l.name));
|
||||
|
|
@ -458,16 +457,16 @@ impl Exporter<'_> {
|
|||
Some(ItemKind::Solid(s)) => Some(self.solid_item(*item, s.color, &comp)),
|
||||
_ => None,
|
||||
},
|
||||
LayerSource::Comp { item } if !self.stack.contains(item) => self.sequence(*item),
|
||||
LayerSource::Comp { item } if !self.stack.contains(item) => Some(self.sequence(*item)),
|
||||
_ => None,
|
||||
};
|
||||
item.and_then(|item| {
|
||||
let mut ti = self.track_item(item, &l.name, fp::TrackKind::Video, &tm, rate)?;
|
||||
item.map(|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())));
|
||||
}
|
||||
Some(ti)
|
||||
ti
|
||||
})
|
||||
}
|
||||
};
|
||||
|
|
@ -487,14 +486,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) => {
|
||||
self.sequence(*item)
|
||||
Some(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);
|
||||
|
|
@ -530,18 +529,18 @@ impl Exporter<'_> {
|
|||
seq.work_area = None;
|
||||
}
|
||||
self.stack.pop();
|
||||
Some(sid)
|
||||
sid
|
||||
}
|
||||
|
||||
fn track_item(&mut self, item: fp::ItemId, name: &str, kind: fp::TrackKind, tm: &Timing, rate: FrameRate) -> Option<fp::TrackItem> {
|
||||
fn track_item(&mut self, item: fp::ItemId, name: &str, kind: fp::TrackKind, tm: &Timing, rate: FrameRate) -> 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))?;
|
||||
let mut ti = self.fp.make_track_item(item, kind, tick_out(tm.start), range, rate_out(rate)).expect("item exists");
|
||||
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;
|
||||
Some(ti)
|
||||
ti
|
||||
}
|
||||
|
||||
/// Transform → Motion + Opacity (clip-time keyframes).
|
||||
|
|
@ -555,7 +554,7 @@ impl Exporter<'_> {
|
|||
_ => (comp.width, comp.height),
|
||||
};
|
||||
let center = |x: f64, y: f64| -> fp::ParamValue { fp::ParamValue::Vec2(Vec2 { x, y }) };
|
||||
let Some(mut m) = effect_or_new(ti, "motion") else { return };
|
||||
let mut m = ti.effect("motion").cloned().unwrap_or_else(|| fp::find_effect("motion").expect("motion").instance());
|
||||
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) {
|
||||
|
|
@ -595,9 +594,8 @@ 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 Some(mut o) = effect_or_new(ti, "opacity")
|
||||
{
|
||||
if let Some(p) = tr.get("opacity") {
|
||||
let mut o = ti.effect("opacity").cloned().unwrap_or_else(|| fp::find_effect("opacity").expect("opacity").instance());
|
||||
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()))
|
||||
|
|
@ -617,7 +615,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 Some(mut v) = effect_or_new(ti, "volume") else { return };
|
||||
let mut v = ti.effect("volume").cloned().unwrap_or_else(|| fp::find_effect("volume").expect("volume").instance());
|
||||
v.params.insert("level".into(), param_out(p, &to_clip, |v| fp::ParamValue::Float(v.as_vec2()[0])));
|
||||
set_effect(ti, v);
|
||||
}
|
||||
|
|
@ -636,11 +634,6 @@ 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,83 +45,6 @@ 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],
|
||||
|
|
@ -133,16 +56,6 @@ 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| {
|
||||
|
|
@ -521,7 +434,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 Some(tr) = l.transform_mut() else { return };
|
||||
let tr = l.transform_mut().expect("transform");
|
||||
// Position / anchor (NaN = centred).
|
||||
for (src, dst) in [("position", "position"), ("anchor", "anchor")] {
|
||||
let Some(p) = m.param(src) else { continue };
|
||||
|
|
|
|||
|
|
@ -29,8 +29,6 @@
|
|||
//! 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)]
|
||||
|
|
@ -158,8 +156,6 @@ 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,42 +498,3 @@ 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,9 +441,7 @@ 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;
|
||||
// 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 lin_in = b.in_interp == Interp::Linear;
|
||||
let is_spatial = spatial && matches!(a.value, Value::Vec2(_) | Value::Vec3(_));
|
||||
if is_spatial {
|
||||
let p0 = v3(&a.value);
|
||||
|
|
@ -586,17 +584,6 @@ 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).saturating_sub(1).max(0) as u32))
|
||||
to_scalar(j).map(|v| Value::Enum((v.as_f64().round() as i64 - 1).max(0) as u32))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,8 +18,6 @@
|
|||
//! 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.saturating_sub(1).clamp(0, 2) as u32);
|
||||
set_enum(g, "cap", (lc - 1).clamp(0, 2) as u32);
|
||||
}
|
||||
if let Some(lj) = int(j, "lj") {
|
||||
set_enum(g, "join", lj.saturating_sub(1).clamp(0, 2) as u32);
|
||||
set_enum(g, "join", (lj - 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.saturating_sub(1).clamp(0, 2) as u32);
|
||||
set_enum(&mut g, "join", (lj - 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.saturating_sub(1).clamp(0, 4) as u32);
|
||||
set_enum(&mut g, "mode", (m - 1).clamp(0, 4) as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -725,31 +725,3 @@ 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).saturating_sub(1)).max(0) as u32).min(max));
|
||||
p.value = Value::Enum(((v.round() as i64 - 1).max(0) as u32).min(max));
|
||||
}
|
||||
};
|
||||
en("units", "r", 1);
|
||||
|
|
|
|||
|
|
@ -73,9 +73,7 @@ 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(l) = footage.layer.as_ref()
|
||||
&& let Some(uuid) = l.embedded.as_deref()
|
||||
{
|
||||
if let Some(uuid) = footage.layer.as_ref().and_then(|l| 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}")))?;
|
||||
|
|
@ -86,6 +84,7 @@ 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,8 +13,6 @@
|
|||
//! (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;
|
||||
|
|
|
|||
|
|
@ -12,8 +12,6 @@
|
|||
//! 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,7 +23,6 @@
|
|||
//! 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)]
|
||||
|
||||
|
|
@ -318,26 +317,23 @@ impl OpusEncoder {
|
|||
}
|
||||
Mode::Silk => {
|
||||
let x = self.silk_input(pcm);
|
||||
// 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));
|
||||
}
|
||||
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));
|
||||
}
|
||||
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();
|
||||
if let Some(sp) = self.silk.as_mut() {
|
||||
sp.enc.encode(&x, sp.target_bits, total - celt_min, &mut enc);
|
||||
}
|
||||
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);
|
||||
|
|
@ -353,7 +349,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 Some(sp) = self.silk.as_mut() else { return vec![vec![]; c] };
|
||||
let sp = self.silk.as_mut().expect("SILK state");
|
||||
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 VectorCraft's `vectorcraft-pathops` (`crates/pathops/src/boolean.rs`; our own code,
|
||||
//! Adapted from DrawCraft'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 VectorCraft's `vectorcraft-pathops` (`crates/pathops/src/fit.rs`; our own code,
|
||||
//! Adapted from DrawCraft'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,7 +72,8 @@ 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 (Some(&p0), Some(&p3)) = (pts.first(), pts.last()) else { return CubicBez::new(Point::ZERO, Point::ZERO, Point::ZERO, Point::ZERO) };
|
||||
let p0 = pts[0];
|
||||
let p3 = *pts.last().unwrap();
|
||||
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,8 +5,6 @@
|
|||
//! 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;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
//! offsetting (kurbo's stroker offsets curves analytically and refits them) and robust overlap
|
||||
//! removal (the boolean sweep).
|
||||
//!
|
||||
//! Adapted from VectorCraft's `vectorcraft-pathops` (`crates/pathops/src/offset.rs`; our own code,
|
||||
//! Adapted from DrawCraft's `vectorcraft-pathops` (`crates/pathops/src/offset.rs`; our own code,
|
||||
//! MIT OR Apache-2.0).
|
||||
|
||||
use kurbo::{BezPath, PathEl, Stroke, StrokeOpts};
|
||||
|
|
|
|||
|
|
@ -651,8 +651,7 @@ pub fn merge(paths: &[BezPath], mode: MergeMode) -> BezPath {
|
|||
MergeMode::Subtract => crate::boolean::boolean_n(&inputs, |m| m[0] && !m[1..].iter().any(|&b| b)),
|
||||
MergeMode::Intersect => crate::boolean::boolean_n(&inputs, |m| m.iter().all(|&b| b)),
|
||||
MergeMode::Exclude => crate::boolean::boolean_n(&inputs, |m| m.iter().filter(|&&b| b).count() % 2 == 1),
|
||||
// Handled above (no boolean operation).
|
||||
MergeMode::Merge => crate::boolean::boolean_n(&inputs, |m| m.iter().any(|&b| b)),
|
||||
MergeMode::Merge => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -646,21 +646,12 @@ impl T2<'_> {
|
|||
self.path.move_to((self.x, self.y));
|
||||
self.open = true;
|
||||
}
|
||||
/// A segment before the first moveto starts at the current point.
|
||||
fn ensure_open(&mut self) {
|
||||
if !self.open {
|
||||
self.path.move_to((self.x, self.y));
|
||||
self.open = true;
|
||||
}
|
||||
}
|
||||
fn line_to(&mut self, dx: f64, dy: f64) {
|
||||
self.ensure_open();
|
||||
self.x += dx;
|
||||
self.y += dy;
|
||||
self.path.line_to((self.x, self.y));
|
||||
}
|
||||
fn curve(&mut self, d: [f64; 6]) {
|
||||
self.ensure_open();
|
||||
let (x1, y1) = (self.x + d[0], self.y + d[1]);
|
||||
let (x2, y2) = (x1 + d[2], y1 + d[3]);
|
||||
self.x = x2 + d[4];
|
||||
|
|
|
|||
|
|
@ -7,8 +7,6 @@
|
|||
//! children are the document's **layers**: one group per top-level optional-content group
|
||||
//! (Illustrator layers), with content outside them gathered into groups of its own.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod build;
|
||||
mod ccitt;
|
||||
mod cff;
|
||||
|
|
|
|||
|
|
@ -332,7 +332,7 @@ impl<'a> Interp<'a> {
|
|||
// Painting.
|
||||
"S" => self.paint(false, None, true, depth),
|
||||
"s" => {
|
||||
self.close_subpath();
|
||||
self.path.close_path();
|
||||
self.paint(false, None, true, depth)
|
||||
}
|
||||
"f" | "F" => self.paint(true, Some(FillRule::NonZero), false, depth),
|
||||
|
|
@ -340,11 +340,11 @@ impl<'a> Interp<'a> {
|
|||
"B" => self.paint(true, Some(FillRule::NonZero), true, depth),
|
||||
"B*" => self.paint(true, Some(FillRule::EvenOdd), true, depth),
|
||||
"b" => {
|
||||
self.close_subpath();
|
||||
self.path.close_path();
|
||||
self.paint(true, Some(FillRule::NonZero), true, depth)
|
||||
}
|
||||
"b*" => {
|
||||
self.close_subpath();
|
||||
self.path.close_path();
|
||||
self.paint(true, Some(FillRule::EvenOdd), true, depth)
|
||||
}
|
||||
"n" => self.paint(false, None, false, depth),
|
||||
|
|
@ -549,13 +549,6 @@ impl<'a> Interp<'a> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Close the current subpath; nothing to close without a current point.
|
||||
fn close_subpath(&mut self) {
|
||||
if self.cur.is_some() {
|
||||
self.path.close_path();
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_start(&mut self) {
|
||||
if self.cur.is_none() {
|
||||
self.path.move_to((0.0, 0.0));
|
||||
|
|
|
|||
|
|
@ -278,7 +278,7 @@ impl Src<'_> {
|
|||
None => {
|
||||
// Radix numbers (16#FF).
|
||||
let s = String::from_utf8_lossy(w).into_owned();
|
||||
match s.split_once('#').and_then(|(r, v)| i64::from_str_radix(v, r.parse::<u32>().ok().filter(|r| (2..=36).contains(r))?).ok()) {
|
||||
match s.split_once('#').and_then(|(r, v)| i64::from_str_radix(v, r.parse().ok()?).ok()) {
|
||||
Some(n) => V::Num(n as f64),
|
||||
None => V::Name(s.into()),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -295,21 +295,3 @@ fn truncated_and_corrupt_eps_never_panic() {
|
|||
assert!(r.is_ok(), "{}", String::from_utf8_lossy(prog));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_an_empty_path_is_a_no_op() {
|
||||
// "s", "b" and "b*" closed the path without a current point (kurbo asserts on that).
|
||||
for ops in ["s", "b", "b*", "0 0 m 50 0 l 50 50 l b s b*", "f s 10 10 m 20 20 l s"] {
|
||||
let doc = parse(&page_pdf(ops, "", vec![])).unwrap();
|
||||
let _ = render(&doc);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn postscript_radix_numbers_outside_2_to_36_are_not_numbers() {
|
||||
// `0#7` asked `i64::from_str_radix` for radix 0, which panics (found by fuzzing).
|
||||
for n in ["0#7", "1#1", "37#1", "99999999999#1", "16#FF"] {
|
||||
let src = format!("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n{n} pop 0 0 moveto 10 10 lineto stroke\n");
|
||||
let _ = parse(src.as_bytes());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,8 +22,6 @@
|
|||
//! differently on another machine. The runtime is the `wasm` feature (on for the desktop app and
|
||||
//! the CLI); without it [`load_wasm`] says plug-ins are unavailable.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
use effectcraft_effects::EffectSpec;
|
||||
pub use effectcraft_effects::plugin::{EffectPlugin, PLUGIN_API_VERSION, PluginFrame, PluginManifest, PluginParams, register_plugin};
|
||||
|
||||
|
|
@ -64,7 +62,5 @@ pub fn loader(bytes: &[u8], source: &str) -> Result<serde_json::Value, String> {
|
|||
}))
|
||||
}
|
||||
|
||||
// Two attributes, so clippy sees `cfg(test)` and treats the module as test code.
|
||||
#[cfg(test)]
|
||||
#[cfg(feature = "wasm")]
|
||||
#[cfg(all(test, feature = "wasm"))]
|
||||
mod tests;
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue