mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-09 13:08:15 +00:00
Merge main into release
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
name: CI (macOS)
|
||||
|
||||
# The macOS leg of the gate: `cargo xtask ci` on Apple silicon, through the suite's shared gate
|
||||
# workflow. The Linux gate (ci.yml) has no real GPU and runs on x86, so GPU tests on Metal and
|
||||
# results that depend on the platform's math library first ran on a contributor's Mac.
|
||||
#
|
||||
# On pushes to main, not on every pull request: macOS runners are scarce. A newer push cancels the
|
||||
# older run, so main is checked at its latest commit. Pull requests that change this file run it.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
paths: [.github/workflows/ci-macos.yml]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ci-macos-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
|
||||
with:
|
||||
os: '["macos-15"]'
|
||||
timeout-minutes: 120
|
||||
@@ -15,7 +15,14 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# Regenerates contributors/contributors.json for the release commit; every build bundles it.
|
||||
credits:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
uses: storytold/craft-common-actions/.github/workflows/refresh-contributors.yml@main
|
||||
release:
|
||||
needs: credits
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
@@ -25,6 +32,7 @@ jobs:
|
||||
app-name: effectcraft
|
||||
app-title: EffectCraft
|
||||
version: ${{ inputs.version }}
|
||||
contributors-artifact: ${{ needs.credits.outputs.artifact-name }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
|
||||
+1
-1
@@ -64,7 +64,7 @@ and licence texts are in [`NOTICE`](NOTICE).
|
||||
| `crates/model/tests/fixtures/cube.obj` | EffectCraft contributors | Original work: Wavefront OBJ test fixture (unit cube, two materials) | MIT OR Apache-2.0 |
|
||||
| `crates/model/tests/fixtures/cube.mtl` | EffectCraft contributors | Original work: MTL test fixture (materials of cube.obj) | MIT OR Apache-2.0 |
|
||||
|
||||
One small After Effects test project of `crates/aep` is in this repository: `crates/aep/tests/fixtures/synthetic.aep` (original work, made with After Effects 2026 from own data, working space None, the embedded colour profile removed; MIT OR Apache-2.0, with its `.attribution` sidecar). The other After Effects test projects of `crates/aep` are not in this repository. They are original work of an EffectCraft contributor, generated with After Effects 2026 as own test data (MIT OR Apache-2.0, each file with its `.attribution` sidecar); they are in [storytold/effectcraft-corpus](https://github.com/storytold/effectcraft-corpus) with their sidecars, fetched by `cargo xtask aep-corpus` (commit-pinned, SHA-256-verified), and the tests read them only from the folder named by `EFFECTCRAFT_AEP_FIXTURES` (opt-in). Their embedded colour profiles are replaced by stubs made with `crates/aep/examples/stubicc.rs` (a header and a description, no colour data), so no vendor profile data is in them.
|
||||
Two small After Effects test projects of `crates/aep` are in this repository: `crates/aep/tests/fixtures/synthetic.aep` (original work, made with After Effects 2026 from own data, working space None, the embedded colour profile removed; MIT OR Apache-2.0, with its `.attribution` sidecar) and `crates/aep/tests/fixtures/ai-mask-3099.aep` (original work, made with After Effects 2026 from four hand-written one-page PDFs named `.ai`, footage paths and host name neutralised, the embedded colour profiles replaced by stubs made with `crates/aep/examples/stubicc.rs`; MIT OR Apache-2.0, with its `.attribution` sidecar). The other After Effects test projects of `crates/aep` are not in this repository. They are original work of an EffectCraft contributor, generated with After Effects 2026 as own test data (MIT OR Apache-2.0, each file with its `.attribution` sidecar); they are in [storytold/effectcraft-corpus](https://github.com/storytold/effectcraft-corpus) with their sidecars, fetched by `cargo xtask aep-corpus` (commit-pinned, SHA-256-verified), and the tests read them only from the folder named by `EFFECTCRAFT_AEP_FIXTURES` (opt-in). Their embedded colour profiles are replaced by stubs made with `crates/aep/examples/stubicc.rs` (a header and a description, no colour data), so no vendor profile data is in them.
|
||||
|
||||
## Screenshots
|
||||
|
||||
|
||||
Generated
+4
-14
@@ -1184,7 +1184,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
@@ -1468,6 +1468,7 @@ dependencies = [
|
||||
"effectcraft-script",
|
||||
"image",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
@@ -1741,6 +1742,7 @@ version = "0.22.0"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"effectcraft-color",
|
||||
"effectcraft-effects",
|
||||
"effectcraft-model",
|
||||
"effectcraft-pdf",
|
||||
"effectcraft-project",
|
||||
@@ -1877,6 +1879,7 @@ dependencies = [
|
||||
"lz4_flex",
|
||||
"rayon",
|
||||
"serde",
|
||||
"tempfile",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
@@ -7956,7 +7959,6 @@ dependencies = [
|
||||
"nix",
|
||||
"typed-path 0.13.0",
|
||||
"windows-sys 0.61.2",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7971,18 +7973,6 @@ version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
|
||||
[[package]]
|
||||
name = "zopfli"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"crc32fast",
|
||||
"log",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
version = "0.5.3"
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-l
|
||||
craft-quit = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
craft-ecproj-format = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "ca1b5e2edb8568335c6c14f390307bd5beae8238" }
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["egui"] }
|
||||
# internal
|
||||
effectcraft-time = { path = "crates/time" }
|
||||
effectcraft-geom = { path = "crates/geom" }
|
||||
|
||||
@@ -129,18 +129,14 @@ or into the Timeline, between layers and at the time you point to.
|
||||
|
||||
308 built-in effects across its categories, each with its parameter names, order and
|
||||
defaults: blur and sharpen, channel, color correction (Curves, Levels, Hue/Saturation, a full
|
||||
color-grading panel…), distort (Warp, Bulge, Turbulent Displace, CC Power Pin…), generate (Fractal Noise,
|
||||
color-grading panel…), distort (Warp, Bulge, Turbulent Displace, Perspective Pin…), generate (Fractal Noise,
|
||||
Gradient Ramp, Stroke, Write-on, Audio Spectrum…), keying, matte, noise and grain, perspective,
|
||||
**simulation** (CC Particle World, CC Rainfall, Shatter, Card Dance, Caustics, Wave World…),
|
||||
stylize (Glow, CC Glass…), **time** (Echo, Posterize Time, Timewarp, Time Displacement…),
|
||||
**simulation** (Particle Emitter 3D, Rainfall, Shatter, Card Dance, Caustics, Wave World…),
|
||||
stylize (Glow, Glass Relief…), **time** (Echo, Posterize Time, Timewarp, Time Displacement…),
|
||||
**audio** (Reverb, Parametric EQ, Delay, Stereo Mixer…), text, transitions, utility and expression
|
||||
controls. All nine **Layer Styles** (Drop Shadow, Inner/Outer Glow, Bevel and Emboss, Satin,
|
||||
overlays, Stroke) with Global Light. Preview plays audio in sync, with meters and waveforms.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/effectcraft-effects.png" alt="A CC Particle World burst with Glow, and the Effect Controls panel listing the particle system's parameters" width="100%">
|
||||
</p>
|
||||
|
||||
## 3D
|
||||
|
||||
Classic 3D compositing: 3D layers with orientation and material options,
|
||||
|
||||
@@ -35,8 +35,8 @@
|
||||
//!
|
||||
//! Project: --project F.ecproj (or a positional *.ecproj) | --demo | --empty (default: demo; mcp: empty)
|
||||
//! Saving: --save (back to --project) | --save-as F.ecproj
|
||||
//! GPU: --gpu renders on the GPU compositor (Mercury GPU Acceleration) when an adapter exists;
|
||||
//! the default is the CPU (Mercury Software Only)
|
||||
//! GPU: --gpu renders on the GPU compositor (GPU Acceleration) when an adapter exists;
|
||||
//! the default is the CPU (Software Rendering)
|
||||
//! Bridge: --bridge PORT drives a running `effectcraft --control PORT` instead of a headless session
|
||||
//! Output: --json for one compact JSON document on stdout (errors: {"error": …}, exit 1)
|
||||
//! ```
|
||||
@@ -466,7 +466,7 @@ fn backend(args: &Args, default_demo: bool) -> Result<Backend, Failure> {
|
||||
}
|
||||
|
||||
/// A wired session; `--gpu` attaches the GPU compositor (renders then follow the project's
|
||||
/// renderer setting, Mercury GPU Acceleration by default).
|
||||
/// renderer setting, GPU Acceleration by default).
|
||||
fn session(args: &Args) -> Result<effectcraft_engine::Session, Failure> {
|
||||
let mut s = effectcraft_host::session();
|
||||
if args.flag("--autosave") {
|
||||
@@ -1443,7 +1443,7 @@ fn adv3d_bench_comp(p: &mut effectcraft_engine::project::Project) -> Option<Item
|
||||
/// `bench --gpu`: CPU vs GPU ms/frame for every comp (or `--comp`) at Full and Half. "cold"
|
||||
/// renders everything (no layer cache); "warm" reuses the layer cache (playback / scrubbing of
|
||||
/// unchanged layers: compositing cost); "viewer" is the GPU display path without readback;
|
||||
/// "auto" is Backend::Auto (Mercury GPU Acceleration) choosing per comp from measured times,
|
||||
/// "auto" is Backend::Auto (GPU Acceleration) choosing per comp from measured times,
|
||||
/// warm; "up/dn MB" are the GPU's uploads / readbacks per warm GPU frame.
|
||||
fn bench_gpu(s: &Session, args: &Args) -> Result<(), Failure> {
|
||||
use effectcraft_render::Backend;
|
||||
|
||||
@@ -329,7 +329,7 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
|
||||
if n > 0 {
|
||||
app.frames.set_remote(Some(Arc::new(frames::WebFrames::start(n, !query_flag("nogpuworkers")))));
|
||||
}
|
||||
app.frames.set_virtual_media(files::STORE.media_epoch(), Arc::new(|path| files::STORE.file_identity(path)));
|
||||
app.frames.set_virtual_id(Arc::new(|path| files::STORE.file_identity(path)));
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
api::set_sender(tx);
|
||||
app = app.with_control(rx);
|
||||
|
||||
@@ -4,23 +4,28 @@
|
||||
//!
|
||||
//! - **Writes** happen in the frame workers: a render request carries the frame's disk-cache key
|
||||
//! (`effectcraft_engine::remote::FrameMsg::Render`), the worker posts the frame, then writes
|
||||
//! the sealed entry (`disk_cache::frame_entry`: LZ4, checksum) with a synchronous access
|
||||
//! handle into a temporary file moved into place (`js/host.js` `cacheWrite`), and reports
|
||||
//! `FrameReply::Stored`.
|
||||
//! the sealed entry (`disk_cache::frame_entry`: the source files the frame read, LZ4,
|
||||
//! checksum) with a synchronous access handle into a temporary file moved into place
|
||||
//! (`js/host.js` `cacheWrite`), and reports `FrameReply::Stored`. A frame whose files can't
|
||||
//! all be named is not stored.
|
||||
//! - **The index** lives here, on the page ([`DiskIndex`], shared with the desktop's cache):
|
||||
//! rebuilt at startup from the folder listing (oldest files first), updated by the workers'
|
||||
//! `Stored` replies and by reads; eviction deletes the least recently used files.
|
||||
//! - **Reads** are asynchronous: a frame whose key is indexed is read and decoded here instead
|
||||
//! of rendering it (`crate::frames`); a damaged or missing file is a miss and is forgotten.
|
||||
//! of rendering it (`crate::frames`), if the files its entry lists still have the size and
|
||||
//! content hash it was made of (the file table's identities); a damaged or missing entry, or
|
||||
//! one whose files changed, is a miss and is deleted.
|
||||
//!
|
||||
//! - **Layer buffers** too (`layers/<32 hex>.ecc`): a layer lookup happens in the middle of a
|
||||
//! synchronous render, where the browser's file system can't be read, so the frame workers
|
||||
//! back their layer caches with a `PrefetchStore`. They report each frame's layer-cache
|
||||
//! misses; before the next request the page names the recent misses that are on disk
|
||||
//! ([`layer_prefetch`], `disk_cache::LayerPrefetch`), and the worker reads those entries
|
||||
//! into memory before rendering, so the lookups find them. Slow buffers are written by the
|
||||
//! worker after its frame and indexed here like frames (one least-recently-used order for
|
||||
//! both kinds).
|
||||
//! into memory before rendering, so the lookups find them; an entry is used only while the
|
||||
//! files it lists (the file table's size and content hash) are unchanged, and a buffer whose
|
||||
//! files can't all be named is not written (#3184). Slow buffers are written by the worker
|
||||
//! after its frame and indexed here like frames (one least-recently-used order for both
|
||||
//! kinds).
|
||||
|
||||
use std::cell::RefCell;
|
||||
|
||||
@@ -187,11 +192,12 @@ pub fn note_miss() {
|
||||
}
|
||||
|
||||
/// Read and decode a cached frame; `None` (forgotten and deleted) when the file is missing or
|
||||
/// damaged.
|
||||
/// damaged, or the source files it was made of changed since.
|
||||
pub async fn read(key: u128) -> Option<disk_cache::Frame8> {
|
||||
let name = disk_cache::entry_name(key);
|
||||
let bytes = JsFuture::from(cache_read(&name)).await.ok().and_then(|b| b.dyn_into::<js_sys::Uint8Array>().ok()).map(|b| b.to_vec());
|
||||
let frame = bytes.as_deref().and_then(disk_cache::read_frame_entry);
|
||||
let identity = |path: &str| crate::files::STORE.file_identity(path);
|
||||
let frame = bytes.as_deref().and_then(|b| disk_cache::read_frame_entry(b, Some(&identity)));
|
||||
ST.with(|s| {
|
||||
let mut s = s.borrow_mut();
|
||||
if frame.is_some() {
|
||||
|
||||
@@ -473,10 +473,13 @@ pub async fn worker_frame(json: String) {
|
||||
g.settled().await;
|
||||
replies = SERVER.with(|c| c.borrow_mut().as_mut().map(|s| s.resume()).unwrap_or_default());
|
||||
}
|
||||
// The source files the frame read, kept with its entry (checked by the page before use); a
|
||||
// frame whose files can't all be named is not stored (#3184).
|
||||
let (files, virtual_id) = SERVER.with(|c| c.borrow_mut().as_mut().map(|s| (s.take_files(), s.virtual_id().cloned())).unwrap_or_default());
|
||||
for (r, px) in replies {
|
||||
post_reply(&r, px.as_deref());
|
||||
if let (FrameReply::Frame { width, height, .. }, Some(px), Some(key)) = (&r, &px, &disk) {
|
||||
let entry = effectcraft_engine::render::disk_cache::frame_entry(*width, *height, px);
|
||||
if let (FrameReply::Frame { width, height, .. }, Some(px), Some(key), Some(files)) = (&r, &px, &disk, &files) {
|
||||
let Some(entry) = effectcraft_engine::render::disk_cache::frame_entry(*width, *height, px, files, virtual_id.as_deref()) else { continue };
|
||||
let name = format!("{key}.ecc");
|
||||
match JsFuture::from(crate::diskcache::cache_write(&name, js_sys::Uint8Array::from(&entry[..]))).await {
|
||||
Ok(n) => post_reply(&FrameReply::Stored { key: key.clone(), bytes: n.as_f64().unwrap_or(entry.len() as f64) as u64, layer: false }, None),
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use effectcraft_engine::config::{ConfigStore, FileOps};
|
||||
@@ -187,13 +186,12 @@ pub struct Store {
|
||||
pub on_change: OnceLock<fn()>,
|
||||
/// Clock in milliseconds since the Unix epoch (tests pin it).
|
||||
pub clock: OnceLock<fn() -> f64>,
|
||||
/// Bumped when a virtual file is written. Preview cache lookups key on it; they do not hash.
|
||||
media_epoch: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
impl Default for Store {
|
||||
/// An empty store with no mirror contents, change callback or clock.
|
||||
fn default() -> Self {
|
||||
Self { mirror: Mutex::default(), on_change: OnceLock::new(), clock: OnceLock::new(), media_epoch: Arc::new(AtomicU64::new(0)) }
|
||||
Self { mirror: Mutex::default(), on_change: OnceLock::new(), clock: OnceLock::new() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,22 +216,12 @@ impl Store {
|
||||
pub fn put_file(&self, path: &str, data: Arc<[u8]>, persist: bool) {
|
||||
let now = self.now();
|
||||
let key = file_key(path);
|
||||
let replaced = self.lock().get(&key).is_none_or(|e| !Arc::ptr_eq(&e.data, &data));
|
||||
self.lock().put(&key, data, now, persist);
|
||||
if replaced {
|
||||
self.media_epoch.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
if persist {
|
||||
self.changed();
|
||||
}
|
||||
}
|
||||
|
||||
/// The generation preview cache lookups key on. It changes when [`Self::put_file`] writes
|
||||
/// a different blob, and it is not a content hash.
|
||||
pub fn media_epoch(&self) -> Arc<AtomicU64> {
|
||||
self.media_epoch.clone()
|
||||
}
|
||||
|
||||
/// Size and content hash of the file under `path`. The hash is stored on the entry so a
|
||||
/// later key does not walk the bytes again.
|
||||
pub fn file_identity(&self, path: &str) -> Option<(u64, u64)> {
|
||||
@@ -541,11 +529,9 @@ mod tests {
|
||||
assert_ne!(a, b);
|
||||
assert_eq!(&data[..], b"blue");
|
||||
assert_eq!(s.file_versioned("/collision.png").map(|f| f.1), Some(b), "reading doesn't change it");
|
||||
let epoch = s.media_epoch().load(Ordering::Relaxed);
|
||||
let id = s.file_identity("/collision.png").expect("identity");
|
||||
assert_eq!(id, (4, content_hash(b"blue")));
|
||||
assert_eq!(s.file_identity("/collision.png"), Some(id), "the hash is kept on the entry");
|
||||
assert!(epoch >= 2, "each new blob bumps the generation the preview keys on");
|
||||
}
|
||||
|
||||
/// A write that failed is flushed again with the key's latest state (#215).
|
||||
|
||||
@@ -276,9 +276,6 @@ fn main() -> eframe::Result {
|
||||
// written on background threads (M13.14).
|
||||
session.check_footage_on_open = true;
|
||||
session.autosave.background = true;
|
||||
// The disk cache's footage stamps too: an imported or opened project with thousands of
|
||||
// sequence frames on a network share took half a minute to stamp.
|
||||
session.disk_salt_background = true;
|
||||
// Settings, shortcut presets and the crash-recovery sentinel live in the platform
|
||||
// config directory. Agent-driven runs (`--control`) skip crash recovery.
|
||||
if let Some(dir) = effectcraft_host::config_dir() {
|
||||
|
||||
@@ -69,13 +69,19 @@ tests reach them only through `tests/it/common/mod.rs`, which hands a test its c
|
||||
of its own cut from the combined file; a test whose fixture is missing (or with the variable not
|
||||
set) prints `SKIPPED: fixture <name> missing` and passes.
|
||||
|
||||
`tests/fixtures/synthetic.aep` (115 KB) is the one project in the repository: made in After Effects 2026
|
||||
`tests/fixtures/synthetic.aep` (115 KB) is one of the two projects in the repository: made in After Effects 2026
|
||||
with the working space set to None (one composition, an image sequence of three 8x8 PNGs, a
|
||||
solid, a folder, a Levels and a Glow effect with expressions). Every After Effects project embeds
|
||||
the working space's ICC profile (HP sRGB IEC61966-2.1 for the default) in a JSON settings chunk;
|
||||
the one `Utf8` chunk of this file that held a profile was replaced by `{}`, and nothing else was
|
||||
changed. The tests in `tests/it/import/synthetic.rs` damage copies of it and need no other file.
|
||||
|
||||
`tests/fixtures/ai-mask-3099.aep` (131 KB, #3099) is the second: four hand-written one-page PDFs
|
||||
named `.ai` with distinct page boxes, placed in a composition, one of them with two masks under a
|
||||
Null parented to a Null (generated by `tests/fixtures/tools/ai-mask-3099.py` and
|
||||
`ai-mask-3099.jsx`, scrubbed with `scrub.py` and `stubicc`). `tests/it/import/ai_mask.rs` checks
|
||||
the import against After Effects' read-back, and the host's `vector_import` tests render it.
|
||||
|
||||
## Tools
|
||||
|
||||
- `tests/fixtures/tools/`: the generator of the test projects (regenerates the corpus with After
|
||||
|
||||
@@ -37,7 +37,9 @@ mod version;
|
||||
mod write;
|
||||
|
||||
pub use aepx::{is_aepx, parse_any, read_aepx, read_aepx_with_limits};
|
||||
pub use arbitrary::{CURVE_IDENTITY, CryptomatteData, CurvesData, HueSaturationData, LevelsData, MeshData, curve_lut};
|
||||
pub use arbitrary::{
|
||||
CURVE_IDENTITY, CryptomatteData, CurvesData, ExtractorChannels, HueSaturationData, LevelsData, MeshData, curve_lut, identifier_channel_type,
|
||||
};
|
||||
pub use camera::{CameraPosition, DEFAULT_CAMERA_ZOOM};
|
||||
pub use chunk::{AepFile, Body, Chunk, LIST, NESTED_IDS, Walk};
|
||||
pub use effects::{DefinitionSource, Effect, EffectDefinitions, EffectParam, LayerRef, ParamKind, ParamValue};
|
||||
|
||||
@@ -308,6 +308,82 @@ impl MeshData {
|
||||
}
|
||||
}
|
||||
|
||||
/// EXtractoR's Channel Info data (`EXtractoR-0013`, 688 bytes, the plug-in's own little-endian
|
||||
/// layout; read from the instances of the importer block of 2026-11-05: the defaults and a preset
|
||||
/// of our own that reads `Z` into Red, Green and Blue): a `u32` 1 and 28 bytes, then four records
|
||||
/// of 164 bytes (Red, Green, Blue, Alpha), each a kind in byte 0 (1 a channel of the footage by
|
||||
/// name, 2 the input's own channel: `(copy)`; bytes 1..4 are not always zero, `0x32` at 3 in one
|
||||
/// definition's default, so only byte 0 is read) and the channel name from byte 35,
|
||||
/// NUL-terminated. The other bytes are not read (they differ between instances with the same
|
||||
/// channels; the `u32` at 4 of a chosen channel looks like its position in the footage's sorted
|
||||
/// channel list, 13 of 14 for `Z`, where the defaults hold 0). Only the name is read.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ExtractorChannels {
|
||||
/// Per output channel (red, green, blue, alpha): the kind and the name.
|
||||
pub channels: [(u32, String); 4],
|
||||
}
|
||||
|
||||
impl ExtractorChannels {
|
||||
/// The data's length.
|
||||
pub const LEN: usize = 688;
|
||||
const RECORD: usize = 164;
|
||||
|
||||
/// `None` when the data does not have this layout.
|
||||
pub fn parse(data: &[u8]) -> Option<Self> {
|
||||
if data.len() != Self::LEN || data.get(..4)? != 1u32.to_le_bytes() {
|
||||
return None;
|
||||
}
|
||||
let mut channels: [(u32, String); 4] = Default::default();
|
||||
for (k, slot) in channels.iter_mut().enumerate() {
|
||||
let at = 32usize.checked_add(k.checked_mul(Self::RECORD)?)?;
|
||||
let rec = data.get(at..at.checked_add(Self::RECORD)?)?;
|
||||
let kind = u32::from(*rec.first()?);
|
||||
let name = rec.get(35..)?;
|
||||
let end = name.iter().position(|b| *b == 0)?;
|
||||
let name = std::str::from_utf8(name.get(..end)?).ok()?;
|
||||
*slot = (kind, name.to_string());
|
||||
}
|
||||
Some(Self { channels })
|
||||
}
|
||||
|
||||
/// The channels of a fresh EXtractoR: Red, Green and Blue from the channels of those names,
|
||||
/// Alpha copied from the input.
|
||||
pub fn is_default(&self) -> bool {
|
||||
let want = [(1, "R"), (1, "G"), (1, "B"), (2, "(copy)")];
|
||||
self.channels.iter().zip(want).all(|((k, n), (wk, wn))| *k == wk && n == wn)
|
||||
}
|
||||
|
||||
/// Per output channel (red, green, blue, alpha) the channel it reads, `Err` with the
|
||||
/// position of the first record of another kind or without a name: a named channel by its
|
||||
/// name, `(copy)` as EffectCraft's [`EXTRACTOR_COPY`](effectcraft_effects::EXTRACTOR_COPY),
|
||||
/// the effect input's own channel of the same position. It is not `R`, `G`, `B` or `A`:
|
||||
/// EffectCraft's EXtractoR reads those from an OpenEXR footage's channels of that name, the
|
||||
/// file's own, which loses what an earlier effect did to the channel.
|
||||
pub fn sources(&self) -> Result<[String; 4], usize> {
|
||||
let mut out: [String; 4] = Default::default();
|
||||
for (k, ((kind, name), slot)) in self.channels.iter().zip(out.iter_mut()).enumerate() {
|
||||
*slot = match (kind, name.trim()) {
|
||||
(1, n) if !n.is_empty() => n.to_string(),
|
||||
(2, _) => effectcraft_effects::EXTRACTOR_COPY.to_string(),
|
||||
_ => return Err(k),
|
||||
};
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// IDentifier's Channel Info data (`IDentifier-0005`, 148 bytes): the chosen channel's name,
|
||||
/// NUL-terminated from byte 19, or `Object ID` when none is chosen (importer blocks 1300 and 1310
|
||||
/// of 2026-11-05: `objectId` in a preset of our own that chose that UINT channel, `Object ID` in
|
||||
/// every other instance). The bytes around it differ between instances with the same settings
|
||||
/// and are not read (a chosen channel has 15 at byte 4, which looks like its position in the
|
||||
/// EXR's sorted channel list, as for EXtractoR).
|
||||
pub fn identifier_channel_type(data: &[u8]) -> Option<&str> {
|
||||
let s = data.get(19..)?;
|
||||
let end = s.iter().position(|b| *b == 0)?;
|
||||
std::str::from_utf8(s.get(..end)?).ok()
|
||||
}
|
||||
|
||||
/// The Cryptomatte plug-in's Settings data (`Cryptomatte-0001`, the plug-in's own layout,
|
||||
/// little-endian; read from 88 instances of one project and from our own sample 1400, fresh
|
||||
/// instances and two presets that pick a layer and an object, docs/aep-format.md 3.7): the magic
|
||||
|
||||
@@ -141,6 +141,13 @@ pub enum Extra {
|
||||
Curves,
|
||||
/// Mesh Warp's Distortion Mesh (965, 985, `MeshData`).
|
||||
MeshWarp,
|
||||
/// Lumetri Color: every parameter goes through its own table, keyed by match name
|
||||
/// (`import/lumetri.rs`, 1200).
|
||||
Lumetri,
|
||||
/// EXtractoR's Channel Info (`ExtractorChannels`): the channels by name.
|
||||
Extractor,
|
||||
/// IDentifier's Channel Info (`identifier_channel_type`): the chosen channel by name.
|
||||
Identifier,
|
||||
/// The Cryptomatte plug-in: the layer and selection in its Settings data
|
||||
/// (`CryptomatteData`), Output split over EffectCraft's Display and Matte Only; every
|
||||
/// parameter is handled by the importer's Cryptomatte step, none by the table.
|
||||
@@ -361,6 +368,8 @@ pub const SHUTTER_CONTROL: &[&str] = &["Manual", "Automatic"];
|
||||
pub const ECHO_OPERATOR: &[&str] = &["Add", "Maximum", "Minimum", "Screen", "Composite In Back", "Composite In Front", "Blend"];
|
||||
/// Color Stabilizer's Stabilize (`colstab-02`, `colstab-03`; the fresh default is Levels).
|
||||
pub const STABILIZE: &[&str] = &["Brightness", "Levels", "Curves"];
|
||||
/// IDentifier's Display (`identifier-04` ... `identifier-06`, `1300-thirdparty.aep`).
|
||||
pub const IDENTIFIER_DISPLAY: &[&str] = &["Colors", "Luma Matte", "Alpha Matte", "Raw"];
|
||||
|
||||
/// The effects the importer converts.
|
||||
pub const EFFECTS: &[EffectMap] = &[
|
||||
@@ -532,6 +541,9 @@ pub const EFFECTS: &[EffectMap] = &[
|
||||
],
|
||||
extra: Extra::MeshWarp,
|
||||
},
|
||||
// Lumetri Color (1200): the parameters are mapped by match name, not by `parT` index
|
||||
// (`import/lumetri.rs`).
|
||||
EffectMap { match_name: "ADBE Lumetri", effect: "ec.color.lumetri", params: &[], extra: Extra::Lumetri },
|
||||
// The Cryptomatte plug-in (docs/aep-format.md 3.7): 1 Settings data, 2 Output, 3 Selection
|
||||
// Mode, imported by the Cryptomatte step (`Extra::Cryptomatte`).
|
||||
EffectMap { match_name: "Cryptomatte", effect: "ec.3d.cryptomatte", params: &[], extra: Extra::Cryptomatte },
|
||||
@@ -1076,6 +1088,40 @@ pub const EFFECTS: &[EffectMap] = &[
|
||||
pm(5, K::FixedSlider, "sampleSize", Conv::Plain { valid_max: 50.0 }),
|
||||
],
|
||||
),
|
||||
// CC Color Neutralizer (importer block 2026-11-05, `ccneutral-*`): Pinning and Blend w.
|
||||
// Original are float sliders in percent (valid 0..100). After Effects' correction is the Red /
|
||||
// Green / Blue slider of each range (3-5, 9-11, 15-17; the Unbalance colours are pickers that
|
||||
// set them and keep no record), then View (22) and Black / White Point (23, 24):
|
||||
// EffectCraft's CC Color Neutralizer has none of them, so they are reported when stored.
|
||||
fx(
|
||||
"CS Color Neutralizer",
|
||||
"ec.color.cccolorneutralizer",
|
||||
&[pm(19, K::FloatSlider, "pinning", Conv::Plain { valid_max: 100.0 }), pm(20, K::FloatSlider, "blendWOriginal", Conv::Plain { valid_max: 100.0 })],
|
||||
),
|
||||
// EXtractoR (`extractor-*`): 1 Channel Info (arbitrary data, `Extra::Extractor`), 2 / 6 the
|
||||
// Process group, Black and White Point float sliders (valid ±3.4e38, the float32 range),
|
||||
// UnMult. EffectCraft's Clip has no counterpart and stays off.
|
||||
EffectMap {
|
||||
match_name: "EXtractoR",
|
||||
effect: "ec.3d.extractor",
|
||||
params: &[
|
||||
pm(3, K::FloatSlider, "blackPoint", Conv::Plain { valid_max: F32_MAX }),
|
||||
pm(4, K::FloatSlider, "whitePoint", Conv::Plain { valid_max: F32_MAX }),
|
||||
pm(5, K::Checkbox, "unMult", Conv::Checkbox),
|
||||
],
|
||||
extra: Extra::Extractor,
|
||||
},
|
||||
// IDentifier (`identifier-*`): 1 Channel Info (arbitrary data, `Extra::Identifier`), 2 Display,
|
||||
// 3 ID an integer slider (valid 0..2 147 483 647).
|
||||
EffectMap {
|
||||
match_name: "IDentifier",
|
||||
effect: "ec.3d.identifier",
|
||||
params: &[
|
||||
pm(2, K::Popup, "display", Conv::Popup { default: 1, items: IDENTIFIER_DISPLAY }),
|
||||
pm(3, K::IntSlider, "id", Conv::Plain { valid_max: 2_147_483_647.0 }),
|
||||
],
|
||||
extra: Extra::Identifier,
|
||||
},
|
||||
// Layer Control (076, 220, 320, 321): the default is the owning layer, None is unset.
|
||||
fx("ADBE Layer Control", "ec.control.layer", &[pm(1, K::Layer, "layer", Conv::LayerRef { own: true })]),
|
||||
fx("ADBE Slider Control", "ec.control.slider", &[pm(1, K::FloatSlider, "slider", Conv::Plain { valid_max: 1_000_000.0 })]),
|
||||
@@ -1089,6 +1135,8 @@ pub const EFFECTS: &[EffectMap] = &[
|
||||
pub const EXPOSURE: Conv = Conv::Plain { valid_max: 100.0 };
|
||||
pub const OFFSET: Conv = Conv::Plain { valid_max: 2.0 };
|
||||
pub const GAMMA: Conv = Conv::Plain { valid_max: 10.0 };
|
||||
/// The largest float32, the valid maximum of EXtractoR's Black and White Point.
|
||||
pub const F32_MAX: f64 = f32::MAX as f64;
|
||||
/// The valid maximum of Glow's A & B Midpoint (16.16 of 0.99, 427).
|
||||
pub const GLOW_MIDPOINT_MAX: f64 = 0.990_005_493_164_062_5;
|
||||
|
||||
|
||||
@@ -12,6 +12,11 @@
|
||||
//! project-level `LIST EfdG` (one `EfDf` per effect type: a `tdmn` with the match name, then a copy
|
||||
//! of one instance's `sspc`), see [`Project::effect_params`]. That copy also carries the values of
|
||||
//! the instance it was copied from, so values are only ever read from the instance's own `tdgp`.
|
||||
//!
|
||||
//! Some files have no `EfdG` at all (seen in 17 projects, all from inside After Effects-made
|
||||
//! Motion Graphics templates). In those, one instance per effect type kept its full `parT` (which
|
||||
//! one is not known) and it is the only place the definitions are written, so the other instances
|
||||
//! of that type borrow them from it (#3170).
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
@@ -138,7 +143,11 @@ pub enum DefinitionSource {
|
||||
/// The project-level `EfdG/EfDf` copy for the effect's match name (the instance's own `parT`
|
||||
/// is empty, 383).
|
||||
Project,
|
||||
/// Neither has any: the effect has no readable parameter definitions.
|
||||
/// The own `parT` of another instance of the same match name in the file: the instance's
|
||||
/// own `parT` is empty and the file has no project-level copy of the type (a project without
|
||||
/// `EfdG`, #3170).
|
||||
OtherInstance,
|
||||
/// None of these has any: the effect has no readable parameter definitions.
|
||||
Missing,
|
||||
}
|
||||
|
||||
@@ -155,7 +164,8 @@ pub struct EffectParam<'f, 'a> {
|
||||
pub name: String,
|
||||
/// The popup items (`pdnm`, split at `|`; for a button its label, e.g. `Swap Colors`).
|
||||
pub items: Option<Vec<String>>,
|
||||
/// True when the definition is the project-level copy (see [`DefinitionSource::Project`]).
|
||||
/// True when the definition is borrowed: the project-level copy ([`DefinitionSource::Project`])
|
||||
/// or another instance's ([`DefinitionSource::OtherInstance`]).
|
||||
pub borrowed_definition: bool,
|
||||
pard: &'f [u8],
|
||||
stored: Option<Property<'f, 'a>>,
|
||||
@@ -214,6 +224,22 @@ impl<'f, 'a> Effect<'f, 'a> {
|
||||
self.chunk.child_list(b"parT").is_some_and(has_pard)
|
||||
}
|
||||
|
||||
/// The match names of the value records in the instance's own `tdgp` (properties only, not
|
||||
/// groups), in file order, each once.
|
||||
pub fn value_record_names(&self) -> Vec<String> {
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
self.chunk
|
||||
.child_list(b"tdgp")
|
||||
.and_then(PropGroup::from_chunk)
|
||||
.map(|g| g.entries())
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|e| matches!(e.node, PropNode::Property(_)))
|
||||
.map(|e| e.match_name)
|
||||
.filter(|n| seen.insert(n.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The parameters in the order of the instance's own `parT`. Empty for an instance whose
|
||||
/// `parT` is empty; [`Project::effect_params`] falls back to the project's definitions.
|
||||
pub fn params(&self) -> Vec<EffectParam<'f, 'a>> {
|
||||
@@ -299,8 +325,25 @@ impl<'f, 'a> Project<'f, 'a> {
|
||||
|
||||
/// [`Self::effect_definitions`] indexed by match name, for looking up many effects: build it
|
||||
/// once per file, not once per effect (a file can hold many definitions and many instances).
|
||||
/// For a type without a project-level copy it also keeps the own `parT` of the first instance
|
||||
/// in the file (items, layers and effects in file order) that has one.
|
||||
pub fn effect_definition_index(&self) -> EffectDefinitions<'f, 'a> {
|
||||
EffectDefinitions { by_name: self.effect_definitions().into_iter().collect() }
|
||||
let by_name: HashMap<String, &'f Chunk<'a>> = self.effect_definitions().into_iter().collect();
|
||||
let mut instances: HashMap<String, &'f Chunk<'a>> = HashMap::new();
|
||||
for item in self.all_items() {
|
||||
let Some(comp) = item.comp() else { continue };
|
||||
for layer in comp.layers() {
|
||||
for fx in layer.effects() {
|
||||
if by_name.contains_key(&fx.match_name) || instances.contains_key(&fx.match_name) {
|
||||
continue;
|
||||
}
|
||||
if let Some(part) = fx.chunk.child_list(b"parT").filter(|p| has_pard(p)) {
|
||||
instances.insert(fx.match_name, part);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
EffectDefinitions { by_name, instances }
|
||||
}
|
||||
|
||||
/// Where the parameter definitions of `effect` come from. Builds the index each call: use
|
||||
@@ -316,10 +359,13 @@ impl<'f, 'a> Project<'f, 'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The project-level effect definitions by match name ([`Project::effect_definition_index`]).
|
||||
/// The borrowable effect definitions by match name ([`Project::effect_definition_index`]).
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct EffectDefinitions<'f, 'a> {
|
||||
/// The project-level copies (`EfdG`).
|
||||
by_name: HashMap<String, &'f Chunk<'a>>,
|
||||
/// For a type without a project-level copy: the own `parT` of the first instance that has one.
|
||||
instances: HashMap<String, &'f Chunk<'a>>,
|
||||
}
|
||||
|
||||
impl<'f, 'a> EffectDefinitions<'f, 'a> {
|
||||
@@ -329,20 +375,23 @@ impl<'f, 'a> EffectDefinitions<'f, 'a> {
|
||||
DefinitionSource::Instance
|
||||
} else if self.by_name.contains_key(effect.match_name()) {
|
||||
DefinitionSource::Project
|
||||
} else if self.instances.contains_key(effect.match_name()) {
|
||||
DefinitionSource::OtherInstance
|
||||
} else {
|
||||
DefinitionSource::Missing
|
||||
}
|
||||
}
|
||||
|
||||
/// The parameters of `effect`: from its own `parT`, or, when that is empty, from the
|
||||
/// project-level definition of its match name ([`EffectParam::borrowed_definition`]). Values
|
||||
/// always come from the instance's own records (absent = the `pard` default); the project
|
||||
/// copy's values belong to another instance and are never read.
|
||||
/// project-level definition of its match name, else from another instance of it that has
|
||||
/// its own ([`EffectParam::borrowed_definition`]). Values always come from the instance's own
|
||||
/// records (absent = the `pard` default); the values next to borrowed definitions belong to
|
||||
/// another instance and are never read.
|
||||
pub fn params(&self, effect: &Effect<'f, 'a>) -> Vec<EffectParam<'f, 'a>> {
|
||||
if effect.has_own_definitions() {
|
||||
return effect.params();
|
||||
}
|
||||
match self.by_name.get(effect.match_name()) {
|
||||
match self.by_name.get(effect.match_name()).or_else(|| self.instances.get(effect.match_name())) {
|
||||
Some(part) => effect.params_from(part, true),
|
||||
None => Vec::new(),
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! of a layer's source.
|
||||
|
||||
use crate::container::bytes::{be_f32, be_f64, be_u32, cstr, le_u32, text};
|
||||
use crate::container::chunk::Chunk;
|
||||
use crate::container::header::Rational;
|
||||
use crate::container::typed::{Item, ItemKind, Layer, Project};
|
||||
|
||||
@@ -159,19 +160,25 @@ fn json_string_at(json: &[u8], mut i: usize) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The numbering of an image sequence item (353, 603).
|
||||
/// The numbering of an image sequence item (353, 603), or its file list when it was imported with
|
||||
/// Force alphabetical order.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SequenceInfo {
|
||||
/// The file name before the frame number (`seq_`, `shot_`): the first `Utf8` after the alias.
|
||||
/// An empty prefix is not stored (`0001.png`): the one `Utf8` is then the suffix.
|
||||
pub prefix: String,
|
||||
/// The file name after the frame number (`.png`, `.exr`): the second `Utf8` after the alias.
|
||||
pub suffix: String,
|
||||
/// The first and last frame number (`sspc` `u32` at 172 and 176: 1 and 3 in 353, 101 and 103
|
||||
/// in 603).
|
||||
/// in 603; both `0xFFFFFFFF` for a sequence with a file list).
|
||||
pub start: u32,
|
||||
pub end: u32,
|
||||
/// The digits of the frame number (`sspc` `u32` at 180: 3 for `seq_001`, 4 for `shot_0101`).
|
||||
pub digits: u32,
|
||||
/// The file names in order for a sequence imported with Force alphabetical order (`LIST StVc`
|
||||
/// after the alias: `StVS` with the count as a little-endian `u32`, then one `Utf8` per file);
|
||||
/// empty for a numbered sequence. When set, it replaces the numbering.
|
||||
pub files: Vec<String>,
|
||||
}
|
||||
|
||||
impl SequenceInfo {
|
||||
@@ -180,6 +187,62 @@ impl SequenceInfo {
|
||||
let width = usize::try_from(self.digits.min(16)).unwrap_or(0);
|
||||
format!("{}{n:0width$}{}", self.prefix, self.suffix)
|
||||
}
|
||||
|
||||
/// The number of files: the listed files, or the frame numbers from start to end (0 when the
|
||||
/// end lies before the start).
|
||||
pub fn frame_count(&self) -> u32 {
|
||||
if self.files.is_empty() {
|
||||
self.end.checked_sub(self.start).map_or(0, |d| d.saturating_add(1))
|
||||
} else {
|
||||
u32::try_from(self.files.len()).unwrap_or(u32::MAX)
|
||||
}
|
||||
}
|
||||
|
||||
/// The name of the `i`-th file (from 0): from the file list, or numbered from `start`; `None`
|
||||
/// past the last file.
|
||||
pub fn nth_file_name(&self, i: u32) -> Option<String> {
|
||||
if self.files.is_empty() {
|
||||
let n = self.start.checked_add(i).filter(|n| *n <= self.end)?;
|
||||
Some(self.file_name(n))
|
||||
} else {
|
||||
self.files.get(usize::try_from(i).ok()?).cloned()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The file names of a `LIST StVc` (Force alphabetical order): the `Utf8` children in order, at most
|
||||
/// as many as the count in `StVS` (little-endian `u32`) says. Empty names count toward that number
|
||||
/// and are then skipped. Without a readable count (no `StVS`, or one shorter than 4 bytes) the list
|
||||
/// is unreadable (empty). A name that is not a plain file name (holding `/`, `\`, `:` or a control
|
||||
/// character such as NUL, or being `.` or `..`) makes the whole list unreadable (empty): the names
|
||||
/// are joined to the folder.
|
||||
fn sequence_file_list(list: &Chunk<'_>) -> Vec<String> {
|
||||
// After Effects writes the count first (the one sample); a list without a readable count is
|
||||
// not read rather than guessed.
|
||||
let Some(count) = list.child(b"StVS").and_then(|c| c.bytes()).and_then(|b| le_u32(b, 0)).and_then(|n| usize::try_from(n).ok()) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let names: Vec<String> =
|
||||
list.children().iter().filter(|c| c.is(b"Utf8")).filter_map(|c| c.bytes()).take(count).map(text).filter(|n| !n.is_empty()).collect();
|
||||
if names.iter().any(|n| n.contains(|c: char| matches!(c, '/' | '\\' | ':') || c.is_control()) || n == "." || n == "..") {
|
||||
return Vec::new();
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
/// The prefix and suffix of a sequence whose naming holds one `Utf8` (`part`): After Effects leaves
|
||||
/// out an empty prefix (`0001.png` stores only `.png`). The stored first file name (`opti`) decides:
|
||||
/// a name starting with `part` has it as the prefix, one ending with it as the suffix. Without that
|
||||
/// evidence, a `part` starting with `.` is the suffix (an extension); anything else is not read.
|
||||
fn single_name_part(part: &str, first_file: Option<&str>) -> Option<(String, String)> {
|
||||
if let Some(first) = first_file.filter(|_| !part.is_empty()) {
|
||||
match (first.starts_with(part), first.ends_with(part)) {
|
||||
(true, false) => return Some((part.to_string(), String::new())),
|
||||
(false, true) => return Some((String::new(), part.to_string())),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
part.starts_with('.').then(|| (String::new(), part.to_string()))
|
||||
}
|
||||
|
||||
impl<'f, 'a> Item<'f, 'a> {
|
||||
@@ -194,10 +257,24 @@ impl<'f, 'a> Item<'f, 'a> {
|
||||
let sspc = pin.child(b"sspc")?.bytes()?;
|
||||
let kids = pin.children();
|
||||
let after = kids.iter().position(|c| c.is_list(b"Als2"))?;
|
||||
let mut names = kids.iter().skip(after + 1).take_while(|c| !c.is(b"opti")).filter(|c| c.is(b"Utf8")).filter_map(|c| c.bytes()).map(text);
|
||||
let prefix = names.next()?;
|
||||
let suffix = names.next()?;
|
||||
Some(SequenceInfo { prefix, suffix, start: be_u32(sspc, 172)?, end: be_u32(sspc, 176)?, digits: be_u32(sspc, 180)? })
|
||||
let naming = || kids.iter().skip(after.saturating_add(1)).take_while(|c| !c.is(b"opti"));
|
||||
let (start, end, digits) = (be_u32(sspc, 172)?, be_u32(sspc, 176)?, be_u32(sspc, 180)?);
|
||||
// Force alphabetical order: the file list replaces the numbering.
|
||||
if let Some(list) = naming().find(|c| c.is_list(b"StVc")) {
|
||||
let files = sequence_file_list(list);
|
||||
if files.is_empty() {
|
||||
return None;
|
||||
}
|
||||
return Some(SequenceInfo { prefix: String::new(), suffix: String::new(), start, end, digits, files });
|
||||
}
|
||||
let names: Vec<String> = naming().filter(|c| c.is(b"Utf8")).filter_map(|c| c.bytes()).map(text).take(2).collect();
|
||||
let (prefix, suffix) = match names.as_slice() {
|
||||
[prefix, suffix] => (prefix.clone(), suffix.clone()),
|
||||
// An empty prefix is not stored (`0001.png` has only `.png`).
|
||||
[one] => single_name_part(one, f.name.as_deref())?,
|
||||
_ => return None,
|
||||
};
|
||||
Some(SequenceInfo { prefix, suffix, start, end, digits, files: Vec::new() })
|
||||
}
|
||||
|
||||
/// The rate a clip is conformed to (Interpret Footage ▸ Conform to frame rate; `sspc` `u32` at
|
||||
|
||||
@@ -461,7 +461,7 @@ fn plan_layer_param(layer: &Layer, g: &PropGroup, target: &str, own: bool, prop:
|
||||
fn plan_extra(spec: &EffectMap, g: &PropGroup, pdefs: &HashMap<u16, ParDef>, here: &str, col: &mut Collector) -> Option<(u16, Vec<u8>)> {
|
||||
let ids: &[&str] = match spec.extra {
|
||||
// (the Cryptomatte plug-in's settings are its own binary layout: not written, reported below)
|
||||
Extra::None | Extra::Curves | Extra::MeshWarp | Extra::Cryptomatte => return None,
|
||||
Extra::None | Extra::Curves | Extra::MeshWarp | Extra::Lumetri | Extra::Cryptomatte | Extra::Extractor | Extra::Identifier => return None,
|
||||
Extra::HueSaturation => &HUE_SATURATION_MASTER,
|
||||
Extra::Levels => &LEVELS_RGB,
|
||||
};
|
||||
@@ -643,7 +643,9 @@ fn report_unmapped(g: &PropGroup, (effect, spec): (&str, &EffectMap), size: (f64
|
||||
// effect was added at is not known here.
|
||||
let (fresh, other) = (fresh([size.0, size.1], &mut next), fresh([size.0 * 2.0 + 7.0, size.1 * 3.0 + 5.0], &mut next));
|
||||
let extra: &[&str] = match spec.extra {
|
||||
Extra::None | Extra::Cryptomatte => &[],
|
||||
// Lumetri Color, EXtractoR and IDentifier have no prototype to write from, so they never
|
||||
// get this far (they are reported).
|
||||
Extra::None | Extra::Lumetri | Extra::Cryptomatte | Extra::Extractor | Extra::Identifier => &[],
|
||||
Extra::HueSaturation => &HUE_SATURATION_MASTER,
|
||||
Extra::Levels => &LEVELS_RGB,
|
||||
Extra::Curves => &["channel", "rgb", "red", "green", "blue", "alpha"],
|
||||
|
||||
@@ -817,7 +817,7 @@ fn plan_transform(layer: &Layer, src: &LayerSrc, comp: &Comp, source_size: (u32,
|
||||
let cam_light = matches!(src, LayerSrc::Camera(_) | LayerSrc::Light(_));
|
||||
let is_shape = matches!(src, LayerSrc::Shape(_));
|
||||
let mut props = Vec::new();
|
||||
let at = PropAt { comp, comp_name, layer: &layer.name, source: source_size, text: is_text || cam_light || is_shape };
|
||||
let at = PropAt { comp, comp_name, layer: &layer.name, source: source_size, text: is_text || cam_light || is_shape, null: matches!(src, LayerSrc::Null) };
|
||||
if let Some(tr) = layer.transform() {
|
||||
for kind in PropKind::ALL {
|
||||
// A camera's or light's point of interest is After Effects' Anchor Point.
|
||||
@@ -1047,9 +1047,10 @@ fn plan_layer(cx: &PlanCx<'_>, at: CompAt<'_>, cp: &CompPlan, layer: &Layer, col
|
||||
let props = plan_transform(layer, &src, comp, source_size, cp, comp_name, col);
|
||||
let remap = remap_prop.filter(|_| remapped).map(|p| plan_remap(p, comp, cp, &layer.name, comp_name, col));
|
||||
let mut name = cut_name(&layer.name, 255, &what("layer"), col);
|
||||
// A text layer named after its text stores no name (110, 113: After Effects shows the text).
|
||||
// A text layer named after its text stores no name (110, 113: After Effects shows the text):
|
||||
// the layer's own rule decides (#3233).
|
||||
if let LayerSrc::Text(tp) = &src
|
||||
&& tp.text.split('\r').next().is_some_and(|first| first.chars().take(64).collect::<String>() == name)
|
||||
&& layer.name_follows_text(&effectcraft_project::text_layer_name(&tp.text))
|
||||
{
|
||||
name.clear();
|
||||
}
|
||||
@@ -1171,16 +1172,21 @@ struct PropAt<'a> {
|
||||
source: (u32, u32),
|
||||
/// The anchor is stored in pixels (text, camera, light and shape layers).
|
||||
text: bool,
|
||||
/// A null layer: an Anchor Point that is not written is its top-left corner (#3099).
|
||||
null: bool,
|
||||
}
|
||||
|
||||
/// What is written for transform property `p` (`kind`) of a layer: its value or keys in the
|
||||
/// stored unit and its expression; `None` when it is at After Effects' default and has neither.
|
||||
fn plan_prop(kind: PropKind, label: Option<&str>, p: &Property, at: PropAt<'_>, cp: &CompPlan, col: &mut Collector) -> Option<PropPlan> {
|
||||
let PropAt { comp, comp_name, layer, source, text } = at;
|
||||
let PropAt { comp, comp_name, layer, source, text, null } = at;
|
||||
// A text layer's anchor is stored in pixels, with the origin as its default (385).
|
||||
let (sw, sh) = if text { (1.0, 1.0) } else { (f64::from(source.0.max(1)), f64::from(source.1.max(1))) };
|
||||
let what = format!("{comp_name}: '{layer}' {}", label.unwrap_or(kind.match_name()));
|
||||
let dims = kind.dims();
|
||||
let default: Vec<f64> = match kind {
|
||||
PropKind::Anchor if text => vec![0.0, 0.0, 0.0],
|
||||
// After Effects 2026 reads a null's absent Anchor Point as `[0, 0]` (#3099).
|
||||
PropKind::Anchor if text || null => vec![0.0, 0.0, 0.0],
|
||||
PropKind::Anchor => vec![0.5, 0.5, 0.0],
|
||||
PropKind::Position => vec![f64::from(comp.width) / 2.0, f64::from(comp.height) / 2.0, 0.0],
|
||||
PropKind::Scale => vec![1.0, 1.0, 1.0],
|
||||
@@ -1318,7 +1324,7 @@ fn plan_prop(kind: PropKind, label: Option<&str>, p: &Property, at: PropAt<'_>,
|
||||
/// back disabled).
|
||||
fn plan_remap(p: &Property, comp: &Comp, cp: &CompPlan, layer: &str, comp_name: &str, col: &mut Collector) -> PropPlan {
|
||||
// Rotation has the same layout (one component, stored as is, not spatial).
|
||||
let at = PropAt { comp, comp_name, layer, source: (1, 1), text: false };
|
||||
let at = PropAt { comp, comp_name, layer, source: (1, 1), text: false, null: false };
|
||||
let planned = plan_prop(PropKind::Rotation, Some("Time Remap"), p, at, cp, col);
|
||||
col.exported.time_remaps += 1;
|
||||
let mut plan = planned.unwrap_or_else(|| PropPlan { kind: PropKind::Rotation, value: vec![0.0], keys: Vec::new(), expr: None });
|
||||
|
||||
@@ -27,9 +27,11 @@
|
||||
mod build;
|
||||
mod camera;
|
||||
mod effects;
|
||||
mod lumetri;
|
||||
mod markers;
|
||||
mod masks;
|
||||
mod props;
|
||||
mod puppet;
|
||||
mod report;
|
||||
mod settings;
|
||||
mod shapes;
|
||||
|
||||
@@ -206,6 +206,8 @@ fn new_comp(h: &CompHeader, renderer: Option<AepRenderer>, map: &TimeMap, name:
|
||||
comp
|
||||
}
|
||||
|
||||
/// Adds one footage item to the project under the given parent.
|
||||
/// Unreadable footage is recorded as a loss, solids and null placeholders are handled separately, and file and sequence footage is linked to its files.
|
||||
fn import_footage(project: &mut Project, cx: &mut Ctx<'_>, item: &AepItem<'_, '_>, name: String, label: Label, parent: ItemId, null_only: &HashSet<u32>) {
|
||||
let Some(f) = item.footage() else {
|
||||
cx.col.lose(LossCategory::DamagedData, format!("footage '{name}': data unreadable, not imported"));
|
||||
@@ -240,31 +242,42 @@ fn import_footage(project: &mut Project, cx: &mut Ctx<'_>, item: &AepItem<'_, '_
|
||||
}
|
||||
let mut path = f.path.clone().unwrap_or_default();
|
||||
// An image sequence's alias is its folder; its files are named by the stored numbering
|
||||
// (353, 603). The first file stands for the sequence, as in EffectCraft.
|
||||
// (353, 603), or listed one by one when imported with Force alphabetical order. The first
|
||||
// file stands for the sequence, as in EffectCraft.
|
||||
let mut sequence = Vec::new();
|
||||
if let Some(seq) = item.sequence_info().filter(|s| !path.is_empty() && s.end >= s.start) {
|
||||
let frames = (seq.end - seq.start).saturating_add(1);
|
||||
if let Some(seq) = item.sequence_info().filter(|s| !path.is_empty() && s.frame_count() > 0) {
|
||||
let frames = seq.frame_count();
|
||||
let sep = if path.contains('\\') { '\\' } else { '/' };
|
||||
let folder = path.trim_end_matches(['\\', '/']).to_string();
|
||||
// One name is the folder, a separator and the file name; the number is as wide as the stored
|
||||
// digits or as the largest number, whichever is longer (an upper bound for every frame).
|
||||
let name_bytes = (folder.len() + 1 + seq.prefix.len() + seq.suffix.len() + usize::try_from(seq.digits.min(16)).unwrap_or(16).max(10)) as u64;
|
||||
let bytes = name_bytes.saturating_mul(u64::from(frames));
|
||||
// One name is the folder, a separator and the file name. A numbered name is as wide as the
|
||||
// stored digits or as the largest number, whichever is longer (an upper bound for every
|
||||
// frame); listed names are counted as they are.
|
||||
let len = |n: usize| u64::try_from(n).unwrap_or(u64::MAX);
|
||||
let file_bytes = if seq.files.is_empty() {
|
||||
len(seq.prefix.len().saturating_add(seq.suffix.len()).saturating_add(usize::try_from(seq.digits.min(16)).unwrap_or(16).max(10)))
|
||||
.saturating_mul(u64::from(frames))
|
||||
} else {
|
||||
seq.files.iter().fold(0u64, |sum, n| sum.saturating_add(len(n.len())))
|
||||
};
|
||||
let bytes = len(folder.len().saturating_add(1)).saturating_mul(u64::from(frames)).saturating_add(file_bytes);
|
||||
if frames > MAX_SEQUENCE_FRAMES || frames > cx.sequence_budget || bytes > cx.sequence_bytes {
|
||||
// Not expanded: the item keeps its first file (a still standing for the sequence).
|
||||
cx.col.lose(
|
||||
LossCategory::DamagedData,
|
||||
format!("footage '{name}': a sequence of {frames} frames is longer than the import expands; only its first file is linked"),
|
||||
);
|
||||
path = format!("{folder}{sep}{}", seq.file_name(seq.start));
|
||||
path = format!("{folder}{sep}{}", seq.nth_file_name(0).unwrap_or_default());
|
||||
} else {
|
||||
cx.sequence_budget -= frames;
|
||||
cx.sequence_bytes -= bytes;
|
||||
sequence = (seq.start..=seq.end).map(|n| format!("{folder}{sep}{}", seq.file_name(n))).collect();
|
||||
sequence = (0..frames).filter_map(|i| seq.nth_file_name(i)).map(|n| format!("{folder}{sep}{n}")).collect();
|
||||
}
|
||||
if let Some(first) = sequence.first() {
|
||||
path = first.clone();
|
||||
}
|
||||
} else if f.is_sequence && !path.is_empty() {
|
||||
// The alias is a folder, but the file names could not be read: the item links the folder.
|
||||
cx.col.lose(LossCategory::DamagedData, format!("footage '{name}': image sequence naming not read, linked to its folder"));
|
||||
}
|
||||
let present = !path.is_empty() && (cx.options.exists)(&path);
|
||||
if !present {
|
||||
@@ -272,6 +285,8 @@ fn import_footage(project: &mut Project, cx: &mut Ctx<'_>, item: &AepItem<'_, '_
|
||||
cx.col.missing_footage.push(shown);
|
||||
}
|
||||
let kind = if sequence.is_empty() { footage_kind(&path, f.native_frame_rate, f.audio_sample_rate.is_some()) } else { FootageKind::Sequence };
|
||||
// A placed PDF / Illustrator file keeps After Effects' framing and size (#3099).
|
||||
let placed_page = kind == FootageKind::Still && is_placed_page_path(&path);
|
||||
let mut footage = Footage {
|
||||
path,
|
||||
kind,
|
||||
@@ -282,6 +297,7 @@ fn import_footage(project: &mut Project, cx: &mut Ctx<'_>, item: &AepItem<'_, '_
|
||||
// footage), and only then.
|
||||
has_audio: kind == FootageKind::Audio || (kind == FootageKind::Video && f.audio_sample_rate.is_some()),
|
||||
missing: !present,
|
||||
placed_page,
|
||||
sequence,
|
||||
..Footage::default()
|
||||
};
|
||||
@@ -314,10 +330,23 @@ fn import_footage(project: &mut Project, cx: &mut Ctx<'_>, item: &AepItem<'_, '_
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `path` names a file whose page is framed as After Effects places it
|
||||
/// ([`Footage::placed_page`]): an Illustrator or PDF file, by its extension. EPS files keep
|
||||
/// their bounding box at their own size: how After Effects sizes them was not measured (#3099).
|
||||
fn is_placed_page_path(path: &str) -> bool {
|
||||
let ext = path.rsplit(['/', '\\']).next().and_then(|name| name.rsplit_once('.')).map(|(_, ext)| ext.to_ascii_lowercase());
|
||||
matches!(ext.as_deref(), Some("pdf" | "ai"))
|
||||
}
|
||||
|
||||
/// The name After Effects shows for a footage item that stores none: the file's name, or for a
|
||||
/// sequence the name with the frame range (`shot_[0101-0103].exr`).
|
||||
fn default_footage_name(item: &AepItem<'_, '_>, f: &crate::Footage) -> Option<String> {
|
||||
if let Some(s) = item.sequence_info() {
|
||||
// A listed sequence (Force alphabetical order) has no numbering: its first file names it.
|
||||
// Assumption: what After Effects shows for such an item without a stored name was not sampled.
|
||||
if let Some(first) = s.files.first() {
|
||||
return Some(first.clone());
|
||||
}
|
||||
let w = usize::try_from(s.digits.min(16)).unwrap_or(0);
|
||||
return Some(format!("{}[{:0w$}-{:0w$}]{}", s.prefix, s.start, s.end, s.suffix));
|
||||
}
|
||||
@@ -435,11 +464,13 @@ fn camera_light_orient(auto_oriented: bool, light: Option<LightKind>) -> AutoOri
|
||||
}
|
||||
|
||||
/// The unit a layer's anchor is stored in.
|
||||
fn anchor_unit(camera_or_light: bool, text_or_shape: bool, size: (u32, u32)) -> AnchorUnit {
|
||||
fn anchor_unit(camera_or_light: bool, text_or_shape: bool, null: bool, size: (u32, u32)) -> AnchorUnit {
|
||||
if camera_or_light {
|
||||
AnchorUnit::PointOfInterest
|
||||
} else if text_or_shape {
|
||||
AnchorUnit::Pixels
|
||||
} else if null {
|
||||
AnchorUnit::Null(size.0, size.1)
|
||||
} else {
|
||||
AnchorUnit::Source(size.0, size.1)
|
||||
}
|
||||
@@ -479,7 +510,12 @@ fn build_layers(project: &mut Project, cx: &mut Ctx<'_>, defs: &EffectDefinition
|
||||
.map(|d| crate::import::text::first_paragraph(&d.raw_text))
|
||||
.filter(|t| !t.trim().is_empty())
|
||||
};
|
||||
let name = aep_name.clone().or_else(|| src.as_ref().map(|s| s.name.clone())).or_else(text_name).unwrap_or_else(|| "Layer".to_string());
|
||||
let name = aep_name
|
||||
.clone()
|
||||
.or_else(|| src.as_ref().map(|s| s.name.clone()))
|
||||
.or_else(text_name)
|
||||
// An empty text layer without a name is named as a new one is (#3233).
|
||||
.unwrap_or_else(|| if h.layer_type == LayerType::Text { "Text" } else { "Layer" }.to_string());
|
||||
let skip = |cx: &mut Ctx<'_>, skipped: &mut HashMap<u32, String>, category: LossCategory, what: &str| {
|
||||
cx.col.lose(category, format!("{comp_name}: {what} '{name}'"));
|
||||
skipped.insert(h.id, what.to_string());
|
||||
@@ -537,6 +573,10 @@ fn build_layers(project: &mut Project, cx: &mut Ctx<'_>, defs: &EffectDefinition
|
||||
};
|
||||
let mut l: Layer = build::layer(project, &comp, &name, source.clone(), size, None);
|
||||
l.name = name.clone();
|
||||
// A text layer without a stored name follows its text; a stored name is the user's (#3233).
|
||||
if is_text {
|
||||
l.name_follows_text = Some(aep_name.is_none());
|
||||
}
|
||||
l.label = Label::ALL.get(usize::from(h.label)).copied().unwrap_or(Label::None);
|
||||
apply_timing(&mut l, &h, &tm, &name, &mut cx.col);
|
||||
apply_switches(&mut l, &h);
|
||||
@@ -555,7 +595,7 @@ fn build_layers(project: &mut Project, cx: &mut Ctx<'_>, defs: &EffectDefinition
|
||||
import_layer_styles(&layer, &mut l, &comp.global_light, &mut Ids(&mut project.next_id), &tm, &what, &mut cx.col);
|
||||
// Text and shape layers store their anchor in pixels (385, 462); cameras and lights their
|
||||
// point of interest.
|
||||
let anchor = anchor_unit(is_camera || light.is_some(), is_text || is_shape, size);
|
||||
let anchor = anchor_unit(is_camera || light.is_some(), is_text || is_shape, is_null, size);
|
||||
if let Some(tr) = l.props.sub_mut("transform") {
|
||||
import_transform(&layer, &name, comp_size, anchor, &tm, tr, &mut cx.col);
|
||||
}
|
||||
@@ -681,7 +721,14 @@ fn apply_timing(l: &mut Layer, h: &LayerHeader, tm: &TimeMap, name: &str, col: &
|
||||
col.lose(LossCategory::DamagedData, format!("layer '{name}': the time stretch is zero or unreadable, 100 % is used"));
|
||||
Rational { num: 1, den: 1 }
|
||||
});
|
||||
l.stretch = stretch.to_f64().map_or(100.0, |r| r * 100.0);
|
||||
let stored = stretch.to_f64().map_or(100.0, |r| r * 100.0);
|
||||
// A ratio such as 1/2^31 (5e-8 %) or 2^31/1 is outside what After Effects allows (±1 % to
|
||||
// ±9900 %); taken as stored, the layer and audio time would divide by ~0 (#3247). The in and
|
||||
// out points keep the comp times the file stores.
|
||||
l.stretch = effectcraft_project::limits::stretch(stored);
|
||||
if l.stretch.to_bits() != stored.to_bits() {
|
||||
col.lose(LossCategory::DamagedData, format!("layer '{name}': the time stretch of {stored:e} % is outside ±1 % to ±9900 %, {} % is used", l.stretch));
|
||||
}
|
||||
// A stored time with a zero denominator (or a sum that overflows) has no value; it is read as 0
|
||||
// and reported, so the layer's timing never changes silently.
|
||||
let unreadable: Vec<&str> = [("start", h.start), ("in point", h.in_relative), ("out point", h.out_relative)]
|
||||
@@ -872,6 +919,18 @@ fn resolve_parents(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// #3099: an `.ai` or `.pdf` from an `.aep` is a placed page; an EPS (not measured in After
|
||||
/// Effects) and other files are not.
|
||||
#[test]
|
||||
fn only_illustrator_and_pdf_files_are_placed_pages() {
|
||||
for path in [r"C:\fixture\x\logo.ai", "/work/Logo.AI", "art.pdf", r"C:\a.b\page.PDF"] {
|
||||
assert!(is_placed_page_path(path), "{path}");
|
||||
}
|
||||
for path in [r"C:\fixture\x\still.eps", "a.epsf", "a.epsi", "still.png", r"C:\dir.ai\file", "ai", ""] {
|
||||
assert!(!is_placed_page_path(path), "{path}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unlisted_extensions_follow_what_the_file_stores() {
|
||||
assert_eq!(footage_kind("C:/clips/a.MP4", 0, false), FootageKind::Video);
|
||||
|
||||
@@ -34,10 +34,10 @@
|
||||
//! import. Hue/Saturation's master values and Levels' values are arbitrary data, decoded by the
|
||||
//! reader (444-446, 512-516).
|
||||
|
||||
use crate::effect_map::{CRYPTOMATTE_OUTPUT, Conv, EFFECTS, EffectMap, Extra, HUE_SATURATION_MASTER, LEVELS_RGB, popup_key};
|
||||
use crate::effect_map::{CRYPTOMATTE_OUTPUT, Conv, EFFECTS, EffectMap, Extra, HUE_SATURATION_MASTER, LEVELS_RGB, ParamMap, popup_key};
|
||||
use crate::{
|
||||
CryptomatteData, CurvesData, DefinitionSource, Effect as AepEffect, EffectDefinitions, EffectParam, HueSaturationData, Layer as AepLayer, LayerRef,
|
||||
LevelsData, MeshData, ParamKind, ParamValue,
|
||||
CryptomatteData, CurvesData, DefinitionSource, Effect as AepEffect, EffectDefinitions, EffectParam, ExtractorChannels, HueSaturationData,
|
||||
Layer as AepLayer, LayerRef, LevelsData, MeshData, ParamKind, ParamValue,
|
||||
};
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::build::Ids;
|
||||
@@ -76,6 +76,11 @@ pub(crate) fn import_effects<'f, 'a>(
|
||||
col: &mut Collector,
|
||||
) {
|
||||
for fx in layer.effects() {
|
||||
// Puppet keeps its meshes and pins in nested groups, not in `parT` (its own step).
|
||||
if fx.match_name() == crate::import::puppet::MATCH_NAME {
|
||||
crate::import::puppet::import_puppet(defs, &fx, cx, ids, target, col);
|
||||
continue;
|
||||
}
|
||||
let shown = fx.display_name().unwrap_or_else(|| fx.match_name().to_string());
|
||||
let Some(map) = EFFECTS.iter().find(|m| m.match_name == fx.match_name()) else {
|
||||
col.lose(LossCategory::Effects, format!("{}: '{}' has the effect '{shown}' ({})", cx.comp, cx.layer, fx.match_name()));
|
||||
@@ -99,7 +104,25 @@ pub(crate) fn import_effects<'f, 'a>(
|
||||
None => col.lose(LossCategory::DamagedData, format!("{}: '{}': the fx switch of '{shown}' is unreadable, imported switched on", cx.comp, cx.layer)),
|
||||
}
|
||||
let params = defs.params(&fx);
|
||||
if map.extra == Extra::Cryptomatte {
|
||||
// Borrowed definitions (another instance's or the project copy) may not name every value
|
||||
// record this instance has: such a record has nothing to import into, so it is reported.
|
||||
if params.first().is_some_and(|p| p.borrowed_definition) {
|
||||
let defined: std::collections::HashSet<&str> = params.iter().map(|p| p.match_name.as_str()).collect();
|
||||
for name in fx.value_record_names().iter().filter(|n| !defined.contains(n.as_str())) {
|
||||
col.lose(
|
||||
LossCategory::Effects,
|
||||
format!(
|
||||
"{}: '{}' effect '{shown}' ({}): the value record {name} has no parameter definition, not imported",
|
||||
cx.comp,
|
||||
cx.layer,
|
||||
fx.match_name()
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
if map.extra == Extra::Lumetri {
|
||||
crate::import::lumetri::import_lumetri(&fx, ¶ms, cx, &shown, ParamOut { group: &mut group, refs: &mut *refs, col: &mut *col });
|
||||
} else if map.extra == Extra::Cryptomatte {
|
||||
import_cryptomatte(&fx, ¶ms, cx, &shown, &mut group, col);
|
||||
} else {
|
||||
for p in ¶ms {
|
||||
@@ -112,7 +135,7 @@ pub(crate) fn import_effects<'f, 'a>(
|
||||
}
|
||||
|
||||
/// Parameters that never carry a value to import.
|
||||
fn carries_nothing(p: &EffectParam<'_, '_>) -> bool {
|
||||
pub(crate) fn carries_nothing(p: &EffectParam<'_, '_>) -> bool {
|
||||
matches!(p.kind, ParamKind::Button | ParamKind::NoData | ParamKind::GroupStart | ParamKind::GroupEnd) || (p.index == 0 && p.kind == ParamKind::Layer)
|
||||
}
|
||||
|
||||
@@ -131,10 +154,15 @@ pub(crate) struct PendingLayerRef {
|
||||
|
||||
/// Where a parameter is imported to: the effect's group, the layer parameters still to resolve
|
||||
/// and the report.
|
||||
struct ParamOut<'a> {
|
||||
group: &'a mut PropGroup,
|
||||
refs: &'a mut Vec<PendingLayerRef>,
|
||||
col: &'a mut Collector,
|
||||
pub(crate) struct ParamOut<'a> {
|
||||
pub group: &'a mut PropGroup,
|
||||
pub refs: &'a mut Vec<PendingLayerRef>,
|
||||
pub col: &'a mut Collector,
|
||||
}
|
||||
|
||||
/// How a parameter is named in the report: its name and match name.
|
||||
pub(crate) fn param_label(p: &EffectParam<'_, '_>) -> String {
|
||||
if p.name.trim().is_empty() { p.match_name.clone() } else { format!("{} ({})", p.name.trim(), p.match_name) }
|
||||
}
|
||||
|
||||
/// Imports one parameter of `fx` into its EffectCraft property: its keyframes, the value in its
|
||||
@@ -144,7 +172,7 @@ fn import_param(fx: &AepEffect<'_, '_>, map: &EffectMap, p: &EffectParam<'_, '_>
|
||||
if carries_nothing(p) {
|
||||
return;
|
||||
}
|
||||
let label = if p.name.is_empty() { p.match_name.clone() } else { format!("{} ({})", p.name, p.match_name) };
|
||||
let label = param_label(p);
|
||||
let here = format!("{}: '{}' effect '{shown}' ({})", cx.comp, cx.layer, fx.match_name());
|
||||
if p.kind == ParamKind::ArbitraryData && map.extra != Extra::None {
|
||||
import_extra(fx, map.extra, p, &here, cx.size, group, col);
|
||||
@@ -158,6 +186,14 @@ fn import_param(fx: &AepEffect<'_, '_>, map: &EffectMap, p: &EffectParam<'_, '_>
|
||||
}
|
||||
return;
|
||||
};
|
||||
import_mapped(p, m, cx, (&here, &label), ParamOut { group, refs, col });
|
||||
}
|
||||
|
||||
/// Imports parameter `p` into the property `m` names, converted by `m.conv`. `names` is
|
||||
/// `(here, label)`, the effect and the parameter for the report.
|
||||
pub(crate) fn import_mapped(p: &EffectParam<'_, '_>, m: &ParamMap, cx: &LayerCtx<'_>, names: (&str, &str), out: ParamOut<'_>) {
|
||||
let (here, label) = names;
|
||||
let ParamOut { group, refs, col } = out;
|
||||
if p.kind != m.kind {
|
||||
col.lose(LossCategory::Effects, format!("{here}: parameter {label} has type {:?}, expected {:?}; not imported", p.kind, m.kind));
|
||||
return;
|
||||
@@ -165,7 +201,7 @@ fn import_param(fx: &AepEffect<'_, '_>, map: &EffectMap, p: &EffectParam<'_, '_>
|
||||
match m.conv {
|
||||
Conv::Skip => return,
|
||||
Conv::LayerRef { own } => {
|
||||
import_layer_ref(p, m.target, own, cx, (&here, &label), ParamOut { group, refs, col });
|
||||
import_layer_ref(p, m.target, own, cx, (here, label), ParamOut { group, refs, col });
|
||||
return;
|
||||
}
|
||||
_ => {}
|
||||
@@ -270,9 +306,9 @@ fn import_param(fx: &AepEffect<'_, '_>, map: &EffectMap, p: &EffectParam<'_, '_>
|
||||
col.lose(LossCategory::DamagedData, format!("{here}: parameter {label} has no readable keyframe, default used"));
|
||||
}
|
||||
if let Some(map) = &popup_map {
|
||||
remap_popup_keys(prop, map, &here, &label, col);
|
||||
remap_popup_keys(prop, map, here, label, col);
|
||||
}
|
||||
report_beyond_limit(m.limit, prop.keys.iter().map(|k| &k.value), &here, &label, col);
|
||||
report_beyond_limit(m.limit, prop.keys.iter().map(|k| &k.value), here, label, col);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -308,7 +344,7 @@ fn import_param(fx: &AepEffect<'_, '_>, map: &EffectMap, p: &EffectParam<'_, '_>
|
||||
};
|
||||
match converted {
|
||||
Some(v) => {
|
||||
report_beyond_limit(m.limit, std::iter::once(&v), &here, &label, col);
|
||||
report_beyond_limit(m.limit, std::iter::once(&v), here, label, col);
|
||||
prop.value = v;
|
||||
}
|
||||
None => col.lose(LossCategory::DamagedData, format!("{here}: parameter {label} holds a value that is not valid ({value:?}), default used")),
|
||||
@@ -422,7 +458,15 @@ fn import_extra(fx: &AepEffect<'_, '_>, extra: Extra, p: &EffectParam<'_, '_>, h
|
||||
col.lose(LossCategory::Effects, format!("{here}: keyframes or an expression on {} are not imported", p.name));
|
||||
}
|
||||
let Some(data) = fx.arbitrary_data(&p.match_name) else {
|
||||
// No data: every value is at its default.
|
||||
// No record: every value is at its default. A Channel Info value record without its
|
||||
// aRbs/aRbp payload (every sampled record has it) is damaged: the payload may have chosen
|
||||
// other channels (`Z`), so the defaults are not what After Effects renders.
|
||||
if p.stored().is_some() && matches!(extra, Extra::Extractor | Extra::Identifier) {
|
||||
col.lose(
|
||||
LossCategory::DamagedData,
|
||||
format!("{here}: the Channel Info data is missing (a value record without its data), the default channels used"),
|
||||
);
|
||||
}
|
||||
return;
|
||||
};
|
||||
let mut set = |id: &str, v: f64| {
|
||||
@@ -473,7 +517,61 @@ fn import_extra(fx: &AepEffect<'_, '_>, extra: Extra, p: &EffectParam<'_, '_>, h
|
||||
Some(m) => import_mesh(&m, here, size, group, col),
|
||||
None => col.lose(LossCategory::DamagedData, format!("{here}: the Distortion Mesh is unreadable, defaults used")),
|
||||
},
|
||||
Extra::None | Extra::Cryptomatte => {}
|
||||
Extra::Extractor => match ExtractorChannels::parse(data).map(|c| (c.sources(), c)) {
|
||||
// Each output channel reads the footage's channel of the stored name, which
|
||||
// EffectCraft's EXtractoR finds by the same name (`Z`); After Effects' `(copy)` is the
|
||||
// input's own channel, EffectCraft's `(copy)` (not `A`, which EffectCraft reads from an
|
||||
// OpenEXR footage's alpha plane, losing what earlier effects did to it).
|
||||
Some((Ok(names), _)) => {
|
||||
for (id, name) in ["red", "green", "blue", "alpha"].into_iter().zip(names) {
|
||||
if let Some(prop) = group.prop_mut(id) {
|
||||
prop.value = Value::Str(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some((Err(k), c)) => {
|
||||
let names: Vec<&str> = c.channels.iter().map(|(_, n)| n.as_str()).collect();
|
||||
col.lose(
|
||||
LossCategory::Effects,
|
||||
format!(
|
||||
"{here}: the channels {} are not imported (record {} is of an unknown kind or names no channel), R, G, B, A used",
|
||||
names.join(", "),
|
||||
k.saturating_add(1)
|
||||
),
|
||||
);
|
||||
}
|
||||
None => col.lose(LossCategory::Effects, format!("{here}: the Channel Info data is not in the sampled layout, R, G, B, A used")),
|
||||
},
|
||||
Extra::Identifier => match crate::identifier_channel_type(data) {
|
||||
// No channel chosen (the type's label): After Effects lists only the footage's integer
|
||||
// (UINT) channels and, with none chosen, leaves the layer unchanged, where EffectCraft
|
||||
// draws the footage's object ID channel.
|
||||
Some("Object ID") => {
|
||||
if let Some(prop) = group.prop_mut("channelType") {
|
||||
prop.value = Value::Enum(0);
|
||||
}
|
||||
col.lose(
|
||||
LossCategory::Effects,
|
||||
format!("{here}: no ID channel is chosen (Channel Info), so After Effects leaves the layer unchanged; EffectCraft draws the footage's object ID channel"),
|
||||
);
|
||||
}
|
||||
// A chosen channel, by name. EffectCraft's IDentifier has no channel of its own: it
|
||||
// reads the footage's first `ObjectID` (`MaterialID`) channel, names compared without
|
||||
// regard to case, so a channel of that name is the one After Effects reads.
|
||||
Some(name) => match IDENTIFIER_OWN.iter().position(|own| own.eq_ignore_ascii_case(name.trim())) {
|
||||
Some(kind) => {
|
||||
if let Some(prop) = group.prop_mut("channelType") {
|
||||
prop.value = Value::Enum(u32::try_from(kind).unwrap_or(0));
|
||||
}
|
||||
}
|
||||
None => col.lose(
|
||||
LossCategory::Effects,
|
||||
format!("{here}: the ID channel '{name}' (Channel Info) is not imported; EffectCraft reads the footage's object ID channel (ObjectID, object_id, objectId, id)"),
|
||||
),
|
||||
},
|
||||
None => col.lose(LossCategory::Effects, format!("{here}: the Channel Info data is not in the sampled layout, the object ID channel used")),
|
||||
},
|
||||
Extra::None | Extra::Lumetri | Extra::Cryptomatte => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -614,6 +712,10 @@ fn crypto_output(p: &EffectParam<'_, '_>, here: &str, label: &str, col: &mut Col
|
||||
}
|
||||
}
|
||||
|
||||
/// The channels EffectCraft's IDentifier reads first for its Channel Type Object ID and Material
|
||||
/// ID (`AuxChannels::object_id`, `material_id`).
|
||||
const IDENTIFIER_OWN: [&str; 2] = ["ObjectID", "MaterialID"];
|
||||
|
||||
/// Curves' EffectCraft parameters, in the stored order (RGB, Red, Green, Blue, Alpha).
|
||||
const CURVE_IDS: [&str; 5] = ["rgb", "red", "green", "blue", "alpha"];
|
||||
|
||||
|
||||
@@ -0,0 +1,803 @@
|
||||
//! Lumetri Color (`ADBE Lumetri`) to EffectCraft's Lumetri Color (`ec.color.lumetri`).
|
||||
//!
|
||||
//! The parameters are found by their match name (`ADBE Lumetri-0011` is Exposure), not by their
|
||||
//! place in `parT`: After Effects has added parameters in the middle of the list over the
|
||||
//! versions (`-0127` ... `-0130` sit between older ones), and several sliders share a valid range
|
||||
//! (Contrast, Highlights, Shadows, Whites and Blacks are all -150..150), so a shifted index
|
||||
//! would map silently to the wrong control. Evidence: the 1200 samples (After Effects 2026,
|
||||
//! `docs/aep-format.md` 3.7).
|
||||
//!
|
||||
//! - **Values in any record state.** Lumetri writes the values of its hidden controls (High
|
||||
//! Dynamic Range, HDR Specular, the second Look popup, ...) in records in the default state
|
||||
//! (`tdsb` 3); After Effects reads them back as set, so every record counts here
|
||||
//! ([`EffectParam::recorded_value`]).
|
||||
//! - **Section switches.** Each section (Basic Correction, Creative, Curves, RGB Curves, the five
|
||||
//! hue / saturation curves, Color Wheels, HSL Secondary, Vignette) has an on / off checkbox in
|
||||
//! After Effects; EffectCraft's Lumetri has none. A section that is off does nothing in After
|
||||
//! Effects (1200 `lumetri-03-sec-basic-correction`, `-17-sec-vignette` render as `-00-defaults`),
|
||||
//! so its values are not imported (the effect looks the same) and are listed in the report.
|
||||
//! - **Arbitrary data** (`ParamKind::ArbitraryData`, every instance stores it). Curves are a
|
||||
//! curve type, a point count and points as `f64` x, y ([`curve_points`]).
|
||||
//! - The RGB curve (`-0039`) holds four curves; the first is the master (white) curve (1200
|
||||
//! `lumetri-60-preset-curves-rgb`), imported. The other three are reported when moved: no
|
||||
//! sample tells which is which channel.
|
||||
//! - Hue vs Sat (`-0042`): y is an offset from neutral, 1 doubling the saturation (`-61`), so
|
||||
//! EffectCraft's y is `0.5 + y / 2`. The other four hue / saturation curves have no sample
|
||||
//! of their scale and are reported when moved.
|
||||
//! - Color Wheels (`-0047`): Shadows, Midtones, Highlights and a fourth wheel, each hue,
|
||||
//! brightness slider, distance ([`import_wheels`], `-62`).
|
||||
//! - The HSL Secondary key ([`HslKey`]) is imported; Split Toning and the correction wheels are
|
||||
//! reported when they are not the default (not sampled).
|
||||
//! - **LUTs and looks.** The Input LUT and the Look are files After Effects names (the Look's name
|
||||
//! is UTF-16 text in `-0096`, the popups only index a list of the installed ones); they are not
|
||||
//! in the project, so a set one is reported by name and EffectCraft's popup stays None.
|
||||
//! - **Eyedropper colours** (White Balance, the hue curve selectors, the key's Set / Add /
|
||||
//! Remove colours) only set other controls in After Effects; they are reported when set.
|
||||
//! - The `Blob` (`-0001`, an XML copy of the grade After Effects writes when a Look changes and does
|
||||
//! not keep current: 1200 `-01-set-all` holds the defaults of controls set after the Look) and
|
||||
//! the Auto Tone analysis (`-0121`, `-0122`) are derived data and not read.
|
||||
|
||||
use crate::effect_map::{Conv, ParamMap};
|
||||
use crate::{Effect as AepEffect, EffectParam, ParamKind, ParamValue};
|
||||
use effectcraft_keyframe::Value;
|
||||
|
||||
use crate::import::effects::{LayerCtx, ParamOut, carries_nothing, import_mapped, param_label};
|
||||
use crate::import::report::LossCategory;
|
||||
use ParamKind as K;
|
||||
|
||||
/// After Effects' Show Mask styles (`-0075`, `pdnm`), EffectCraft's in the same order.
|
||||
pub const MASK_STYLE: &[&str] = &["Color/Gray", "Color/Black", "White/Black"];
|
||||
|
||||
/// A part of the effect with its own on / off switch.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Section {
|
||||
/// No switch (High Dynamic Range and the controls outside the sections).
|
||||
Always,
|
||||
BasicCorrection,
|
||||
Creative,
|
||||
RgbCurves,
|
||||
/// The five hue / saturation curves (0 Hue vs Sat ... 4 Sat vs Sat).
|
||||
HueSat(u8),
|
||||
ColorWheels,
|
||||
HslSecondary,
|
||||
Vignette,
|
||||
SpeedGradeCustom,
|
||||
}
|
||||
|
||||
impl Section {
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Section::Always => "",
|
||||
Section::BasicCorrection => "Basic Correction",
|
||||
Section::Creative => "Creative",
|
||||
Section::RgbCurves => "RGB Curves",
|
||||
Section::HueSat(0) => "Hue vs Sat",
|
||||
Section::HueSat(1) => "Hue vs Hue",
|
||||
Section::HueSat(2) => "Hue vs Luma",
|
||||
Section::HueSat(3) => "Luma vs Sat",
|
||||
Section::HueSat(_) => "Sat vs Sat",
|
||||
Section::ColorWheels => "Color Wheels",
|
||||
Section::HslSecondary => "HSL Secondary",
|
||||
Section::Vignette => "Vignette",
|
||||
Section::SpeedGradeCustom => "SpeedGrade Custom",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Arbitrary data the importer reads.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Data {
|
||||
/// The four RGB curves (4 x 520 bytes).
|
||||
RgbCurves,
|
||||
/// One hue / saturation curve (520 bytes); the EffectCraft parameter when the scale of its y
|
||||
/// is known (Hue vs Sat).
|
||||
HueSatCurve(Option<&'static str>),
|
||||
/// Color Wheels (4 x 3 `f64`, each wheel 0, 0.5, 0 by default).
|
||||
Wheels,
|
||||
/// HSL Secondary's correction wheels (8 x 3 `f64`, the same default).
|
||||
CorrectionWheels,
|
||||
/// Split Toning (32 zero bytes by default).
|
||||
SplitToning,
|
||||
/// HSL Secondary's key ([`HslKey`]).
|
||||
HslKey,
|
||||
/// Data without a counterpart, empty by default (`fe fe` or zero bytes).
|
||||
Other,
|
||||
}
|
||||
|
||||
/// What a Lumetri parameter is.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Role {
|
||||
/// An EffectCraft parameter and the conversion.
|
||||
To(&'static str, Conv),
|
||||
/// The on / off switch of a section.
|
||||
Switch,
|
||||
/// No counterpart: reported when not at the default.
|
||||
Missing,
|
||||
/// A checkbox without counterpart whose `pard` default is not reliable: reported when a
|
||||
/// record says on. Legacy Tone And Saturation (`-0097`) is off in a fresh instance, but its
|
||||
/// `pard` default reads off in 1200 as saved by the script and on after the project was
|
||||
/// opened and saved again (`versions/1200-lumetri-26`, `-25x`, `-24x`).
|
||||
MissingWhenOn,
|
||||
/// An eyedropper colour: reported when set.
|
||||
Eyedropper,
|
||||
/// The Input LUT (`lut` true) or the Look: a popup, a name or an asset.
|
||||
Lut {
|
||||
lut: bool,
|
||||
},
|
||||
/// The Look's colour space (its popup and the space's id): goes with the Look.
|
||||
LookSpace,
|
||||
Data(Data),
|
||||
/// Derived data, not read.
|
||||
Derived,
|
||||
}
|
||||
|
||||
struct Row {
|
||||
suffix: u16,
|
||||
section: Section,
|
||||
kind: Option<ParamKind>,
|
||||
role: Role,
|
||||
}
|
||||
|
||||
const fn row(suffix: u16, section: Section, kind: Option<ParamKind>, role: Role) -> Row {
|
||||
Row { suffix, section, kind, role }
|
||||
}
|
||||
|
||||
const fn to(suffix: u16, section: Section, kind: ParamKind, target: &'static str, conv: Conv) -> Row {
|
||||
row(suffix, section, Some(kind), Role::To(target, conv))
|
||||
}
|
||||
|
||||
const fn plain(valid_max: f64) -> Conv {
|
||||
Conv::Plain { valid_max }
|
||||
}
|
||||
|
||||
use Section as S;
|
||||
|
||||
/// Every Lumetri parameter by match-name suffix (1200: `dump.props` and the `pard` of
|
||||
/// `lumetri-00-defaults`). The valid maxima are After Effects 2026's; the stored numbers are the
|
||||
/// values After Effects shows (read back in every variant).
|
||||
const ROWS: &[Row] = &[
|
||||
to(62, S::Always, K::Checkbox, "highDynamicRange", Conv::Checkbox),
|
||||
// Basic Correction: 3 the switch; 4 / 95 the Input LUT's name data, 5 / 127 its popups.
|
||||
row(3, S::BasicCorrection, Some(K::Checkbox), Role::Switch),
|
||||
row(4, S::BasicCorrection, Some(K::ArbitraryData), Role::Lut { lut: true }),
|
||||
row(95, S::BasicCorrection, Some(K::ArbitraryData), Role::Lut { lut: true }),
|
||||
row(5, S::BasicCorrection, Some(K::Popup), Role::Lut { lut: true }),
|
||||
row(127, S::BasicCorrection, Some(K::Popup), Role::Lut { lut: true }),
|
||||
to(63, S::BasicCorrection, K::FloatSlider, "basicCorrection/tone/hdrWhite", plain(1000.0)),
|
||||
// The Auto tone's Intensity (next to the Auto and Reset buttons 120, 119).
|
||||
row(123, S::BasicCorrection, Some(K::FloatSlider), Role::Missing),
|
||||
row(94, S::BasicCorrection, Some(K::Color), Role::Eyedropper),
|
||||
to(7, S::BasicCorrection, K::FloatSlider, "basicCorrection/whiteBalance/temperature", plain(300.0)),
|
||||
to(8, S::BasicCorrection, K::FloatSlider, "basicCorrection/whiteBalance/tint", plain(300.0)),
|
||||
to(20, S::BasicCorrection, K::FloatSlider, "basicCorrection/saturation", plain(300.0)),
|
||||
to(11, S::BasicCorrection, K::FloatSlider, "basicCorrection/tone/exposure", plain(7.0)),
|
||||
to(12, S::BasicCorrection, K::FloatSlider, "basicCorrection/tone/contrast", plain(150.0)),
|
||||
to(13, S::BasicCorrection, K::FloatSlider, "basicCorrection/tone/highlights", plain(150.0)),
|
||||
to(14, S::BasicCorrection, K::FloatSlider, "basicCorrection/tone/shadows", plain(150.0)),
|
||||
to(15, S::BasicCorrection, K::FloatSlider, "basicCorrection/tone/whites", plain(150.0)),
|
||||
to(16, S::BasicCorrection, K::FloatSlider, "basicCorrection/tone/blacks", plain(150.0)),
|
||||
to(64, S::BasicCorrection, K::FloatSlider, "basicCorrection/tone/hdrSpecular", plain(150.0)),
|
||||
// Creative: 23 the switch; 24 / 96 the Look's name, 25 / 128 its popups, 129 / 130 its colour
|
||||
// space.
|
||||
row(23, S::Creative, Some(K::Checkbox), Role::Switch),
|
||||
row(24, S::Creative, Some(K::ArbitraryData), Role::Lut { lut: false }),
|
||||
row(96, S::Creative, Some(K::ArbitraryData), Role::Lut { lut: false }),
|
||||
row(25, S::Creative, Some(K::Popup), Role::Lut { lut: false }),
|
||||
row(128, S::Creative, Some(K::Popup), Role::Lut { lut: false }),
|
||||
row(129, S::Creative, Some(K::ArbitraryData), Role::LookSpace),
|
||||
row(130, S::Creative, Some(K::Popup), Role::LookSpace),
|
||||
to(26, S::Creative, K::FloatSlider, "creative/lookIntensity", plain(200.0)),
|
||||
to(28, S::Creative, K::FloatSlider, "creative/adjustments/fadedFilm", plain(150.0)),
|
||||
to(29, S::Creative, K::FloatSlider, "creative/adjustments/sharpen", plain(100.0)),
|
||||
to(30, S::Creative, K::FloatSlider, "creative/adjustments/vibrance", plain(100.0)),
|
||||
to(31, S::Creative, K::FloatSlider, "creative/adjustments/creativeSaturation", plain(300.0)),
|
||||
row(32, S::Creative, Some(K::ArbitraryData), Role::Data(Data::SplitToning)),
|
||||
to(33, S::Creative, K::FloatSlider, "creative/adjustments/tintBalance", plain(150.0)),
|
||||
// Curves: 37 switches all curves, 118 the RGB curves, 104 ... 111 each hue / saturation curve.
|
||||
row(37, S::RgbCurves, Some(K::Checkbox), Role::Switch),
|
||||
row(118, S::RgbCurves, Some(K::Checkbox), Role::Switch),
|
||||
row(65, S::RgbCurves, Some(K::FloatSlider), Role::Missing),
|
||||
row(39, S::RgbCurves, Some(K::ArbitraryData), Role::Data(Data::RgbCurves)),
|
||||
row(104, S::HueSat(0), Some(K::Checkbox), Role::Switch),
|
||||
row(113, S::HueSat(0), Some(K::Color), Role::Eyedropper),
|
||||
row(42, S::HueSat(0), Some(K::ArbitraryData), Role::Data(Data::HueSatCurve(Some("curves/hueSaturationCurves/hueVsSat")))),
|
||||
row(105, S::HueSat(1), Some(K::Checkbox), Role::Switch),
|
||||
row(114, S::HueSat(1), Some(K::Color), Role::Eyedropper),
|
||||
row(106, S::HueSat(1), Some(K::ArbitraryData), Role::Data(Data::HueSatCurve(None))),
|
||||
row(107, S::HueSat(2), Some(K::Checkbox), Role::Switch),
|
||||
row(115, S::HueSat(2), Some(K::Color), Role::Eyedropper),
|
||||
row(108, S::HueSat(2), Some(K::ArbitraryData), Role::Data(Data::HueSatCurve(None))),
|
||||
row(109, S::HueSat(3), Some(K::Checkbox), Role::Switch),
|
||||
row(116, S::HueSat(3), Some(K::Color), Role::Eyedropper),
|
||||
row(110, S::HueSat(3), Some(K::ArbitraryData), Role::Data(Data::HueSatCurve(None))),
|
||||
row(111, S::HueSat(4), Some(K::Checkbox), Role::Switch),
|
||||
row(117, S::HueSat(4), Some(K::Color), Role::Eyedropper),
|
||||
row(112, S::HueSat(4), Some(K::ArbitraryData), Role::Data(Data::HueSatCurve(None))),
|
||||
// Color Wheels: 46 the switch, 102 Face Detection (on by default), 66 HDR White, 47 the wheels.
|
||||
row(46, S::ColorWheels, Some(K::Checkbox), Role::Switch),
|
||||
row(102, S::ColorWheels, Some(K::Checkbox), Role::Missing),
|
||||
row(66, S::ColorWheels, Some(K::FloatSlider), Role::Missing),
|
||||
row(47, S::ColorWheels, Some(K::ArbitraryData), Role::Data(Data::Wheels)),
|
||||
// HSL Secondary: 68 the switch; the key (70-76), Refine (81, 82), Correction (85-90).
|
||||
row(68, S::HslSecondary, Some(K::Checkbox), Role::Switch),
|
||||
row(70, S::HslSecondary, Some(K::Color), Role::Eyedropper),
|
||||
row(71, S::HslSecondary, Some(K::Color), Role::Eyedropper),
|
||||
row(72, S::HslSecondary, Some(K::Color), Role::Eyedropper),
|
||||
row(73, S::HslSecondary, Some(K::ArbitraryData), Role::Data(Data::HslKey)),
|
||||
to(74, S::HslSecondary, K::Checkbox, "hslSecondary/key/showMask", Conv::Checkbox),
|
||||
to(75, S::HslSecondary, K::Popup, "hslSecondary/key/maskStyle", Conv::Popup { default: 1, items: MASK_STYLE }),
|
||||
to(76, S::HslSecondary, K::Checkbox, "hslSecondary/key/invertMask", Conv::Checkbox),
|
||||
to(81, S::HslSecondary, K::FloatSlider, "hslSecondary/refine/denoise", plain(100.0)),
|
||||
to(82, S::HslSecondary, K::FloatSlider, "hslSecondary/refine/blur", plain(1000.0)),
|
||||
row(85, S::HslSecondary, Some(K::ArbitraryData), Role::Data(Data::CorrectionWheels)),
|
||||
// The correction wheel's mode (4 zero bytes by default).
|
||||
row(93, S::HslSecondary, Some(K::ArbitraryData), Role::Data(Data::Other)),
|
||||
to(86, S::HslSecondary, K::FloatSlider, "hslSecondary/correction/temperature", plain(300.0)),
|
||||
to(87, S::HslSecondary, K::FloatSlider, "hslSecondary/correction/tint", plain(300.0)),
|
||||
to(88, S::HslSecondary, K::FloatSlider, "hslSecondary/correction/contrast", plain(150.0)),
|
||||
to(89, S::HslSecondary, K::FloatSlider, "hslSecondary/correction/sharpen", plain(100.0)),
|
||||
to(90, S::HslSecondary, K::FloatSlider, "hslSecondary/correction/saturation", plain(300.0)),
|
||||
// Vignette: 50 the switch.
|
||||
row(50, S::Vignette, Some(K::Checkbox), Role::Switch),
|
||||
to(51, S::Vignette, K::FloatSlider, "vignette/vignetteAmount", plain(5.0)),
|
||||
to(52, S::Vignette, K::FloatSlider, "vignette/vignetteMidpoint", plain(100.0)),
|
||||
to(53, S::Vignette, K::FloatSlider, "vignette/vignetteRoundness", plain(100.0)),
|
||||
to(54, S::Vignette, K::FloatSlider, "vignette/vignetteFeather", plain(100.0)),
|
||||
// SpeedGrade Custom: 57 Custom Layer (on by default), 60 its data.
|
||||
row(57, S::SpeedGradeCustom, Some(K::Checkbox), Role::Switch),
|
||||
row(60, S::SpeedGradeCustom, Some(K::ArbitraryData), Role::Data(Data::Other)),
|
||||
// Outside the sections: Legacy Tone And Saturation, the embedded LUTs, Auto Tone, Auto Color,
|
||||
// the LUT and Look assets.
|
||||
row(97, S::Always, Some(K::Checkbox), Role::MissingWhenOn),
|
||||
row(98, S::Always, Some(K::ArbitraryData), Role::Data(Data::Other)),
|
||||
row(99, S::Always, Some(K::ArbitraryData), Role::Data(Data::Other)),
|
||||
row(100, S::Always, Some(K::ArbitraryData), Role::Data(Data::Other)),
|
||||
row(124, S::Always, Some(K::Checkbox), Role::Missing),
|
||||
row(125, S::Always, Some(K::ArbitraryData), Role::Lut { lut: true }),
|
||||
row(126, S::Always, Some(K::ArbitraryData), Role::Lut { lut: false }),
|
||||
row(1, S::Always, Some(K::ArbitraryData), Role::Derived),
|
||||
row(121, S::Always, Some(K::FloatSlider), Role::Derived),
|
||||
row(122, S::Always, Some(K::ArbitraryData), Role::Derived),
|
||||
];
|
||||
|
||||
/// The names After Effects shows for the parameters whose `pard` name is blank (`dump.props`).
|
||||
fn blank_name(suffix: u16) -> Option<&'static str> {
|
||||
Some(match suffix {
|
||||
62 => "High Dynamic Range",
|
||||
3 | 23 | 37 | 118 | 104 | 105 | 107 | 109 | 111 | 46 | 68 | 50 => "Active",
|
||||
4 | 95 => "Input LUT data",
|
||||
24 | 96 => "Look name",
|
||||
102 => "Face Detection",
|
||||
74 => "Show Mask",
|
||||
75 => "Mask style",
|
||||
76 => "Invert Mask",
|
||||
97 => "Legacy Tone And Saturation",
|
||||
124 => "Auto Color Active",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The parameter's name for the report: its own, else the one After Effects shows.
|
||||
fn name_of(p: &EffectParam<'_, '_>) -> String {
|
||||
match (p.name.trim(), suffix(&p.match_name).and_then(blank_name)) {
|
||||
("", Some(n)) => n.to_string(),
|
||||
("", None) => p.match_name.clone(),
|
||||
(n, _) => n.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The match-name suffix of a Lumetri parameter (`ADBE Lumetri-0011` is 11).
|
||||
fn suffix(match_name: &str) -> Option<u16> {
|
||||
match_name.strip_prefix("ADBE Lumetri-")?.parse().ok()
|
||||
}
|
||||
|
||||
/// The parameter's value: the recorded one, else the `pard` default.
|
||||
fn value_of(p: &EffectParam<'_, '_>) -> Option<ParamValue> {
|
||||
p.recorded_value().or_else(|| p.pard_default())
|
||||
}
|
||||
|
||||
/// True when the parameter has keyframes, an expression, or a recorded value other than its
|
||||
/// default (a record without a readable default counts as set).
|
||||
fn is_set(p: &EffectParam<'_, '_>) -> bool {
|
||||
if p.is_animated() || p.stored().is_some_and(|s| s.expression().is_some()) {
|
||||
return true;
|
||||
}
|
||||
match (p.recorded_value(), p.pard_default()) {
|
||||
(Some(v), Some(d)) => !same(v, d),
|
||||
(Some(_), None) => true,
|
||||
(None, _) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Equal within the float noise of a stored `f64` / `f32`.
|
||||
fn same(a: ParamValue, b: ParamValue) -> bool {
|
||||
let close = |x: f64, y: f64| (x - y).abs() <= 1e-6 * x.abs().max(y.abs()).max(1.0);
|
||||
match (a, b) {
|
||||
(ParamValue::Number(x), ParamValue::Number(y)) => close(x, y),
|
||||
(ParamValue::Argb(x), ParamValue::Argb(y)) => x.iter().zip(y).skip(1).all(|(x, y)| close(*x, y)),
|
||||
_ => a == b,
|
||||
}
|
||||
}
|
||||
|
||||
/// A value as the report shows it.
|
||||
fn shown_value(v: Option<ParamValue>) -> String {
|
||||
match v {
|
||||
Some(ParamValue::Number(n)) => {
|
||||
let s = format!("{n:.3}");
|
||||
s.trim_end_matches('0').trim_end_matches('.').to_string()
|
||||
}
|
||||
Some(ParamValue::Bool(b)) => if b { "on" } else { "off" }.to_string(),
|
||||
Some(ParamValue::Index(i)) => format!("item {i}"),
|
||||
Some(ParamValue::Argb([_, r, g, b])) => format!("RGB {:.3} {:.3} {:.3}", r, g, b),
|
||||
Some(other) => format!("{other:?}"),
|
||||
None => "set".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Empty data: absent, `fe fe` (no text, no asset) or only zero bytes.
|
||||
fn is_empty_data(d: Option<&[u8]>) -> bool {
|
||||
match d {
|
||||
None => true,
|
||||
Some(d) => d == [0xfe, 0xfe] || d.iter().all(|b| *b == 0),
|
||||
}
|
||||
}
|
||||
|
||||
fn le_u32(d: &[u8], at: usize) -> Option<u32> {
|
||||
Some(u32::from_le_bytes(*d.get(at..at.checked_add(4)?)?.first_chunk::<4>()?))
|
||||
}
|
||||
|
||||
fn le_f32(d: &[u8], at: usize) -> Option<f32> {
|
||||
Some(f32::from_le_bytes(*d.get(at..at.checked_add(4)?)?.first_chunk::<4>()?))
|
||||
}
|
||||
|
||||
fn le_f64(d: &[u8], at: usize) -> Option<f64> {
|
||||
Some(f64::from_le_bytes(*d.get(at..at.checked_add(8)?)?.first_chunk::<8>()?))
|
||||
}
|
||||
|
||||
/// A UTF-16LE text up to its NUL (the Look's name, `-0096`: `SL BLUE ICE` in 1200 `-01-set-all`);
|
||||
/// `None` for empty data.
|
||||
fn utf16_text(d: &[u8]) -> Option<String> {
|
||||
if is_empty_data(Some(d)) {
|
||||
return None;
|
||||
}
|
||||
let units: Vec<u16> = d.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).take_while(|u| *u != 0).collect();
|
||||
let s = String::from_utf16_lossy(&units);
|
||||
let s = s.trim();
|
||||
(!s.is_empty()).then(|| s.to_string())
|
||||
}
|
||||
|
||||
/// A Lumetri curve (520 bytes): `i32` curve type at 0 (3 for the hue curves, which wrap; 2 for
|
||||
/// Luma vs Sat and Sat vs Sat; 0 for an RGB curve), the `u32` point count at 4, then the points
|
||||
/// as little-endian `f64` pairs x, y from byte 8 (at most 32), sorted by x. Bytes after the
|
||||
/// counted points are stale (a fresh hue curve has count 0 and old points behind it). An RGB
|
||||
/// curve's x and y are 0..1 (2 points 0,0 and 1,1 by default); a hue / saturation curve's x is
|
||||
/// the hue (0 red, 1/3 green) or the luma / saturation, 0..1, and y its offset from neutral.
|
||||
/// `None` when the count does not fit.
|
||||
fn curve_points(d: &[u8]) -> Option<Vec<(f64, f64)>> {
|
||||
let n = usize::try_from(le_u32(d, 4)?).ok()?;
|
||||
if n > d.len().saturating_sub(8) / 16 {
|
||||
return None;
|
||||
}
|
||||
(0..n).map(|i| Some((le_f64(d, 8 + 16 * i)?, le_f64(d, 16 + 16 * i)?))).collect()
|
||||
}
|
||||
|
||||
/// HSL Secondary's key (`-0073`, 40 bytes): a flag byte (1 once a key colour was picked, 0 for a
|
||||
/// fresh effect), then hue, saturation and lightness, each three little-endian `f32` (centre,
|
||||
/// range, tolerance; fractions of the full hue circle / of 0..1) and an enabled byte. A fresh effect
|
||||
/// stores flag 0 and centre 0.5, range 0, tolerance 0, enabled for each (1200 `-00-defaults`);
|
||||
/// After Effects' own copy in `-0001` names the same fields (`middleHLS`, `rangeHLS`,
|
||||
/// `toleranceHLS`, `enableHLS`, hue in degrees: centre 180). Picking Set color RGB 0.02 0.55 0.85
|
||||
/// gives hue centre 0.5605 (that colour's hue, 201.8 degrees) and lightness centre 0.435 (its
|
||||
/// HSL lightness) with range 0.05 and tolerance 0.1 (`-01-set-all`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct HslKey {
|
||||
pub picked: bool,
|
||||
/// Hue, saturation, lightness: centre, range, tolerance, enabled.
|
||||
pub channels: [(f32, f32, f32, bool); 3],
|
||||
}
|
||||
|
||||
impl HslKey {
|
||||
pub fn parse(d: &[u8]) -> Option<HslKey> {
|
||||
if d.len() != 40 {
|
||||
return None;
|
||||
}
|
||||
let picked = match d.first()? {
|
||||
0 => false,
|
||||
1 => true,
|
||||
_ => return None,
|
||||
};
|
||||
let ch = |at: usize| -> Option<(f32, f32, f32, bool)> {
|
||||
let (c, r, t) = (le_f32(d, at)?, le_f32(d, at + 4)?, le_f32(d, at + 8)?);
|
||||
let on = match d.get(at + 12)? {
|
||||
0 => false,
|
||||
1 => true,
|
||||
_ => return None,
|
||||
};
|
||||
(c.is_finite() && r.is_finite() && t.is_finite()).then_some((c, r, t, on))
|
||||
};
|
||||
Some(HslKey { picked, channels: [ch(1)?, ch(14)?, ch(27)?] })
|
||||
}
|
||||
|
||||
fn is_default(&self) -> bool {
|
||||
!self.picked && self.channels.iter().all(|c| *c == (0.5, 0.0, 0.0, true))
|
||||
}
|
||||
}
|
||||
|
||||
/// The colour wheel data: triples of `f64`, each wheel `0, 0.5, 0` by default.
|
||||
fn wheels_default(d: &[u8], wheels: usize) -> bool {
|
||||
d.len() == wheels * 24 && (0..wheels).all(|w| le_f64(d, w * 24) == Some(0.0) && le_f64(d, w * 24 + 8) == Some(0.5) && le_f64(d, w * 24 + 16) == Some(0.0))
|
||||
}
|
||||
|
||||
/// Is the data of `data` at After Effects' default (as far as it can be told)?
|
||||
fn data_default(data: Data, d: Option<&[u8]>) -> bool {
|
||||
let Some(d) = d else { return true };
|
||||
match data {
|
||||
Data::RgbCurves => {
|
||||
d.len() == 4 * 520 && d.as_chunks::<520>().0.iter().all(|c| curve_points(c).is_some_and(|pts| pts.iter().all(|(x, y)| (x - y).abs() < 1e-9)))
|
||||
}
|
||||
Data::HueSatCurve(_) => curve_points(d).is_some_and(|pts| pts.iter().all(|(_, y)| *y == 0.0)),
|
||||
Data::Wheels => wheels_default(d, 4),
|
||||
Data::CorrectionWheels => wheels_default(d, 8),
|
||||
Data::SplitToning | Data::Other => is_empty_data(Some(d)),
|
||||
Data::HslKey => HslKey::parse(d).is_some_and(|k| k.is_default()),
|
||||
}
|
||||
}
|
||||
|
||||
/// What the Input LUT or the Look holds: names, a popup item, an asset.
|
||||
#[derive(Default)]
|
||||
struct LutState {
|
||||
names: Vec<String>,
|
||||
item: Option<String>,
|
||||
asset: bool,
|
||||
}
|
||||
|
||||
/// How a set parameter of a section that does nothing is listed in the report; `None` when it is
|
||||
/// at its default.
|
||||
fn off_text(p: &EffectParam<'_, '_>, role: Role, data: Option<&[u8]>) -> Option<String> {
|
||||
match role {
|
||||
Role::Data(d) => (!data_default(d, data)).then(|| name_of(p)),
|
||||
// A LUT by its name (the popups index the same choice).
|
||||
Role::Lut { lut } if p.kind == K::ArbitraryData => {
|
||||
let what = if lut { "Input LUT" } else { "Look" };
|
||||
data.and_then(utf16_text).map(|n| format!("{what} '{n}'")).or_else(|| (!is_empty_data(data)).then(|| what.to_string()))
|
||||
}
|
||||
// The Look's colour space goes with the Look.
|
||||
Role::Lut { .. } | Role::LookSpace | Role::Derived => None,
|
||||
_ => is_set(p).then(|| format!("{} {}", name_of(p), shown_value(p.recorded_value()))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Notes what an Input LUT or Look parameter holds: a popup item, an asset or a name.
|
||||
fn note_lut(p: &EffectParam<'_, '_>, suffix: u16, data: Option<&[u8]>, st: &mut LutState) {
|
||||
if p.kind == K::Popup {
|
||||
// The popup indexes the LUTs installed with After Effects; item 1 is None.
|
||||
if let Some(ParamValue::Index(i)) = p.recorded_value()
|
||||
&& i > 1
|
||||
{
|
||||
st.item = usize::try_from(i).ok().and_then(|i| p.items.as_ref()?.get(i - 1).cloned()).or(Some(format!("item {i}")));
|
||||
}
|
||||
} else if suffix == 125 || suffix == 126 {
|
||||
st.asset |= !is_empty_data(data);
|
||||
} else if let Some(name) = data.and_then(utf16_text)
|
||||
&& !st.names.contains(&name)
|
||||
{
|
||||
st.names.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
/// Imports a Lumetri Color instance's parameters into `out.group` and reports what does not arrive.
|
||||
pub(crate) fn import_lumetri(fx: &AepEffect<'_, '_>, params: &[EffectParam<'_, '_>], cx: &LayerCtx<'_>, shown: &str, out: ParamOut<'_>) {
|
||||
let ParamOut { group, refs, col } = out;
|
||||
let here = format!("{}: '{}' effect '{shown}' ({})", cx.comp, cx.layer, fx.match_name());
|
||||
let find = |s: u16| params.iter().find(|p| suffix(&p.match_name) == Some(s));
|
||||
// The switches: on unless a record says off. Keys or an expression on a switch are not
|
||||
// imported (the static value counts).
|
||||
let switch_on = |s: u16| -> bool {
|
||||
match find(s) {
|
||||
Some(p) => !matches!(value_of(p), Some(ParamValue::Bool(false))),
|
||||
None => true,
|
||||
}
|
||||
};
|
||||
// HSL Secondary without a picked key colour selects nothing: After Effects applies none of
|
||||
// its correction (1200 `-16-sec-correction`, `-15-sec-refine` render as `-00-defaults`);
|
||||
// EffectCraft's default key is the whole image, so the values are left out too.
|
||||
// Unreadable or missing key data does not count as unpicked (it is reported or older).
|
||||
let key_picked = find(73).and_then(|p| fx.arbitrary_data(&p.match_name)).and_then(HslKey::parse).is_none_or(|k| k.picked);
|
||||
let section_on = |sec: Section| -> bool {
|
||||
match sec {
|
||||
Section::Always => true,
|
||||
Section::BasicCorrection => switch_on(3),
|
||||
Section::Creative => switch_on(23),
|
||||
Section::RgbCurves => switch_on(37) && switch_on(118),
|
||||
Section::HueSat(i) => switch_on(37) && switch_on([104, 105, 107, 109, 111].get(usize::from(i)).copied().unwrap_or(104)),
|
||||
Section::ColorWheels => switch_on(46),
|
||||
Section::HslSecondary => switch_on(68) && key_picked,
|
||||
Section::Vignette => switch_on(50),
|
||||
// Custom Layer off is itself reported (a switch without counterpart).
|
||||
Section::SpeedGradeCustom => true,
|
||||
}
|
||||
};
|
||||
let mut off: Vec<(Section, Vec<String>)> = Vec::new();
|
||||
let mut eyedroppers: Vec<String> = Vec::new();
|
||||
let mut luts = [LutState::default(), LutState::default()];
|
||||
for p in params {
|
||||
if carries_nothing(p) || p.match_name == "ADBE Effect Built In Params" {
|
||||
continue;
|
||||
}
|
||||
let label = if p.name.trim().is_empty() { format!("{} ({})", name_of(p), p.match_name) } else { param_label(p) };
|
||||
let Some(row) = suffix(&p.match_name).and_then(|s| ROWS.iter().find(|r| r.suffix == s)) else {
|
||||
// A parameter this table does not know (a newer version): lost when set.
|
||||
if p.stored().is_some()
|
||||
&& (p.kind != K::ArbitraryData && is_set(p) || p.kind == K::ArbitraryData && !is_empty_data(fx.arbitrary_data(&p.match_name)))
|
||||
{
|
||||
col.lose(LossCategory::Effects, format!("{here}: parameter {label} not imported"));
|
||||
}
|
||||
continue;
|
||||
};
|
||||
if row.kind.is_some_and(|k| k != p.kind) {
|
||||
if p.stored().is_some() {
|
||||
col.lose(LossCategory::Effects, format!("{here}: parameter {label} has type {:?}, expected {:?}; not imported", p.kind, row.kind));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let data = || fx.arbitrary_data(&p.match_name);
|
||||
// A switched-off section: its set values are listed, not imported.
|
||||
if !matches!(row.role, Role::Switch) && !section_on(row.section) {
|
||||
let text = off_text(p, row.role, data());
|
||||
if let Some(text) = text {
|
||||
match off.iter_mut().find(|(s, _)| *s == row.section) {
|
||||
Some((_, list)) => {
|
||||
if !list.contains(&text) {
|
||||
list.push(text);
|
||||
}
|
||||
}
|
||||
None => off.push((row.section, vec![text])),
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match row.role {
|
||||
Role::To(target, conv) => {
|
||||
let m = ParamMap { index: p.index, kind: p.kind, target, conv, limit: None };
|
||||
import_mapped(p, &m, cx, (&here, &label), ParamOut { group: &mut *group, refs: &mut *refs, col: &mut *col });
|
||||
}
|
||||
Role::Switch => {
|
||||
if p.is_animated() || p.stored().is_some_and(|s| s.expression().is_some()) {
|
||||
col.lose(LossCategory::Effects, format!("{here}: keyframes or an expression on the switch {label} are not imported"));
|
||||
}
|
||||
if row.section == Section::SpeedGradeCustom && !switch_on(row.suffix) {
|
||||
col.lose(LossCategory::Effects, format!("{here}: parameter {label} is off and has no counterpart"));
|
||||
}
|
||||
}
|
||||
Role::MissingWhenOn => {
|
||||
if p.is_animated() || matches!(p.recorded_value(), Some(ParamValue::Bool(true))) {
|
||||
col.lose(LossCategory::Effects, format!("{here}: parameter {label} is on and has no counterpart, not imported"));
|
||||
}
|
||||
}
|
||||
Role::Missing => {
|
||||
if is_set(p) {
|
||||
col.lose(
|
||||
LossCategory::Effects,
|
||||
format!("{here}: parameter {label} is {} and has no counterpart, not imported", shown_value(p.recorded_value())),
|
||||
);
|
||||
}
|
||||
}
|
||||
Role::Eyedropper => {
|
||||
if is_set(p) {
|
||||
eyedroppers.push(name_of(p));
|
||||
}
|
||||
}
|
||||
Role::Lut { lut } => note_lut(p, row.suffix, data(), &mut luts[usize::from(!lut)]),
|
||||
// The Look's colour space only applies to a Look, which is reported.
|
||||
Role::LookSpace => {}
|
||||
Role::Data(_) if p.stored().is_some() && data().is_none() => {
|
||||
// The value record is there but its aRbs/aRbp payload is not: damaged, not the
|
||||
// default (a parameter without a record is the default).
|
||||
col.lose(LossCategory::DamagedData, format!("{here}: the data of {label} is missing, not imported (the default used)"));
|
||||
}
|
||||
Role::Data(Data::HslKey) => import_hsl_key(data(), &here, &mut *group, &mut *col),
|
||||
Role::Data(d) => {
|
||||
if p.is_animated() || p.stored().is_some_and(|s| s.expression().is_some()) {
|
||||
col.lose(LossCategory::Effects, format!("{here}: keyframes or an expression on {label} are not imported"));
|
||||
}
|
||||
if !data_default(d, data()) {
|
||||
let why = match d {
|
||||
Data::RgbCurves => {
|
||||
import_rgb_curves(data(), (&here, &label), &mut *group, &mut *col);
|
||||
continue;
|
||||
}
|
||||
Data::HueSatCurve(Some(target)) => {
|
||||
import_hue_sat_curve(data(), target, (&here, &label), &mut *group, &mut *col);
|
||||
continue;
|
||||
}
|
||||
Data::Wheels => {
|
||||
import_wheels(data(), (&here, &label), &mut *group, &mut *col);
|
||||
continue;
|
||||
}
|
||||
Data::HueSatCurve(None) => "the curve's points are moved; no sample tells their scale",
|
||||
Data::CorrectionWheels | Data::SplitToning => "the data is not decoded (not sampled)",
|
||||
Data::HslKey | Data::Other => "no counterpart",
|
||||
};
|
||||
col.lose(LossCategory::Effects, format!("{here}: parameter {label} is not imported ({why})"));
|
||||
}
|
||||
}
|
||||
Role::Derived => {}
|
||||
}
|
||||
}
|
||||
for (sec, list) in off {
|
||||
let why = if sec == Section::HslSecondary && switch_on(68) {
|
||||
"has no key colour picked (After Effects applies it to nothing)"
|
||||
} else {
|
||||
"is switched off (EffectCraft's Lumetri has no section switch)"
|
||||
};
|
||||
col.lose(LossCategory::Effects, format!("{here}: section {} {why}, its values are not imported: {}", sec.name(), list.join(", ")));
|
||||
}
|
||||
if !eyedroppers.is_empty() {
|
||||
col.lose(LossCategory::Effects, format!("{here}: the eyedropper colours {} are not imported (they only set other controls)", eyedroppers.join(", ")));
|
||||
}
|
||||
for (st, what) in luts.iter().zip(["Input LUT", "Look"]) {
|
||||
let name = st.names.first().cloned().or_else(|| st.item.clone());
|
||||
if name.is_some() || st.asset {
|
||||
let name = name.map(|n| format!(" '{n}'")).unwrap_or_default();
|
||||
col.lose(
|
||||
LossCategory::Effects,
|
||||
format!("{here}: the {what}{name} is not imported (the LUT file is not in the project; EffectCraft's {what} stays None)"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// HSL Secondary's key as EffectCraft's hue centre / range / softness (degrees) and saturation and
|
||||
/// lightness bands (percent): range is taken as the half width, tolerance as the softness. A key
|
||||
/// that was never picked keeps EffectCraft's default (the whole image).
|
||||
fn import_hsl_key(d: Option<&[u8]>, here: &str, group: &mut effectcraft_project::PropGroup, col: &mut crate::import::report::Collector) {
|
||||
let Some(d) = d else { return };
|
||||
let Some(key) = HslKey::parse(d) else {
|
||||
col.lose(LossCategory::DamagedData, format!("{here}: the HSL Secondary key data is unreadable, the default key used"));
|
||||
return;
|
||||
};
|
||||
if !key.picked {
|
||||
if !key.is_default() {
|
||||
col.lose(LossCategory::Effects, format!("{here}: the HSL Secondary key holds values without a picked colour, not imported"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
let mut set = |id: &str, v: Value| {
|
||||
if let Some(prop) = group.prop_mut(&format!("hslSecondary/key/{id}")) {
|
||||
prop.value = v;
|
||||
}
|
||||
};
|
||||
let [h, s, l] = key.channels.map(|(c, r, t, on)| (f64::from(c), f64::from(r), f64::from(t), on));
|
||||
set("hueEnabled", Value::Bool(h.3));
|
||||
set("hueCenter", Value::Scalar((h.0 * 360.0).rem_euclid(360.0)));
|
||||
set("hueRange", Value::Scalar((h.1 * 360.0).clamp(0.0, 180.0)));
|
||||
set("hueSoftness", Value::Scalar((h.2 * 360.0).clamp(0.0, 180.0)));
|
||||
for ((c, r, t, on), id) in [(s.0, s.1, s.2, s.3), (l.0, l.1, l.2, l.3)].into_iter().zip(["saturation", "lightness"]) {
|
||||
set(&format!("{id}Enabled"), Value::Bool(on));
|
||||
set(&format!("{id}Low"), Value::Scalar(((c - r) * 100.0).clamp(0.0, 100.0)));
|
||||
set(&format!("{id}High"), Value::Scalar(((c + r) * 100.0).clamp(0.0, 100.0)));
|
||||
set(&format!("{id}Softness"), Value::Scalar((t * 100.0).clamp(0.0, 100.0)));
|
||||
}
|
||||
}
|
||||
|
||||
/// A parameter of `group` set to `v` (ids the spec does not have are skipped).
|
||||
fn set_prop(group: &mut effectcraft_project::PropGroup, id: &str, v: Value) {
|
||||
if let Some(prop) = group.prop_mut(id) {
|
||||
prop.value = v;
|
||||
}
|
||||
}
|
||||
|
||||
/// Curve points as EffectCraft's `x,y x,y ...` text, x and y clamped to 0..1; `None` when a value
|
||||
/// is not finite.
|
||||
fn points_text(pts: impl IntoIterator<Item = (f64, f64)>) -> Option<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for (x, y) in pts {
|
||||
if !x.is_finite() || !y.is_finite() {
|
||||
return None;
|
||||
}
|
||||
out.push(format!("{},{}", x.clamp(0.0, 1.0), y.clamp(0.0, 1.0)));
|
||||
}
|
||||
Some(out.join(" "))
|
||||
}
|
||||
|
||||
/// The RGB curve (`-0039`, four curves of 520 bytes). The first is the master curve (1200
|
||||
/// `lumetri-60-preset-curves-rgb`: its midpoint raised to 0.787 lifts R, G and B alike) and
|
||||
/// becomes `curves/rgbCurves/master`. The other three are the channel curves, but no sample moves
|
||||
/// one, so which is red, green or blue is not known: a moved one is reported.
|
||||
fn import_rgb_curves(d: Option<&[u8]>, (here, label): (&str, &str), group: &mut effectcraft_project::PropGroup, col: &mut crate::import::report::Collector) {
|
||||
let Some(d) = d else { return };
|
||||
let curves: Option<Vec<Vec<(f64, f64)>>> = if d.len() == 4 * 520 { d.as_chunks::<520>().0.iter().map(|c| curve_points(c)).collect() } else { None };
|
||||
let Some(curves) = curves else {
|
||||
col.lose(LossCategory::DamagedData, format!("{here}: the data of {label} is unreadable, the curves are not imported"));
|
||||
return;
|
||||
};
|
||||
let identity = |pts: &[(f64, f64)]| pts.iter().all(|(x, y)| (x - y).abs() < 1e-9);
|
||||
if let Some(master) = curves.first().filter(|c| !identity(c)) {
|
||||
match points_text(master.iter().copied()) {
|
||||
Some(text) => set_prop(group, "curves/rgbCurves/master", Value::Str(text)),
|
||||
None => col.lose(LossCategory::DamagedData, format!("{here}: the master curve of {label} has unreadable points, not imported")),
|
||||
}
|
||||
}
|
||||
let moved = curves.iter().skip(1).filter(|c| !identity(c)).count();
|
||||
if moved > 0 {
|
||||
col.lose(
|
||||
LossCategory::Effects,
|
||||
format!("{here}: {moved} channel curve(s) of {label} are not imported (no sample tells which curve is red, green or blue)"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Hue vs Sat (`-0042`): After Effects' y is an offset from neutral where 1 doubles the
|
||||
/// saturation (1200 `lumetri-61-preset-curves-huesat`: a point at hue 0.0155, y 0.97 scales pure
|
||||
/// red's saturation by about 1.96 in linear light; pins at -0.03 and -0.02 scale green, blue and
|
||||
/// cyan by 0.97). EffectCraft's y = 1 doubles it and 0.5 is neutral, so y becomes `0.5 + y / 2`.
|
||||
/// x is the hue in both (0 red, 1/3 green).
|
||||
fn import_hue_sat_curve(
|
||||
d: Option<&[u8]>,
|
||||
target: &str,
|
||||
(here, label): (&str, &str),
|
||||
group: &mut effectcraft_project::PropGroup,
|
||||
col: &mut crate::import::report::Collector,
|
||||
) {
|
||||
let Some(d) = d else { return };
|
||||
match curve_points(d).and_then(|pts| points_text(pts.into_iter().map(|(x, y)| (x, 0.5 + y / 2.0)))) {
|
||||
Some(text) => set_prop(group, target, Value::Str(text)),
|
||||
None => col.lose(LossCategory::DamagedData, format!("{here}: the data of {label} is unreadable, the curve is not imported")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The colour EffectCraft's wheel takes for After Effects' hue (degrees, 0 red, 120 green) and
|
||||
/// distance from the centre (0..1): the HSL colour of that hue with saturation = distance and
|
||||
/// lightness 0.5 (grey 0.5 at distance 0). EffectCraft's wheel adds that colour's offset from its
|
||||
/// grey, so the direction is After Effects' and the strength grows with the distance.
|
||||
fn wheel_colour(hue: f64, distance: f64) -> [f64; 4] {
|
||||
let c = distance.clamp(0.0, 1.0);
|
||||
let h = hue.rem_euclid(360.0) / 60.0;
|
||||
let x = c * (1.0 - (h.rem_euclid(2.0) - 1.0).abs());
|
||||
let (r, g, b) = match h {
|
||||
h if h < 1.0 => (c, x, 0.0),
|
||||
h if h < 2.0 => (x, c, 0.0),
|
||||
h if h < 3.0 => (0.0, c, x),
|
||||
h if h < 4.0 => (0.0, x, c),
|
||||
h if h < 5.0 => (x, 0.0, c),
|
||||
_ => (c, 0.0, x),
|
||||
};
|
||||
let m = 0.5 - c / 2.0;
|
||||
[r + m, g + m, b + m, 1.0]
|
||||
}
|
||||
|
||||
/// Color Wheels (`-0047`, 96 bytes): four wheels of three little-endian `f64`, the hue in degrees,
|
||||
/// the brightness slider (0..1, 0.5 neutral) and the distance from the centre (0..1); `0, 0.5, 0`
|
||||
/// each by default. The first three are Shadows, Midtones and Highlights (1200
|
||||
/// `lumetri-62-preset-wheels`: Shadows 229 degrees (blue), slider 0.37, distance 0.55; Midtones
|
||||
/// 0.1 degrees (red), 0.5, 0.39; Highlights 93 degrees (yellow-green), 0.73, 0.32); the fourth is
|
||||
/// not known. Hue and distance become EffectCraft's wheel colour ([`wheel_colour`]); the brightness
|
||||
/// sliders and a set fourth wheel have no counterpart and are reported.
|
||||
fn import_wheels(d: Option<&[u8]>, (here, label): (&str, &str), group: &mut effectcraft_project::PropGroup, col: &mut crate::import::report::Collector) {
|
||||
let Some(d) = d else { return };
|
||||
let wheels: Option<Vec<[f64; 3]>> = if d.len() == 4 * 24 {
|
||||
(0..4).map(|w| Some([le_f64(d, w * 24)?, le_f64(d, w * 24 + 8)?, le_f64(d, w * 24 + 16)?]).filter(|v| v.iter().all(|x| x.is_finite()))).collect()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let Some(wheels) = wheels else {
|
||||
col.lose(LossCategory::DamagedData, format!("{here}: the data of {label} is unreadable, the colour wheels are not imported"));
|
||||
return;
|
||||
};
|
||||
let mut lost: Vec<String> = Vec::new();
|
||||
for (w, (id, name)) in wheels.iter().zip([("shadowsWheel", "Shadows"), ("midtonesWheel", "Midtones"), ("highlightsWheel", "Highlights")]) {
|
||||
let [hue, brightness, distance] = *w;
|
||||
if distance != 0.0 {
|
||||
set_prop(group, &format!("colorWheels/{id}"), Value::Color(wheel_colour(hue, distance)));
|
||||
}
|
||||
if (brightness - 0.5).abs() > 1e-9 {
|
||||
lost.push(format!("{name} brightness {brightness:.3} (0.5 is neutral)"));
|
||||
}
|
||||
}
|
||||
if wheels.get(3).is_some_and(|w| *w != [0.0, 0.5, 0.0]) {
|
||||
lost.push("the fourth wheel (its role is not known)".to_string());
|
||||
}
|
||||
if !lost.is_empty() {
|
||||
col.lose(LossCategory::Effects, format!("{here}: {label}: {} not imported (no counterpart)", lost.join(", ")));
|
||||
}
|
||||
}
|
||||
@@ -136,8 +136,12 @@ const IGNORED: [&str; 4] = ["ADBE Position_0", "ADBE Position_1", "ADBE Position
|
||||
/// How the stored Anchor Point becomes pixels.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum AnchorUnit {
|
||||
/// A fraction of a source this big (pixels): AV layers.
|
||||
/// A fraction of a source this big (pixels): AV layers; the source's centre when absent.
|
||||
Source(u32, u32),
|
||||
/// A fraction of a null object's source this big (pixels); its top-left corner when absent:
|
||||
/// After Effects 2026 reads a null's absent Anchor Point as `[0, 0]` (#3099: two nulls made
|
||||
/// with `addNull`, one left at its default, read back `[0, 0, 0]` and `[10, 20, 0]`).
|
||||
Null(u32, u32),
|
||||
/// Pixels as stored, the origin when absent: text (385) and shape (462) layers, which have no
|
||||
/// source.
|
||||
Pixels,
|
||||
@@ -169,6 +173,14 @@ pub(crate) fn import_transform(
|
||||
let (sw, sh) = (f64::from(w), f64::from(h));
|
||||
(Kind::Anchor(sw, sh), tr.anchor_px(sw, sh))
|
||||
}
|
||||
AnchorUnit::Null(w, h) => {
|
||||
let (sw, sh) = (f64::from(w), f64::from(h));
|
||||
let px = match tr.anchor_fraction.source {
|
||||
ValueSource::Default | ValueSource::Unreadable => [0.0; 3],
|
||||
ValueSource::Stored | ValueSource::Keyframed => tr.anchor_px(sw, sh),
|
||||
};
|
||||
(Kind::Anchor(sw, sh), px)
|
||||
}
|
||||
AnchorUnit::Pixels | AnchorUnit::PointOfInterest => (Kind::Anchor(1.0, 1.0), tr.text_anchor_px()),
|
||||
};
|
||||
let poi = anchor == AnchorUnit::PointOfInterest;
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
//! After Effects' Puppet effect (`ADBE FreePin3`) arrives as EffectCraft's Puppet
|
||||
//! (`ec.distort.puppet`) in its place in the stack, with its name and fx switch, but **without
|
||||
//! its meshes and pins**: how After Effects stores a pin's rest place (a vertex index and offset
|
||||
//! into the hidden mesh data) and its Position (pixels or a fraction of the layer) is not decoded
|
||||
//! yet, and a guessed rest place would deform the layer wrongly without a word. What the instance
|
||||
//! holds is reported instead, mesh by mesh: Triangles, Expansion, and per pin container (Deform,
|
||||
//! Stiffness, Overlap) the number of pins, how many are keyed or carry an expression, and the
|
||||
//! match names of their atoms (Advanced and Bend pins show up there). Any entry this reader does
|
||||
//! not know is named verbatim.
|
||||
//!
|
||||
//! The tree read here (docs/aep-format.md 3.7, Puppet) comes from an external scripting listing
|
||||
//! of the effect's property tree, not from the corpus (no corpus file has a Puppet): **?** until a
|
||||
//! byte sample exists. It is read with the same verified group reader as masks, shapes and layer
|
||||
//! styles; a file whose tree differs is reported, never guessed at.
|
||||
|
||||
use effectcraft_project::build::Ids;
|
||||
use effectcraft_project::{Node, PropGroup};
|
||||
|
||||
use crate::import::effects::{LayerCtx, carries_nothing};
|
||||
use crate::import::report::{Collector, LossCategory};
|
||||
use crate::{Effect as AepEffect, EffectDefinitions, ParamValue, PropGroup as AepGroup, PropNode, Property as AepProperty};
|
||||
|
||||
/// After Effects' Puppet effect.
|
||||
pub(crate) const MATCH_NAME: &str = "ADBE FreePin3";
|
||||
/// The hidden group that holds the meshes (`arap`).
|
||||
const ARAP: &str = "ADBE FreePin3 ARAP Group";
|
||||
/// Auto-traced Shapes, beside the meshes.
|
||||
const OUTLINES: &str = "ADBE FreePin3 Outlines";
|
||||
/// The hidden group of the meshes.
|
||||
const MESH_GROUP: &str = "ADBE FreePin3 Mesh Group";
|
||||
/// One mesh (`Mesh 1`).
|
||||
const MESH_ATOM: &str = "ADBE FreePin3 Mesh Atom";
|
||||
/// A mesh's hidden triangulation.
|
||||
const MESH_DATA: &str = "ADBE FreePin3 Mesh";
|
||||
const TRIANGLES: &str = "ADBE FreePin3 Mesh Tri Count";
|
||||
const EXPANSION: &str = "ADBE FreePin3 Mesh Expansion";
|
||||
/// At most this many meshes, pin atom names or unrecognised names are listed in a report line;
|
||||
/// the rest are counted (`and 3 more`).
|
||||
const LIST_CAP: usize = 10;
|
||||
/// The pin containers of a mesh: match name and the name After Effects shows.
|
||||
const PIN_GROUPS: [(&str, &str); 3] = [("ADBE FreePin3 PosPins", "Deform"), ("ADBE FreePin3 HghtPins", "Overlap"), ("ADBE FreePin3 StarchPins", "Stiffness")];
|
||||
|
||||
/// Imports a Puppet instance (the caller checked the match name) into `target`.
|
||||
pub(crate) fn import_puppet<'f, 'a>(
|
||||
defs: &EffectDefinitions<'f, 'a>,
|
||||
fx: &AepEffect<'f, 'a>,
|
||||
cx: &LayerCtx<'_>,
|
||||
ids: &mut Ids<'_>,
|
||||
target: &mut PropGroup,
|
||||
col: &mut Collector,
|
||||
) {
|
||||
let shown = fx.display_name().unwrap_or_else(|| fx.match_name().to_string());
|
||||
let here = format!("{}: '{}' effect '{shown}' ({MATCH_NAME})", cx.comp, cx.layer);
|
||||
let Some(spec) = effectcraft_effects::find(effectcraft_effects::puppet::ID) else {
|
||||
col.lose(LossCategory::Effects, format!("{}: '{}' has the effect '{shown}' ({MATCH_NAME})", cx.comp, cx.layer));
|
||||
return;
|
||||
};
|
||||
let size = [f64::from(cx.size.0), f64::from(cx.size.1)];
|
||||
let mut group = effectcraft_effects::instantiate(spec, ids, &shown, size);
|
||||
match fx.enabled() {
|
||||
Some(on) => group.enabled = on,
|
||||
None => col.lose(LossCategory::DamagedData, format!("{}: '{}': the fx switch of '{shown}' is unreadable, imported switched on", cx.comp, cx.layer)),
|
||||
}
|
||||
// The ordinary parameters (`parT`): none is mapped yet, so a stored one is reported.
|
||||
let params = defs.params(fx);
|
||||
for p in ¶ms {
|
||||
if carries_nothing(p) {
|
||||
continue;
|
||||
}
|
||||
if p.stored().is_some() && !matches!(p.stored_value(), Some(ParamValue::Layer(0))) {
|
||||
let label = if p.name.is_empty() { p.match_name.clone() } else { format!("{} ({})", p.name, p.match_name) };
|
||||
col.lose(LossCategory::Effects, format!("{here}: parameter {label} not imported"));
|
||||
}
|
||||
}
|
||||
report_meshes(fx, ¶ms.iter().map(|p| p.match_name.as_str()).collect::<Vec<_>>(), &here, col);
|
||||
// The effect itself arrives (an EffectCraft Puppet the user can pin again), so it counts as
|
||||
// imported; what it held is in the losses above.
|
||||
col.imported.effects = col.imported.effects.saturating_add(1);
|
||||
target.children.push(Node::Group(group));
|
||||
}
|
||||
|
||||
/// Reports the meshes and pins of `fx` and every entry of its value group that is neither a
|
||||
/// `parT` parameter (`param_names`) nor the mesh tree.
|
||||
fn report_meshes(fx: &AepEffect<'_, '_>, param_names: &[&str], here: &str, col: &mut Collector) {
|
||||
let mut unknown: Vec<String> = Vec::new();
|
||||
let values = fx.chunk().child_list(b"tdgp").and_then(AepGroup::from_chunk);
|
||||
let mut arap = None;
|
||||
for e in values.map(|g| g.entries()).unwrap_or_default() {
|
||||
if e.match_name == ARAP {
|
||||
if let PropNode::Group(g) = e.node {
|
||||
if arap.is_none() {
|
||||
arap = Some(g);
|
||||
}
|
||||
} else {
|
||||
push_unique(&mut unknown, e.match_name);
|
||||
}
|
||||
} else if !param_names.contains(&e.match_name.as_str()) {
|
||||
match e.node {
|
||||
// A value outside `parT` (On Transparent, ...): reported with what it holds.
|
||||
PropNode::Property(p) => col.lose(LossCategory::Effects, format!("{here}: property {} ({}) not imported", e.match_name, describe(&p))),
|
||||
PropNode::Group(_) | PropNode::Other(_) => push_unique(&mut unknown, e.match_name),
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut meshes: Vec<String> = Vec::new();
|
||||
let mut found = false;
|
||||
if let Some(arap) = arap {
|
||||
for e in arap.entries() {
|
||||
match (e.match_name.as_str(), e.node) {
|
||||
(MESH_GROUP, PropNode::Group(g)) if !found => {
|
||||
found = true;
|
||||
for m in g.entries() {
|
||||
match (m.match_name.as_str(), m.node) {
|
||||
(MESH_ATOM, PropNode::Group(mesh)) => {
|
||||
let n = meshes.len().saturating_add(1);
|
||||
meshes.push(mesh_summary(&mesh, n, &mut unknown));
|
||||
}
|
||||
_ => push_unique(&mut unknown, m.match_name.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
// Auto-traced Shapes are made from the layer again (EffectCraft traces the alpha).
|
||||
(OUTLINES, _) => {}
|
||||
_ => push_unique(&mut unknown, e.match_name.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
let extra = if unknown.is_empty() { String::new() } else { format!("; not recognised: {}", capped(&unknown, ", ")) };
|
||||
if !found {
|
||||
col.lose(
|
||||
LossCategory::Effects,
|
||||
format!("{here}: imported without meshes or pins: its mesh group ({ARAP} / {MESH_GROUP}) was not found or is not a group{extra}"),
|
||||
);
|
||||
} else if !meshes.is_empty() {
|
||||
col.lose(
|
||||
LossCategory::Effects,
|
||||
format!(
|
||||
"{here}: imported without its meshes and pins (After Effects' pin rest places and positions are not decoded yet): {}{extra}",
|
||||
capped(&meshes, "; ")
|
||||
),
|
||||
);
|
||||
} else if !unknown.is_empty() {
|
||||
col.lose(LossCategory::Effects, format!("{here}: no mesh{extra}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// `Mesh n (Triangles t, Expansion e): Deform 3 pins (1 keyed) [atom names], Overlap no pins, ...`
|
||||
fn mesh_summary(mesh: &AepGroup<'_, '_>, n: usize, unknown: &mut Vec<String>) -> String {
|
||||
let name = mesh.user_name().unwrap_or_else(|| format!("Mesh {n}"));
|
||||
let mut settings: Vec<String> = Vec::new();
|
||||
let mut containers: Vec<String> = Vec::new();
|
||||
for e in mesh.entries() {
|
||||
let label = match e.match_name.as_str() {
|
||||
TRIANGLES => Some("Triangles"),
|
||||
EXPANSION => Some("Expansion"),
|
||||
_ => None,
|
||||
};
|
||||
match (label, e.node) {
|
||||
(Some(label), PropNode::Property(p)) => settings.push(format!("{label} {}", describe(&p))),
|
||||
// The triangulation After Effects keeps; EffectCraft builds its own.
|
||||
_ if e.match_name == MESH_DATA => {}
|
||||
_ => match (PIN_GROUPS.iter().find(|(m, _)| *m == e.match_name), e.node) {
|
||||
(Some((_, shown)), PropNode::Group(g)) => containers.push(format!("{shown} {}", pins_summary(&g, unknown))),
|
||||
_ => push_unique(unknown, e.match_name),
|
||||
},
|
||||
}
|
||||
}
|
||||
let settings = if settings.is_empty() { String::new() } else { format!(" ({})", settings.join(", ")) };
|
||||
let containers = if containers.is_empty() { "no pin groups".to_string() } else { containers.join(", ") };
|
||||
format!("{name}{settings}: {containers}")
|
||||
}
|
||||
|
||||
/// `3 pins (1 keyed, 1 with an expression) [ADBE FreePin3 PosPin Atom]`, or `no pins`. An entry
|
||||
/// of the container that is not a group (a pin is one) goes to `unknown`.
|
||||
fn pins_summary(container: &AepGroup<'_, '_>, unknown: &mut Vec<String>) -> String {
|
||||
let mut count = 0usize;
|
||||
let mut keyed = 0usize;
|
||||
let mut expressions = 0usize;
|
||||
let mut atoms: Vec<String> = Vec::new();
|
||||
for e in container.entries() {
|
||||
let PropNode::Group(pin) = e.node else {
|
||||
push_unique(unknown, e.match_name);
|
||||
continue;
|
||||
};
|
||||
count = count.saturating_add(1);
|
||||
push_unique(&mut atoms, e.match_name);
|
||||
let props: Vec<AepProperty<'_, '_>> =
|
||||
pin.entries().into_iter().filter_map(|p| if let PropNode::Property(p) = p.node { Some(p) } else { None }).collect();
|
||||
if props.iter().any(AepProperty::is_animated) {
|
||||
keyed = keyed.saturating_add(1);
|
||||
}
|
||||
if props.iter().any(|p| p.expression().is_some()) {
|
||||
expressions = expressions.saturating_add(1);
|
||||
}
|
||||
}
|
||||
if count == 0 {
|
||||
return "no pins".to_string();
|
||||
}
|
||||
let mut what = vec![format!("{keyed} keyed")];
|
||||
if expressions > 0 {
|
||||
what.push(format!("{expressions} with an expression"));
|
||||
}
|
||||
format!("{count} pin{} ({}) [{}]", if count == 1 { "" } else { "s" }, what.join(", "), capped(&atoms, ", "))
|
||||
}
|
||||
|
||||
/// A stored property's value for the report: `keyed`, the static value, or `unreadable`.
|
||||
fn describe(p: &AepProperty<'_, '_>) -> String {
|
||||
if p.is_animated() {
|
||||
return "keyed".to_string();
|
||||
}
|
||||
match p.static_value() {
|
||||
Some(v) if v.len() == 1 => v.first().map(|x| format!("{x}")).unwrap_or_default(),
|
||||
Some(v) => format!("[{}]", v.iter().map(|x| format!("{x}")).collect::<Vec<_>>().join(", ")),
|
||||
None => "unreadable".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds `name` to `names` unless it is there already.
|
||||
fn push_unique(names: &mut Vec<String>, name: String) {
|
||||
if !names.contains(&name) {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
/// The first [`LIST_CAP`] of `items` joined by `sep`, and how many more there are.
|
||||
fn capped(items: &[String], sep: &str) -> String {
|
||||
let shown: Vec<&str> = items.iter().take(LIST_CAP).map(String::as_str).collect();
|
||||
let mut out = shown.join(sep);
|
||||
let more = items.len().saturating_sub(LIST_CAP);
|
||||
if more > 0 {
|
||||
out.push_str(&format!("{sep}and {more} more"));
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -26,10 +26,11 @@ pub(crate) const MAX_TEXT_CHARS: usize = 1 << 20;
|
||||
/// U+0003 (END OF TEXT): After Effects' other paragraph break besides `\r`.
|
||||
const ETX: char = '\u{3}';
|
||||
|
||||
/// The first paragraph of a stored text, at most 64 characters: the name After Effects shows for
|
||||
/// a text layer without a name of its own (U+0003 ends a paragraph like `\r`).
|
||||
/// The name of a text layer without a name of its own: its first paragraph, cut as EffectCraft
|
||||
/// cuts a following name ([`effectcraft_project::text_layer_name`]), so a following name that
|
||||
/// export writes as no name comes back unchanged (#3233). U+0003 ends a paragraph like `\r`.
|
||||
pub(crate) fn first_paragraph(raw: &str) -> String {
|
||||
raw.split(['\r', ETX]).next().unwrap_or_default().chars().take(64).collect()
|
||||
effectcraft_project::text_layer_name(raw.split(ETX).next().unwrap_or_default())
|
||||
}
|
||||
|
||||
/// The Source Text value of `doc`, with what cannot be mapped reported for the layer `what`.
|
||||
|
||||
@@ -16,11 +16,12 @@ pub mod import;
|
||||
|
||||
pub use container::{
|
||||
AeVersion, AepError, AepFile, AutoKern, BlendMode, Body, CURVE_IDENTITY, CameraPosition, Caps, Chunk, Comp, CompHeader, CryptomatteData, CueType,
|
||||
CurvesData, DEFAULT_CAMERA_ZOOM, DefinitionSource, Effect, EffectDefinitions, EffectParam, ExpressionFlags, FontBaseline, Footage, GradientType,
|
||||
HueSaturationData, Interpolation, Item, ItemKind, Justification, KernRun, KeyEase, KeyLayout, KeyList, Keyframe, KeyframeKind, LIST, Layer, LayerHeader,
|
||||
LayerRef, LayerType, LevelsData, LightType, Limits, ListHeader, Marker, Mask, MaskAtom, MaskMode, MatteMode, MeshData, NESTED_IDS, Orientation,
|
||||
OrientationKey, ParagraphRun, ParamKind, ParamValue, PathVertex, PolystarType, Project, ProjectSettings, PropEntry, PropGroup, PropNode, Property, Quality,
|
||||
Rational, Renderer, SequenceInfo, ShapeDetail, ShapeGroup, ShapeItem, ShapePath, SpatialKey, Stroke, StrokeCap, StrokeJoin, StyleRun, TextBox,
|
||||
TextDocument, TextValue, TimeBase, TimeDisplay, TimeRemap, TimeRemapKey, TrackMatte, Tracked, Transform, ValueSource, Walk, WorkingSpace, curve_lut,
|
||||
effect_map, is_aepx, parse, parse_any, parse_text_dict, parse_with_limits, read_aepx, read_aepx_with_limits, style_map, write, write_to,
|
||||
CurvesData, DEFAULT_CAMERA_ZOOM, DefinitionSource, Effect, EffectDefinitions, EffectParam, ExpressionFlags, ExtractorChannels, FontBaseline, Footage,
|
||||
GradientType, HueSaturationData, Interpolation, Item, ItemKind, Justification, KernRun, KeyEase, KeyLayout, KeyList, Keyframe, KeyframeKind, LIST, Layer,
|
||||
LayerHeader, LayerRef, LayerType, LevelsData, LightType, Limits, ListHeader, Marker, Mask, MaskAtom, MaskMode, MatteMode, MeshData, NESTED_IDS,
|
||||
Orientation, OrientationKey, ParagraphRun, ParamKind, ParamValue, PathVertex, PolystarType, Project, ProjectSettings, PropEntry, PropGroup, PropNode,
|
||||
Property, Quality, Rational, Renderer, SequenceInfo, ShapeDetail, ShapeGroup, ShapeItem, ShapePath, SpatialKey, Stroke, StrokeCap, StrokeJoin, StyleRun,
|
||||
TextBox, TextDocument, TextValue, TimeBase, TimeDisplay, TimeRemap, TimeRemapKey, TrackMatte, Tracked, Transform, ValueSource, Walk, WorkingSpace,
|
||||
curve_lut, effect_map, identifier_channel_type, is_aepx, parse, parse_any, parse_text_dict, parse_with_limits, read_aepx, read_aepx_with_limits, style_map,
|
||||
write, write_to,
|
||||
};
|
||||
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,7 @@
|
||||
asset: ai-mask-3099.aep
|
||||
title: Small After Effects test project: placed .ai files with masks under two parented Nulls (#3099)
|
||||
author: EffectCraft contributors
|
||||
source: original work (made in After Effects 2026 from own data: four hand-written one-page PDFs named .ai, generated by tools/ai-mask-3099.py, imported and laid out by tools/ai-mask-3099.jsx)
|
||||
license: MIT OR Apache-2.0
|
||||
added: 2026-11-08 by EffectCraft contributors
|
||||
notes: Scrubbed with tools/scrub.py (footage paths C:\fixture\x\<name>.ai of the same length, host name HOST000, the XMP packet of an empty project of the contributor); its four embedded colour profiles replaced by stubs (description only, no colour data) with crates/aep/examples/stubicc.rs, then checked with the corpus repository's tools/aep/inspect-embedded.py (CLEAN). No Adobe asset is in it.
|
||||
+1
@@ -29,6 +29,7 @@ single-case files some cases are merged from, and the contributor's own animatio
|
||||
| `scrub.py` | fixture hygiene of a saved `.aep`: footage alias paths replaced by neutral ones of the same length (`C:\fixtures\...`), host name `HOST000...`, neutral ascend counts, the XMP packet replaced by the base project's |
|
||||
| `layers-comps-scrub-aepx.py` | the same XMP swap for the `.aepx` of the pair |
|
||||
| `patch-text.py` | the byte edits of the text cases 732 and 733 (see below) |
|
||||
| `ai-mask-3099.py`, `ai-mask-3099.jsx` | the committed `../ai-mask-3099.aep` (#3099): the `.py` writes four one-page PDFs named `.ai` with distinct page boxes, the `.jsx` (ExtendScript, run once through an After Effects MCP server's script call in an empty untitled project) imports them, builds composition `ai-mask` and returns After Effects' read-back that `../../it/import/ai_mask.rs` compares against |
|
||||
|
||||
## Run order (per file)
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// Issue 3099: how After Effects sizes a placed .ai (PDF) and where masks / parented layers land.
|
||||
// Generates ai-mask-3099.aep: run `python -I ai-mask-3099.py <work>` first, set `dir` to that
|
||||
// folder, then run this script (ExtendScript) in an empty, untitled project. It returns After
|
||||
// Effects' read-back (footage sizes, transforms, mask vertices, toComp points; the tests compare
|
||||
// against it) and saves <work>/i3099-ai-mask.aep, which is then scrubbed (README: steps 5 and 6).
|
||||
(function () {
|
||||
var dir = "<work>/";
|
||||
var out = {};
|
||||
if (app.project.file !== null || app.project.numItems !== 0) {
|
||||
return "ABORT: project not empty/untitled (" + app.project.numItems + " items)";
|
||||
}
|
||||
var target = new File(dir + "i3099-ai-mask.aep");
|
||||
if (target.exists) { return "ABORT: target exists"; }
|
||||
var names = ["i3099-a-boxes.ai", "i3099-b-frac.ai", "i3099-c-rot.ai", "i3099-d-trim.ai"];
|
||||
var items = {};
|
||||
out.footage = {};
|
||||
for (var i = 0; i < names.length; i++) {
|
||||
var io = new ImportOptions(new File(dir + names[i]));
|
||||
io.importAs = ImportAsType.FOOTAGE;
|
||||
var it = app.project.importFile(io);
|
||||
items[names[i]] = it;
|
||||
out.footage[names[i]] = { w: it.width, h: it.height, par: it.pixelAspect };
|
||||
}
|
||||
var comp = app.project.items.addComp("ai-mask", 1920, 1080, 1, 5, 25);
|
||||
var art = comp.layers.add(items["i3099-a-boxes.ai"]);
|
||||
art.name = "art";
|
||||
var artD = comp.layers.add(items["i3099-d-trim.ai"]);
|
||||
artD.name = "artD";
|
||||
artD.collapseTransformation = true;
|
||||
var artC = comp.layers.add(items["i3099-c-rot.ai"]);
|
||||
artC.name = "artC";
|
||||
artC.property("ADBE Transform Group").property("ADBE Position").setValue([1500, 800]);
|
||||
var n1 = comp.layers.addNull(); n1.name = "N1";
|
||||
var n2 = comp.layers.addNull(); n2.name = "N2";
|
||||
n2.setParentWithJump(n1);
|
||||
art.setParentWithJump(n2);
|
||||
artD.setParentWithJump(n2);
|
||||
var t = function (l, p, v) { l.property("ADBE Transform Group").property(p).setValue(v); };
|
||||
t(n1, "ADBE Position", [700, 400]);
|
||||
t(n1, "ADBE Rotate Z", 15);
|
||||
t(n1, "ADBE Scale", [80, 120]);
|
||||
t(n2, "ADBE Anchor Point", [10, 20]);
|
||||
t(n2, "ADBE Position", [50, -30]);
|
||||
t(n2, "ADBE Scale", [150, 150]);
|
||||
t(art, "ADBE Position", [0, 0]);
|
||||
t(artD, "ADBE Position", [300, 250]);
|
||||
// Two rectangular masks on each art layer, in layer pixels.
|
||||
var rect = function (l, nm, x0, y0, x1, y1) {
|
||||
var m = l.property("ADBE Mask Parade").addProperty("ADBE Mask Atom");
|
||||
m.name = nm;
|
||||
var s = new Shape();
|
||||
s.vertices = [[x0, y0], [x1, y0], [x1, y1], [x0, y1]];
|
||||
s.closed = true;
|
||||
m.property("ADBE Mask Shape").setValue(s);
|
||||
};
|
||||
rect(art, "M1", 40, 30, 200, 90);
|
||||
rect(art, "M2", 220, 100, 350, 160);
|
||||
rect(artD, "M1", 40, 30, 200, 90);
|
||||
rect(artD, "M2", 220, 100, 350, 160);
|
||||
// World positions of layer points through the parent chain (toComp), read via a Point Control.
|
||||
var probe = n1.property("ADBE Effect Parade").addProperty("ADBE Point Control");
|
||||
var pt = probe.property(1);
|
||||
var toComp = function (ln, p) {
|
||||
pt.expression = "thisComp.layer('" + ln + "').toComp([" + p[0] + "," + p[1] + "])";
|
||||
var v = pt.valueAtTime(0, false);
|
||||
return [v[0], v[1]];
|
||||
};
|
||||
var rd = function (l) {
|
||||
var tg = l.property("ADBE Transform Group");
|
||||
var r = {
|
||||
w: l.width, h: l.height,
|
||||
anchor: tg.property("ADBE Anchor Point").value,
|
||||
position: tg.property("ADBE Position").value,
|
||||
scale: tg.property("ADBE Scale").value,
|
||||
rot: tg.property("ADBE Rotate Z").value,
|
||||
masks: []
|
||||
};
|
||||
var mp = l.property("ADBE Mask Parade");
|
||||
for (var k = 1; k <= mp.numProperties; k++) {
|
||||
r.masks.push(mp.property(k).property("ADBE Mask Shape").value.vertices);
|
||||
}
|
||||
return r;
|
||||
};
|
||||
out.layers = { art: rd(art), artD: rd(artD), artC: rd(artC), N1: rd(n1), N2: rd(n2) };
|
||||
out.world = {};
|
||||
var pts = { tl: [0, 0], tr: [art.width, 0], bl: [0, art.height], m1: [40, 30], m2: [350, 160] };
|
||||
for (var key in pts) { out.world["art_" + key] = toComp("art", pts[key]); }
|
||||
var ptsD = { tl: [0, 0], tr: [artD.width, 0], bl: [0, artD.height], m1: [40, 30] };
|
||||
for (var key2 in ptsD) { out.world["artD_" + key2] = toComp("artD", ptsD[key2]); }
|
||||
probe.remove();
|
||||
app.project.save(target);
|
||||
out.saved = target.fsName;
|
||||
return out;
|
||||
})();
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
"""Writes hand-made one-page PDFs named .ai with distinct page boxes (issue 3099 repro)."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
out = sys.argv[1]
|
||||
os.makedirs(out, exist_ok=True)
|
||||
|
||||
|
||||
def pdf(page_extra, content):
|
||||
objs = [
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
b"<< /Type /Page /Parent 2 0 R " + page_extra.encode() + b" /Contents 4 0 R >>",
|
||||
b"<< /Length " + str(len(content)).encode() + b" >>\nstream\n" + content + b"\nendstream",
|
||||
]
|
||||
data = b"%PDF-1.4\n"
|
||||
offs = []
|
||||
for i, o in enumerate(objs, 1):
|
||||
offs.append(len(data))
|
||||
data += str(i).encode() + b" 0 obj\n" + o + b"\nendobj\n"
|
||||
x = len(data)
|
||||
data += b"xref\n0 " + str(len(objs) + 1).encode() + b"\n0000000000 65535 f \n"
|
||||
for o in offs:
|
||||
data += b"%010d 00000 n \n" % o
|
||||
data += b"trailer\n<< /Size " + str(len(objs) + 1).encode() + b" /Root 1 0 R >>\nstartxref\n" + str(x).encode() + b"\n%%EOF\n"
|
||||
return data
|
||||
|
||||
|
||||
def boxes_content(boxes):
|
||||
# One outline per box (different colours) plus a filled marker at the media box origin corner.
|
||||
cols = ["0 0 0", "1 0 0", "0 0.6 0", "0 0 1", "1 0.5 0"]
|
||||
s = b"0.85 0.85 0.85 rg "
|
||||
x0, y0, x1, y1 = boxes[0]
|
||||
s += b"%g %g %g %g re f\n" % (x0, y0, x1 - x0, y1 - y0)
|
||||
for c, (a, b, cc, d) in zip(cols, boxes):
|
||||
s += c.encode() + b" RG 1 w %g %g %g %g re S\n" % (a + 0.5, b + 0.5, cc - a - 1, d - b - 1)
|
||||
# A solid square in the lower-left of the last (innermost) box: shows y orientation.
|
||||
a, b, _, _ = boxes[-1]
|
||||
s += b"0.2 0.2 0.8 rg %g %g 20 10 re f" % (a + 4, b + 4)
|
||||
return s
|
||||
|
||||
|
||||
def arr(b):
|
||||
return "[%s]" % " ".join("%g" % v for v in b)
|
||||
|
||||
|
||||
# A: every box distinct, nested, non-square.
|
||||
media, crop, bleed, trim, art = [0, 0, 420, 220], [5, 5, 415, 215], [10, 10, 410, 210], [20, 20, 400, 200], [60, 50, 300.6, 170.4]
|
||||
a = pdf(
|
||||
f"/MediaBox {arr(media)} /CropBox {arr(crop)} /BleedBox {arr(bleed)} /TrimBox {arr(trim)} /ArtBox {arr(art)}",
|
||||
boxes_content([media, crop, bleed, trim, art]),
|
||||
)
|
||||
# B: only a fractional media box.
|
||||
mb = [0, 0, 300.6, 120.4]
|
||||
b = pdf(f"/MediaBox {arr(mb)}", boxes_content([mb]))
|
||||
# C: media box with an offset origin and /Rotate 90.
|
||||
mc = [100, 50, 400, 150]
|
||||
c = pdf(f"/MediaBox {arr(mc)} /Rotate 90", boxes_content([mc]))
|
||||
# D: media + trim + art, no crop box (bleed around the artboard).
|
||||
md, td, ad = [0, 0, 418, 218], [9, 9, 409, 209], [40, 30, 250, 150]
|
||||
d = pdf(f"/MediaBox {arr(md)} /TrimBox {arr(td)} /BleedBox {arr(md)} /ArtBox {arr(ad)}", boxes_content([md, td, ad]))
|
||||
|
||||
for name, data in [("i3099-a-boxes.ai", a), ("i3099-b-frac.ai", b), ("i3099-c-rot.ai", c), ("i3099-d-trim.ai", d)]:
|
||||
with open(os.path.join(out, name), "wb") as f:
|
||||
f.write(data)
|
||||
print(name, len(data))
|
||||
@@ -237,9 +237,11 @@ fn exercise_layer(project: &Project<'_, '_>, comp_size: (u32, u32), layer: &Laye
|
||||
if let Some(root) = layer.properties() {
|
||||
exercise_group(&root, 0);
|
||||
}
|
||||
// One index for the layer's effects: building it walks every layer of the file.
|
||||
let defs = project.effect_definition_index();
|
||||
for e in layer.effects() {
|
||||
let _ = (e.match_name().len(), e.name(), e.declared_param_count(), e.has_own_definitions(), project.definition_source(&e));
|
||||
for p in e.params().into_iter().chain(project.effect_params(&e)) {
|
||||
let _ = (e.match_name().len(), e.name(), e.declared_param_count(), e.has_own_definitions(), defs.source(&e));
|
||||
for p in e.params().into_iter().chain(defs.params(&e)) {
|
||||
let _ = (p.index, p.kind, p.name.len(), p.items.as_ref().map(Vec::len), p.pard().len(), p.value(), p.stored_value(), p.pard_default());
|
||||
let _ = (p.is_default(), p.popup_item_count(), p.popup_selected_index(), p.popup_selected_item(), p.valid_max(), p.is_animated());
|
||||
let _ = (p.valid_min(), p.slider_range());
|
||||
|
||||
@@ -83,6 +83,45 @@ fn ntsc_and_odd_frame_rates_use_the_time_bases_after_effects_writes() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #3099: After Effects reads a null's absent Anchor Point as its top-left corner `[0, 0]`. A
|
||||
/// null anchored at its centre is written (it was left out as "the default" and came back at the
|
||||
/// corner); one at the corner is not, and both import back as they were.
|
||||
#[test]
|
||||
fn null_anchor_points_round_trip() {
|
||||
let mut p = Project::default();
|
||||
let c = add_comp(&mut p, "C", 800, 600, FrameRate::new(30, 1), 2.0, None);
|
||||
add_layer(&mut p, c, "Corner", LayerSource::Null, (100, 100));
|
||||
add_layer(&mut p, c, "Centre", LayerSource::Null, (100, 100));
|
||||
for (idx, anchor) in [(0, [50.0, 50.0, 0.0]), (1, [0.0, 0.0, 0.0])] {
|
||||
with_layer(&mut p, c, idx, |l| {
|
||||
if let Some(a) = l.props.prop_mut("transform/anchor") {
|
||||
a.value = Value::Vec3(anchor);
|
||||
}
|
||||
});
|
||||
}
|
||||
let r = export_all(&p);
|
||||
let file = parse(&r.bytes).expect("parse");
|
||||
let view = effectcraft_aep::Project::new(&file).expect("project");
|
||||
let comp = view.all_items().into_iter().find(|i| i.comp().is_some()).expect("comp");
|
||||
let stored: Vec<_> =
|
||||
comp.comp().expect("c").layers().map(|l| (l.name().unwrap_or_default(), l.transform(800, 600).expect("transform").anchor_fraction.source)).collect();
|
||||
assert_eq!(stored, [("Centre".to_string(), effectcraft_aep::ValueSource::Stored), ("Corner".to_string(), effectcraft_aep::ValueSource::Default)]);
|
||||
let mut back = Project::default();
|
||||
let none = |_: &str| false;
|
||||
let res = effectcraft_aep::import::import(
|
||||
&mut back,
|
||||
&r.bytes,
|
||||
&effectcraft_aep::import::ImportOptions { allow_losses: true, ..effectcraft_aep::import::ImportOptions::new(&none) },
|
||||
)
|
||||
.expect("import");
|
||||
let comp = res.comps.first().and_then(|id| back.comp(*id)).expect("comp");
|
||||
for (name, want) in [("Centre", [50.0, 50.0]), ("Corner", [0.0, 0.0])] {
|
||||
let l = comp.layers.iter().find(|l| l.name == name).expect("layer");
|
||||
let a = l.props.prop("transform/anchor").expect("anchor").value.as_vec2();
|
||||
assert!(close(a[0], want[0]) && close(a[1], want[1]), "{name}: {a:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_header_fields() {
|
||||
let mut p = Project::default();
|
||||
|
||||
@@ -364,6 +364,42 @@ fn what_cannot_be_written_is_reported() {
|
||||
parse(&r.bytes).expect("still a valid file");
|
||||
}
|
||||
|
||||
/// Lumetri Color is imported but not written (no prototype to write from): the export reports it
|
||||
/// as a loss and still produces a valid file.
|
||||
#[test]
|
||||
fn lumetri_color_is_reported_not_written() {
|
||||
let mut p = skip!(tint(|_| {}));
|
||||
let c = p.items.values().find(|i| i.name == "C").map(|i| i.id).expect("C");
|
||||
with_layer(&mut p, c, 0, |l| {
|
||||
if let Some(Node::Group(g)) = l.props.sub_mut("effects").and_then(|e| e.children.first_mut()) {
|
||||
g.kind = effectcraft_project::GroupKind::Effect { effect: "ec.color.lumetri".into() };
|
||||
}
|
||||
});
|
||||
let r = export_all(&p);
|
||||
assert_eq!(r.report.count(LossCategory::Effects), 1, "{:?}", r.report.losses);
|
||||
assert_eq!(r.report.exported.effects, 0);
|
||||
parse(&r.bytes).expect("still a valid file");
|
||||
}
|
||||
|
||||
/// CC Color Neutralizer, EXtractoR and IDentifier are in the import table but have no prototype
|
||||
/// to write from: each is reported, not written.
|
||||
#[test]
|
||||
fn imported_plug_in_effects_are_reported_not_written() {
|
||||
for effect in ["ec.color.cccolorneutralizer", "ec.3d.extractor", "ec.3d.identifier"] {
|
||||
let mut p = skip!(tint(|_| {}));
|
||||
let c = p.items.values().find(|i| i.name == "C").map(|i| i.id).expect("C");
|
||||
with_layer(&mut p, c, 0, |l| {
|
||||
if let Some(Node::Group(g)) = l.props.sub_mut("effects").and_then(|e| e.children.first_mut()) {
|
||||
g.kind = effectcraft_project::GroupKind::Effect { effect: effect.into() };
|
||||
}
|
||||
});
|
||||
let r = export_all(&p);
|
||||
assert_eq!(r.report.count(LossCategory::Effects), 1, "{effect}: {:?}", r.report.losses);
|
||||
assert_eq!(r.report.exported.effects, 0, "{effect}");
|
||||
parse(&r.bytes).expect("still a valid file");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_effect_values_never_panic() {
|
||||
for v in [Value::Scalar(f64::NAN), Value::Scalar(1e300), Value::Color([f32::NAN as f64, 2.0, -1.0, 1.0]), Value::Bool(true), Value::Text(Box::default())] {
|
||||
|
||||
@@ -196,6 +196,69 @@ fn documents_built_in_code_are_written_as_their_fields() {
|
||||
assert_eq!(layer.header().map(|h| h.layer_type), Some(effectcraft_aep::LayerType::Text));
|
||||
}
|
||||
|
||||
/// #3233: whether a text layer's name follows its text survives export and import. A followed
|
||||
/// name is written as no name (After Effects shows the text) and comes back following; a name the
|
||||
/// user gave is written, even when it equals the text, and comes back as the user's. Without a
|
||||
/// recorded state (older projects) a name equal to the text follows.
|
||||
#[test]
|
||||
fn a_text_layer_name_follows_its_text_through_export_and_import() {
|
||||
for (name, follows, stored) in [
|
||||
("Hello", Some(false), Some("Hello")),
|
||||
("Hello 2", Some(true), None),
|
||||
("Hello", None, None),
|
||||
("Text 2", None, None),
|
||||
("Text Title", None, Some("Text Title")),
|
||||
("Text Title", Some(false), Some("Text Title")),
|
||||
] {
|
||||
let case = format!("{name:?} {follows:?}");
|
||||
let (mut p, c) = text_project(vec![TextDoc::plain("Hello")]);
|
||||
with_layer(&mut p, c, 0, |l| {
|
||||
l.name = name.into();
|
||||
l.name_follows_text = follows;
|
||||
});
|
||||
let r = export_all(&p);
|
||||
let file = parse(&r.bytes).expect("parse");
|
||||
let view = effectcraft_aep::Project::new(&file).expect("project");
|
||||
let layer = view.all_items().into_iter().find_map(|i| i.comp()).and_then(|c| c.layers().next()).expect("layer");
|
||||
assert_eq!(layer.name().as_deref(), stored, "stored name ({case})");
|
||||
let back = reimport(&r.bytes);
|
||||
let l = back.comps().flat_map(|(_, c)| c.layers.iter()).find(|l| matches!(l.source, LayerSource::Text)).expect("text layer");
|
||||
assert_eq!(l.name, stored.unwrap_or("Hello"), "imported name ({case})");
|
||||
assert_eq!(l.name_follows_text, Some(stored.is_none()), "imported state ({case})");
|
||||
}
|
||||
// An empty text layer that follows its text is written without a name, as After Effects
|
||||
// writes it, and comes back named "Text", still following.
|
||||
let (mut p, c) = text_project(vec![TextDoc::plain("")]);
|
||||
with_layer(&mut p, c, 0, |l| {
|
||||
l.name = "Text".into();
|
||||
l.name_follows_text = Some(true);
|
||||
});
|
||||
let back = reimport(&export_all(&p).bytes);
|
||||
let l = back.comps().flat_map(|(_, c)| c.layers.iter()).find(|l| matches!(l.source, LayerSource::Text)).expect("text layer");
|
||||
assert_eq!((l.name.as_str(), l.name_follows_text), ("Text", Some(true)), "an empty text layer");
|
||||
}
|
||||
|
||||
/// #3233: a following name cut from a first line longer than 40 characters is written as no name
|
||||
/// and comes back as the same cut name, still following; a long name the user gave stays whole.
|
||||
#[test]
|
||||
fn a_long_first_line_names_a_text_layer_the_same_after_export_and_import() {
|
||||
let line = "A headline that runs past forty characters, to 64";
|
||||
assert!((41..=64).contains(&line.chars().count()));
|
||||
let text = format!("{line}\rSecond");
|
||||
let cut = effectcraft_project::text_layer_name(&text);
|
||||
assert_eq!(cut.chars().count(), 40);
|
||||
for (name, follows, back_name) in [(cut.as_str(), Some(true), cut.as_str()), (line, Some(false), line)] {
|
||||
let (mut p, c) = text_project(vec![TextDoc::plain(&text)]);
|
||||
with_layer(&mut p, c, 0, |l| {
|
||||
l.name = name.into();
|
||||
l.name_follows_text = follows;
|
||||
});
|
||||
let back = reimport(&export_all(&p).bytes);
|
||||
let l = back.comps().flat_map(|(_, c)| c.layers.iter()).find(|l| matches!(l.source, LayerSource::Text)).expect("text layer");
|
||||
assert_eq!((l.name.as_str(), l.name_follows_text), (back_name, follows), "{name:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_a_text_document_cannot_hold_is_reported() {
|
||||
let mut d = TextDoc::plain("x");
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
mod helpers;
|
||||
|
||||
mod aepx;
|
||||
mod ai_mask;
|
||||
mod blend_modes;
|
||||
mod cameras;
|
||||
mod cases;
|
||||
@@ -15,10 +16,13 @@ mod effects_batch5;
|
||||
mod effects_quickwins;
|
||||
mod effects_round2;
|
||||
mod effects_round3;
|
||||
mod effects_thirdparty;
|
||||
mod fx_inject;
|
||||
mod lumetri;
|
||||
mod markers;
|
||||
mod masks;
|
||||
mod mattes;
|
||||
mod puppet;
|
||||
mod resaved_defaults;
|
||||
mod robustness;
|
||||
mod shapes;
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
//! A placed Illustrator file with masks under two parented Nulls (#3099), against After Effects
|
||||
//! 2026's own read-back of the committed project `tests/fixtures/ai-mask-3099.aep` (generator:
|
||||
//! `tests/fixtures/tools/ai-mask-3099.py` and `ai-mask-3099.jsx`). Most of these guard After
|
||||
//! Effects' values the importer already read so (the drawing of the page is tested in
|
||||
//! `effectcraft-media` and the host's `vector_import` tests); the Anchor Point of the null left
|
||||
//! at its default (`[0, 0]`, not the centre) was wrong before #3099.
|
||||
//!
|
||||
//! The four `.ai` files are hand-written one-page PDFs with distinct page boxes. After Effects
|
||||
//! sized them by their ArtBox (a, d), by the MediaBox rounded up (b: 300.6 × 120.4 → 301 × 121)
|
||||
//! and ignored `/Rotate 90` (c). Composition `ai-mask` (1920 × 1080): `art` (a) and `artD` (d,
|
||||
//! Continuously Rasterize) are children of Null `N2`, itself a child of Null `N1`; both carry two
|
||||
//! rectangular masks; `artC` (c) is on its own.
|
||||
|
||||
use effectcraft_aep::import::{ImportOptions, import};
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::{ItemKind, Layer, Project};
|
||||
|
||||
use crate::import::helpers::comp;
|
||||
|
||||
const AI_MASK: &[u8] = include_bytes!("../../fixtures/ai-mask-3099.aep");
|
||||
|
||||
/// The fixture imported into an empty project (losses allowed, no footage file present).
|
||||
fn load() -> (Project, effectcraft_aep::import::ImportResult) {
|
||||
let mut project = Project::default();
|
||||
let none = |_: &str| false;
|
||||
let r = import(&mut project, AI_MASK, &ImportOptions { allow_losses: true, ..ImportOptions::new(&none) }).unwrap();
|
||||
(project, r)
|
||||
}
|
||||
|
||||
/// The first two components of the static value of property `path` of `l`.
|
||||
fn vec2(l: &Layer, path: &str) -> [f64; 2] {
|
||||
let v = l.props.prop(path).unwrap_or_else(|| panic!("{}: {path}", l.name)).value.as_vec2();
|
||||
[v[0], v[1]]
|
||||
}
|
||||
|
||||
/// `a` and `b` agree within float32 noise.
|
||||
fn near(a: [f64; 2], b: [f64; 2]) -> bool {
|
||||
(a[0] - b[0]).abs() < 1e-3 && (a[1] - b[1]).abs() < 1e-3
|
||||
}
|
||||
|
||||
/// The four `.ai` items have After Effects' sizes and are placed pages.
|
||||
#[test]
|
||||
fn placed_ai_sizes_are_after_effects_sizes() {
|
||||
let (p, _) = load();
|
||||
let mut sizes: Vec<(String, (u32, u32))> = p
|
||||
.items
|
||||
.values()
|
||||
.filter_map(|i| match &i.kind {
|
||||
ItemKind::Footage(f) if f.path.ends_with(".ai") => {
|
||||
// Framed and drawn as After Effects places the page (#3099).
|
||||
assert!(f.placed_page, "{}", i.name);
|
||||
Some((i.name.clone(), (f.width, f.height)))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
sizes.sort();
|
||||
assert_eq!(
|
||||
sizes,
|
||||
[
|
||||
("i3099-a-boxes.ai".to_string(), (241, 121)),
|
||||
("i3099-b-frac.ai".to_string(), (301, 121)),
|
||||
("i3099-c-rot.ai".to_string(), (300, 100)),
|
||||
("i3099-d-trim.ai".to_string(), (210, 120)),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// Parents, transforms (the default null anchor at `[0, 0]`) and both masks of each art layer
|
||||
/// as After Effects read them back.
|
||||
#[test]
|
||||
fn masks_anchors_and_parents_are_after_effects_values() {
|
||||
let (p, r) = load();
|
||||
let (_, c) = comp(&p, &r, "ai-mask");
|
||||
let layer = |name: &str| c.layers.iter().find(|l| l.name == name).unwrap_or_else(|| panic!("no layer {name}"));
|
||||
let (art, art_d, art_c, n1, n2) = (layer("art"), layer("artD"), layer("artC"), layer("N1"), layer("N2"));
|
||||
// Parents: art, artD -> N2 -> N1.
|
||||
assert_eq!(art.parent, Some(n2.id));
|
||||
assert_eq!(art_d.parent, Some(n2.id));
|
||||
assert_eq!(n2.parent, Some(n1.id));
|
||||
assert_eq!(n1.parent, None);
|
||||
assert_eq!(art_c.parent, None);
|
||||
// Transforms as After Effects read them back (anchor = half the source size for footage).
|
||||
for (l, anchor, position, scale) in [
|
||||
(art, [120.5, 60.5], [0.0, 0.0], [100.0, 100.0]),
|
||||
(art_d, [105.0, 60.0], [300.0, 250.0], [100.0, 100.0]),
|
||||
(art_c, [150.0, 50.0], [1500.0, 800.0], [100.0, 100.0]),
|
||||
(n1, [0.0, 0.0], [700.0, 400.0], [80.0, 120.0]),
|
||||
(n2, [10.0, 20.0], [50.0, -30.0], [150.0, 150.0]),
|
||||
] {
|
||||
assert!(near(vec2(l, "transform/anchor"), anchor), "{} anchor {:?}", l.name, vec2(l, "transform/anchor"));
|
||||
assert!(near(vec2(l, "transform/position"), position), "{} position {:?}", l.name, vec2(l, "transform/position"));
|
||||
assert!(near(vec2(l, "transform/scale"), scale), "{} scale {:?}", l.name, vec2(l, "transform/scale"));
|
||||
}
|
||||
match n1.props.prop("transform/rotation").map(|p| &p.value) {
|
||||
Some(Value::Scalar(v)) => assert!((v - 15.0).abs() < 1e-9, "N1 rotation {v}"),
|
||||
other => panic!("N1 rotation {other:?}"),
|
||||
}
|
||||
// Both art layers: M1 (40, 30)-(200, 90) and M2 (220, 100)-(350, 160) in layer pixels, the
|
||||
// second reaching past the layer, as After Effects read the vertices back.
|
||||
let rects = [[[40.0, 30.0], [200.0, 30.0], [200.0, 90.0], [40.0, 90.0]], [[220.0, 100.0], [350.0, 100.0], [350.0, 160.0], [220.0, 160.0]]];
|
||||
for l in [art, art_d] {
|
||||
let masks: Vec<_> = l.masks().expect("masks").groups().collect();
|
||||
assert_eq!(masks.len(), 2, "{}", l.name);
|
||||
for (m, want) in masks.iter().zip(&rects) {
|
||||
let Some(Value::Path(path)) = m.get("path").map(|p| &p.value) else { panic!("{}: {} has no path", l.name, m.name) };
|
||||
assert_eq!(path.vertices.len(), 4, "{}: {}", l.name, m.name);
|
||||
for (v, w) in path.vertices.iter().zip(want) {
|
||||
assert!(near(*v, *w), "{}: {} vertex {v:?} != {w:?}", l.name, m.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,7 +72,7 @@ fn the_table_names_tint_gradient_ramp_and_exposure() {
|
||||
] {
|
||||
assert!(t.contains(&want), "{want:?}");
|
||||
}
|
||||
assert_eq!(t.len(), 63);
|
||||
assert_eq!(t.len(), 67);
|
||||
for (_, id) in t {
|
||||
assert!(effectcraft_effects::find(id).is_some(), "{id}");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
//! CC Color Neutralizer, EXtractoR and IDentifier (#3112) arrive as EffectCraft's effects of the
|
||||
//! same name. Each is built into `synthetic.aep` (`fx_inject`) with the definitions After Effects
|
||||
//! 2026 wrote for it in the importer block of 2026-11-05 (`1300-thirdparty.aep`) and the values
|
||||
//! of that block's compositions; the Channel Info data is laid out as those instances store it.
|
||||
|
||||
use crate::import::fx_inject::{Default, Fx, losses, near, other, popup, run, slider, v};
|
||||
use effectcraft_aep::import::LossCategory;
|
||||
use effectcraft_aep::{ExtractorChannels, ParamKind as K, identifier_channel_type};
|
||||
use effectcraft_keyframe::Value;
|
||||
|
||||
fn neutralizer(values: Vec<(usize, f64)>) -> Fx {
|
||||
let range = |name| slider(K::FloatSlider, name, 0.0, (-100.0, 100.0));
|
||||
let group = |name| other(K::GroupStart, name, Default::None);
|
||||
let end = || other(K::GroupEnd, "", Default::None);
|
||||
Fx {
|
||||
match_name: "CS Color Neutralizer",
|
||||
name: "CC Color Neutralizer",
|
||||
defs: vec![
|
||||
other(K::Color, "Shadows Unbalance", Default::Color([0, 0, 0, 0])),
|
||||
group("Shadows"),
|
||||
range("Red - Shadows"),
|
||||
range("Green - Shadows"),
|
||||
range("Blue - Shadows"),
|
||||
end(),
|
||||
other(K::Color, "Midtones Unbalance", Default::Color([255, 127, 127, 127])),
|
||||
group("Midtones"),
|
||||
range("Red - Midtones"),
|
||||
range("Green - Midtones"),
|
||||
range("Blue - Midtones"),
|
||||
end(),
|
||||
other(K::Color, "Highlights Unbalance", Default::Color([255, 255, 255, 255])),
|
||||
group("Highlights"),
|
||||
range("Red - Highlights"),
|
||||
range("Green - Highlights"),
|
||||
range("Blue - Highlights"),
|
||||
end(),
|
||||
slider(K::FloatSlider, "Pinning", 0.0, (0.0, 100.0)),
|
||||
slider(K::FloatSlider, "Blend w. Original", 0.0, (0.0, 100.0)),
|
||||
group("Special"),
|
||||
popup("View", 1, &["Final Result", "Correction B to W", "Correction Stair"]),
|
||||
slider(K::FloatSlider, "Black Point", 0.0, (0.0, 49.9)),
|
||||
slider(K::FloatSlider, "White Point", 100.0, (50.1, 100.0)),
|
||||
end(),
|
||||
],
|
||||
values,
|
||||
data: vec![],
|
||||
keys: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cc_color_neutralizer_pinning_and_blend() {
|
||||
// `ccneutral-00-defaults` and the Pinning / Blend of `ccneutral-01-set-all`.
|
||||
let (g, r) = run(&neutralizer(vec![(19, 16.0), (20, 19.0)]), "ec.color.cccolorneutralizer");
|
||||
assert_eq!(losses(&r, LossCategory::Effects, "CS Color Neutralizer"), Vec::<String>::new());
|
||||
let g = g.expect("imported");
|
||||
near(&g, "pinning", 16.0);
|
||||
near(&g, "blendWOriginal", 19.0);
|
||||
// EffectCraft's own (identity) balance stays.
|
||||
assert_eq!(v(&g, "midtonesUnbalance"), v(&g, "midtonesBalance"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cc_color_neutralizer_corrections_without_a_counterpart_are_reported() {
|
||||
// `ccneutral-03-sec-shadows`, `ccneutral-09-pop22-2`, `ccneutral-06-sec-special`.
|
||||
let (g, r) = run(&neutralizer(vec![(3, 13.0), (4, 16.0), (5, 19.0), (22, 2.0), (23, 10.0), (24, 87.0)]), "ec.color.cccolorneutralizer");
|
||||
let found = losses(&r, LossCategory::Effects, "CS Color Neutralizer");
|
||||
for name in ["Red - Shadows", "Green - Shadows", "Blue - Shadows", "View", "Black Point", "White Point"] {
|
||||
assert!(found.iter().any(|d| d.contains(&format!("parameter {name} (")) && d.contains("not imported")), "{name}: {found:?}");
|
||||
}
|
||||
assert_eq!(found.len(), 6, "{found:?}");
|
||||
assert!(g.is_some(), "the effect itself is imported");
|
||||
}
|
||||
|
||||
/// EXtractoR's Channel Info data with these record kinds and names.
|
||||
fn channels(recs: [(u32, &str); 4]) -> Vec<u8> {
|
||||
let mut d = vec![0u8; ExtractorChannels::LEN];
|
||||
d[..4].copy_from_slice(&1u32.to_le_bytes());
|
||||
for (k, (kind, name)) in recs.iter().enumerate() {
|
||||
let at = 32 + k * 164;
|
||||
d[at..at + 4].copy_from_slice(&kind.to_le_bytes());
|
||||
d[at + 35..at + 35 + name.len()].copy_from_slice(name.as_bytes());
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
const DEFAULT_CHANNELS: [(u32, &str); 4] = [(1, "R"), (1, "G"), (1, "B"), (2, "(copy)")];
|
||||
|
||||
fn extractor(values: Vec<(usize, f64)>, data: Vec<u8>) -> Fx {
|
||||
let mut values = values;
|
||||
values.insert(0, (1, 0.0));
|
||||
Fx {
|
||||
match_name: "EXtractoR",
|
||||
name: "EXtractoR",
|
||||
defs: vec![
|
||||
other(K::ArbitraryData, "Channel Info", Default::None),
|
||||
other(K::GroupStart, "Process", Default::None),
|
||||
slider(K::FloatSlider, "Black Point", 0.0, (-f64::from(f32::MAX), f64::from(f32::MAX))),
|
||||
slider(K::FloatSlider, "White Point", 1.0, (-f64::from(f32::MAX), f64::from(f32::MAX))),
|
||||
other(K::Checkbox, "", Default::Number(0.0)),
|
||||
other(K::GroupEnd, "", Default::None),
|
||||
],
|
||||
values,
|
||||
data: vec![(1, data)],
|
||||
keys: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extractor_channel_info_layout() {
|
||||
let c = ExtractorChannels::parse(&channels(DEFAULT_CHANNELS)).expect("the default layout");
|
||||
assert!(c.is_default());
|
||||
let c = ExtractorChannels::parse(&channels([(1, "Z"), (1, "G"), (1, "B"), (2, "(copy)")])).expect("parses");
|
||||
assert!(!c.is_default());
|
||||
assert_eq!(c.channels[0], (1, "Z".to_string()));
|
||||
assert_eq!(ExtractorChannels::parse(&[0u8; 10]), None);
|
||||
// The kind is byte 0 of a record: the definition default of `extractor-00-defaults` has
|
||||
// `02 00 00 32` on Alpha, still the input's own channel.
|
||||
let c = ExtractorChannels::parse(&channels([(1, "R"), (1, "G"), (1, "B"), (0x3200_0002, "(copy)")])).expect("parses");
|
||||
assert!(c.is_default());
|
||||
assert_eq!(c.sources(), Ok(["R", "G", "B", "(copy)"].map(String::from)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extractor_points_unmult_and_default_channels() {
|
||||
// `extractor-01-set-all`: Black Point 10, White Point 14, UnMult on.
|
||||
let (g, r) = run(&extractor(vec![(3, 10.0), (4, 14.0), (5, 1.0)], channels(DEFAULT_CHANNELS)), "ec.3d.extractor");
|
||||
assert_eq!(losses(&r, LossCategory::Effects, "EXtractoR"), Vec::<String>::new());
|
||||
let g = g.expect("imported");
|
||||
near(&g, "blackPoint", 10.0);
|
||||
near(&g, "whitePoint", 14.0);
|
||||
assert_eq!((v(&g, "unMult"), v(&g, "clip")), (Value::Bool(true), Value::Bool(false)));
|
||||
// After Effects' `(copy)` Alpha is the input's alpha: EffectCraft's `(copy)`, not `A` (an
|
||||
// OpenEXR's own alpha plane).
|
||||
for (id, want) in [("red", "R"), ("green", "G"), ("blue", "B"), ("alpha", "(copy)")] {
|
||||
assert_eq!(v(&g, id), Value::Str(want.into()), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `extractor-07-preset-channel-z` (a preset of our own): `Z` into Red, Green and Blue, Alpha
|
||||
/// `(copy)`. EffectCraft's EXtractoR reads the footage's channel by the same name.
|
||||
#[test]
|
||||
fn extractor_channels_are_imported_by_name() {
|
||||
let (g, r) = run(&extractor(vec![], channels([(1, "Z"), (1, "Z"), (1, "Z"), (2, "(copy)")])), "ec.3d.extractor");
|
||||
assert_eq!(losses(&r, LossCategory::Effects, "EXtractoR"), Vec::<String>::new());
|
||||
let g = g.expect("imported");
|
||||
for (id, want) in [("red", "Z"), ("green", "Z"), ("blue", "Z"), ("alpha", "(copy)")] {
|
||||
assert_eq!(v(&g, id), Value::Str(want.into()), "{id}");
|
||||
}
|
||||
// `(copy)` on another channel is that channel of the input (EffectCraft's `(copy)` there).
|
||||
let (g, _) = run(&extractor(vec![], channels([(2, "(copy)"), (1, "CryptoObject00.R"), (2, "(copy)"), (1, "Z")])), "ec.3d.extractor");
|
||||
let g = g.expect("imported");
|
||||
for (id, want) in [("red", "(copy)"), ("green", "CryptoObject00.R"), ("blue", "(copy)"), ("alpha", "Z")] {
|
||||
assert_eq!(v(&g, id), Value::Str(want.into()), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extractor_channels_of_an_unknown_kind_are_reported() {
|
||||
for recs in [[(1, "Z"), (3, "Z"), (1, "Z"), (2, "(copy)")], [(1, "Z"), (1, ""), (1, "Z"), (2, "(copy)")]] {
|
||||
let (g, r) = run(&extractor(vec![], channels(recs)), "ec.3d.extractor");
|
||||
let found = losses(&r, LossCategory::Effects, "EXtractoR");
|
||||
assert!(found.len() == 1 && found[0].contains("are not imported (record 2 is of an unknown kind or names no channel)"), "{recs:?}: {found:?}");
|
||||
assert_eq!(v(&g.expect("imported"), "red"), Value::Str("R".into()), "{recs:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// IDentifier's Channel Info: the channel type's name at 19 among bytes that are not read.
|
||||
fn id_info(name: &str) -> Vec<u8> {
|
||||
let mut d: Vec<u8> = (0..148u32).map(|i| (i * 37 % 251) as u8 | 1).collect();
|
||||
d[19..19 + name.len()].copy_from_slice(name.as_bytes());
|
||||
d[19 + name.len()] = 0;
|
||||
d
|
||||
}
|
||||
|
||||
fn identifier(values: Vec<(usize, f64)>, data: Vec<u8>) -> Fx {
|
||||
let mut values = values;
|
||||
values.insert(0, (1, 0.0));
|
||||
Fx {
|
||||
match_name: "IDentifier",
|
||||
name: "IDentifier",
|
||||
defs: vec![
|
||||
other(K::ArbitraryData, "Channel Info", Default::None),
|
||||
popup("Display", 1, &["Colors", "Luma Matte", "Alpha Matte", "Raw"]),
|
||||
slider(K::IntSlider, "ID", 0.0, (0.0, 2_147_483_647.0)),
|
||||
],
|
||||
values,
|
||||
data: vec![(1, data)],
|
||||
keys: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identifier_display_id_and_channel_type() {
|
||||
assert_eq!(identifier_channel_type(&id_info("Object ID")), Some("Object ID"));
|
||||
// `identifier-01-set-all` (ID 10) and `identifier-05-pop2-3` (Alpha Matte): no channel chosen
|
||||
// (After Effects found none or none was picked), so After Effects leaves the layer unchanged.
|
||||
let (g, r) = run(&identifier(vec![(2, 3.0), (3, 10.0)], id_info("Object ID")), "ec.3d.identifier");
|
||||
let found = losses(&r, LossCategory::Effects, "IDentifier");
|
||||
assert!(found.len() == 1 && found[0].contains("no ID channel is chosen"), "{found:?}");
|
||||
let g = g.expect("imported");
|
||||
assert_eq!((v(&g, "display"), v(&g, "channelType")), (Value::Enum(2), Value::Enum(0)));
|
||||
near(&g, "id", 10.0);
|
||||
}
|
||||
|
||||
/// `identifier-09-preset-channel-objectid-id2` (1310, a preset of our own): the UINT channel
|
||||
/// `objectId` chosen and ID 2. EffectCraft's IDentifier reads that channel itself (`ObjectID`
|
||||
/// without regard to case), so nothing is lost; another name is reported.
|
||||
#[test]
|
||||
fn identifier_chosen_channels_by_name() {
|
||||
for (name, kind) in [("objectId", 0), ("ObjectID", 0), ("materialid", 1)] {
|
||||
let (g, r) = run(&identifier(vec![(3, 2.0)], id_info(name)), "ec.3d.identifier");
|
||||
assert_eq!(losses(&r, LossCategory::Effects, "IDentifier"), Vec::<String>::new(), "{name}");
|
||||
let g = g.expect("imported");
|
||||
assert_eq!(v(&g, "channelType"), Value::Enum(kind), "{name}");
|
||||
near(&g, "id", 2.0);
|
||||
}
|
||||
for name in ["IndexOB.X", "ID", "Material ID"] {
|
||||
let (_, r) = run(&identifier(vec![], id_info(name)), "ec.3d.identifier");
|
||||
let found = losses(&r, LossCategory::Effects, "IDentifier");
|
||||
assert!(found.len() == 1 && found[0].contains(&format!("the ID channel '{name}' (Channel Info) is not imported")), "{name}: {found:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// EXtractoR's data with the name of record `k` from byte 35 replaced by `name` (no NUL added; the
|
||||
/// rest of the record's name field is left as `fill`).
|
||||
fn channels_with_raw_name(k: usize, name: &[u8], fill: u8) -> Vec<u8> {
|
||||
let mut d = channels(DEFAULT_CHANNELS);
|
||||
let at = 32 + k * 164 + 35;
|
||||
d[at..at + 129].fill(fill);
|
||||
d[at..at + name.len()].copy_from_slice(name);
|
||||
d
|
||||
}
|
||||
|
||||
/// A channel name that never ends (no NUL up to the end of its record) or is not UTF-8 is
|
||||
/// refused by the decoder and reported at the importer; the defaults R, G, B, A stay.
|
||||
#[test]
|
||||
fn extractor_names_without_a_terminator_or_in_invalid_utf8_are_refused() {
|
||||
let unterminated = channels_with_raw_name(2, b"Z", b'Z');
|
||||
let invalid = channels_with_raw_name(1, &[0x5a, 0xff, 0xfe, 0x00], 0);
|
||||
for (what, data) in [("unterminated", unterminated), ("invalid UTF-8", invalid)] {
|
||||
assert_eq!(ExtractorChannels::parse(&data), None, "{what}");
|
||||
let (g, r) = run(&extractor(vec![], data), "ec.3d.extractor");
|
||||
let found = losses(&r, LossCategory::Effects, "EXtractoR");
|
||||
assert!(found.len() == 1 && found[0].contains("not in the sampled layout"), "{what}: {found:?}");
|
||||
let g = g.expect("imported");
|
||||
for (id, want) in [("red", "R"), ("green", "G"), ("blue", "B"), ("alpha", "A")] {
|
||||
assert_eq!(v(&g, id), Value::Str(want.into()), "{what}: {id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The same for IDentifier: a channel name without NUL up to the end of the data, or in invalid
|
||||
/// UTF-8, is refused and reported; the Object ID channel stays.
|
||||
#[test]
|
||||
fn identifier_names_without_a_terminator_or_in_invalid_utf8_are_refused() {
|
||||
let mut unterminated = id_info("objectId");
|
||||
unterminated[19..].fill(b'x');
|
||||
let mut invalid = id_info("objectId");
|
||||
invalid[19..22].copy_from_slice(&[b'o', 0xff, 0]);
|
||||
for (what, data) in [("unterminated", unterminated), ("invalid UTF-8", invalid)] {
|
||||
assert_eq!(identifier_channel_type(&data), None, "{what}");
|
||||
let (g, r) = run(&identifier(vec![], data), "ec.3d.identifier");
|
||||
let found = losses(&r, LossCategory::Effects, "IDentifier");
|
||||
assert!(found.len() == 1 && found[0].contains("not in the sampled layout"), "{what}: {found:?}");
|
||||
assert_eq!(v(&g.expect("imported"), "channelType"), Value::Enum(0), "{what}");
|
||||
}
|
||||
// Data too short to reach the name.
|
||||
assert_eq!(identifier_channel_type(&[1; 10]), None);
|
||||
}
|
||||
|
||||
/// A Channel Info value record without its arbitrary data (no `aRbs`): the effect still imports,
|
||||
/// with the defaults, nothing panics, and the missing data is reported as damaged (it may have
|
||||
/// chosen other channels, so the defaults are not what After Effects renders).
|
||||
#[test]
|
||||
fn a_missing_channel_info_payload_is_reported_and_leaves_the_defaults() {
|
||||
let mut fx = extractor(vec![], vec![]);
|
||||
fx.data.clear();
|
||||
let (g, r) = run(&fx, "ec.3d.extractor");
|
||||
assert_eq!(losses(&r, LossCategory::Effects, "EXtractoR"), Vec::<String>::new());
|
||||
let found = losses(&r, LossCategory::DamagedData, "EXtractoR");
|
||||
assert!(found.len() == 1 && found[0].contains("Channel Info data is missing"), "{found:?}");
|
||||
let g = g.expect("imported");
|
||||
for (id, want) in [("red", "R"), ("green", "G"), ("blue", "B"), ("alpha", "A")] {
|
||||
assert_eq!(v(&g, id), Value::Str(want.into()), "{id}");
|
||||
}
|
||||
let mut fx = identifier(vec![(3, 5.0)], vec![]);
|
||||
fx.data.clear();
|
||||
let (g, r) = run(&fx, "ec.3d.identifier");
|
||||
assert_eq!(losses(&r, LossCategory::Effects, "IDentifier"), Vec::<String>::new());
|
||||
let found = losses(&r, LossCategory::DamagedData, "IDentifier");
|
||||
assert!(found.len() == 1 && found[0].contains("Channel Info data is missing"), "{found:?}");
|
||||
let g = g.expect("imported");
|
||||
assert_eq!(v(&g, "channelType"), Value::Enum(0));
|
||||
near(&g, "id", 5.0);
|
||||
}
|
||||
@@ -103,18 +103,18 @@ pub fn tdmn(name: &str) -> Chunk<'static> {
|
||||
}
|
||||
|
||||
/// A UTF-8 string chunk (`Utf8`).
|
||||
fn utf8(s: &str) -> Chunk<'static> {
|
||||
pub fn utf8(s: &str) -> Chunk<'static> {
|
||||
Chunk::data(*b"Utf8", s.as_bytes().to_vec())
|
||||
}
|
||||
|
||||
/// A chunk whose body is chunks without being a `LIST` (`pdnm`, `fnam`: `NESTED_IDS`).
|
||||
fn nested(id: [u8; 4], children: Vec<Chunk<'static>>) -> Chunk<'static> {
|
||||
pub fn nested(id: [u8; 4], children: Vec<Chunk<'static>>) -> Chunk<'static> {
|
||||
Chunk { id, body: Body::Chunks(children), pad: None }
|
||||
}
|
||||
|
||||
/// A static value record holding `v`, after the one After Effects wrote for Glow's A & B Midpoint
|
||||
/// (its header; without the expression).
|
||||
fn record(template: &Chunk<'_>, v: f64) -> Chunk<'static> {
|
||||
pub fn record(template: &Chunk<'_>, v: f64) -> Chunk<'static> {
|
||||
let mut kids = Vec::new();
|
||||
for c in template.children() {
|
||||
match &c.body {
|
||||
@@ -131,7 +131,7 @@ fn record(template: &Chunk<'_>, v: f64) -> Chunk<'static> {
|
||||
}
|
||||
|
||||
/// A keyframed value record: `record`'s header with a key list of linear one-component keys.
|
||||
fn keyed_record(template: &Chunk<'_>, keys: &[(i32, f64)]) -> Chunk<'static> {
|
||||
pub fn keyed_record(template: &Chunk<'_>, keys: &[(i32, f64)]) -> Chunk<'static> {
|
||||
let Body::List { children, .. } = record(template, keys.first().map_or(0.0, |k| k.1)).body else { unreachable!("record builds a LIST") };
|
||||
let mut children = children;
|
||||
let mut lhd3 = vec![0u8; 52];
|
||||
@@ -229,39 +229,51 @@ fn find_glow_template(chunks: &[Chunk<'_>]) -> Option<Chunk<'static>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Replaces every Glow (`ADBE Glo2` with its `sspc`) below `chunks` with `fx`; the number replaced.
|
||||
fn replace_glow(chunks: &mut [Chunk<'_>], fx: &Fx, template: &Chunk<'_>) -> usize {
|
||||
/// Replaces every Glow (`ADBE Glo2` with its `sspc`) below `chunks` with the effect `match_name`
|
||||
/// and its `sspc`; the number replaced.
|
||||
fn replace_glow(chunks: &mut [Chunk<'_>], match_name: &str, sspc: &Chunk<'static>) -> usize {
|
||||
let mut n = 0;
|
||||
for ch in chunks {
|
||||
let kids = ch.children_mut();
|
||||
if let Some(at) = kids.iter().position(|k| k.is(b"tdmn") && k.bytes().is_some_and(|b| b.starts_with(b"ADBE Glo2\0")))
|
||||
&& kids.get(at + 1).is_some_and(|k| k.is_list(b"sspc"))
|
||||
{
|
||||
kids[at] = tdmn(fx.match_name);
|
||||
kids[at + 1] = sspc(fx, template);
|
||||
kids[at] = tdmn(match_name);
|
||||
kids[at + 1] = sspc.clone();
|
||||
n += 1;
|
||||
}
|
||||
n += replace_glow(kids, fx, template);
|
||||
n += replace_glow(kids, match_name, sspc);
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// `synthetic.aep` with `fx` in place of the Glow effect.
|
||||
pub fn bytes_with(fx: &Fx) -> Vec<u8> {
|
||||
/// `synthetic.aep` with the effect `match_name` in place of the Glow effect; `make` builds its
|
||||
/// `sspc` from the Glow's value record (the template of every record).
|
||||
pub fn bytes_with_effect(match_name: &str, make: impl Fn(&Chunk<'_>) -> Chunk<'static>) -> Vec<u8> {
|
||||
let file = parse(SYNTHETIC).unwrap();
|
||||
let template = find_glow_template(&file.chunks).expect("Glow's A & B Midpoint record");
|
||||
let sspc = make(&template);
|
||||
let mut file = parse(SYNTHETIC).unwrap();
|
||||
assert_eq!(replace_glow(&mut file.chunks, fx, &template), 2, "the Glow effect and its shared definitions");
|
||||
assert_eq!(replace_glow(&mut file.chunks, match_name, &sspc), 2, "the Glow effect and its shared definitions");
|
||||
write(&file).unwrap()
|
||||
}
|
||||
|
||||
/// `synthetic.aep` with `fx` in place of the Glow effect.
|
||||
pub fn bytes_with(fx: &Fx) -> Vec<u8> {
|
||||
bytes_with_effect(fx.match_name, |t| sspc(fx, t))
|
||||
}
|
||||
|
||||
/// Imports `synthetic.aep` with `fx` in place of its Glow: the EffectCraft effect `effect` (if
|
||||
/// imported) and the result.
|
||||
pub fn run(fx: &Fx, effect: &str) -> (Option<PropGroup>, ImportResult) {
|
||||
let bytes = bytes_with(fx);
|
||||
import_bytes(&bytes_with(fx), effect)
|
||||
}
|
||||
|
||||
/// Imports `bytes`: the first EffectCraft effect `effect` (if imported) and the result.
|
||||
pub fn import_bytes(bytes: &[u8], effect: &str) -> (Option<PropGroup>, ImportResult) {
|
||||
let mut project = Project::default();
|
||||
let none = |_: &str| false;
|
||||
let r = import(&mut project, &bytes, &ImportOptions { allow_losses: true, ..ImportOptions::new(&none) }).unwrap();
|
||||
let r = import(&mut project, bytes, &ImportOptions { allow_losses: true, ..ImportOptions::new(&none) }).unwrap();
|
||||
let g = project
|
||||
.comps()
|
||||
.flat_map(|(_, c)| c.layers.iter())
|
||||
|
||||
@@ -0,0 +1,573 @@
|
||||
//! Lumetri Color (`ADBE Lumetri`) arrives as EffectCraft's Lumetri Color (`ec.color.lumetri`),
|
||||
//! its parameters found by match name. The effect is built into the committed `synthetic.aep` in
|
||||
//! place of its Glow, in the layout of the 1200 samples (After Effects 2026, docs/aep-format.md
|
||||
//! 3.7): the definitions are a subset in an order of our own, so a mapping by `parT` index fails.
|
||||
|
||||
use effectcraft_aep::import::{ImportOptions, ImportResult, LossCategory, import};
|
||||
use effectcraft_aep::{Body, Chunk, parse, write};
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::{Project, PropGroup};
|
||||
|
||||
const SYNTHETIC: &[u8] = include_bytes!("../../fixtures/synthetic.aep");
|
||||
|
||||
fn tdmn(name: &str) -> Chunk<'static> {
|
||||
let mut b = name.as_bytes().to_vec();
|
||||
b.resize(40, 0);
|
||||
Chunk::data(*b"tdmn", b)
|
||||
}
|
||||
|
||||
/// A chunk holding chunks (`pdnm`, `fnam`: `NESTED_IDS`) with one `Utf8`.
|
||||
fn nested(id: [u8; 4], text: &[u8]) -> Chunk<'static> {
|
||||
Chunk { id, body: Body::Chunks(vec![Chunk::data(*b"Utf8", text.to_vec())]), pad: None }
|
||||
}
|
||||
|
||||
fn mn(suffix: u16) -> String {
|
||||
format!("ADBE Lumetri-{suffix:04}")
|
||||
}
|
||||
|
||||
/// A parameter definition (`pard`, 148 bytes): the type at byte 15, the name at 16, the default at
|
||||
/// 56 (a float slider's `f64`, a checkbox's or popup's `u32`), a popup's item count at 60, a float
|
||||
/// slider's valid range at 104 / 108 and slider range at 112 / 116 (`f32`).
|
||||
#[derive(Clone)]
|
||||
enum Def {
|
||||
Float { name: &'static str, default: f64, min: f32, max: f32 },
|
||||
Check { default: bool },
|
||||
Popup { name: &'static str, items: &'static [&'static str] },
|
||||
Data { name: &'static str },
|
||||
Group { name: &'static str, end: bool },
|
||||
}
|
||||
|
||||
fn pard(d: &Def) -> Vec<Chunk<'static>> {
|
||||
let mut b = vec![0u8; 148];
|
||||
let name = |b: &mut Vec<u8>, n: &str| b[16..16 + n.len()].copy_from_slice(n.as_bytes());
|
||||
let mut extra = Vec::new();
|
||||
match d {
|
||||
Def::Float { name: n, default, min, max } => {
|
||||
b[15] = 10;
|
||||
name(&mut b, n);
|
||||
b[56..64].copy_from_slice(&default.to_be_bytes());
|
||||
b[104..108].copy_from_slice(&min.to_be_bytes());
|
||||
b[108..112].copy_from_slice(&max.to_be_bytes());
|
||||
b[112..116].copy_from_slice(&min.to_be_bytes());
|
||||
b[116..120].copy_from_slice(&max.to_be_bytes());
|
||||
}
|
||||
Def::Check { default } => {
|
||||
b[15] = 4;
|
||||
name(&mut b, " ");
|
||||
b[56..60].copy_from_slice(&u32::from(*default).to_be_bytes());
|
||||
}
|
||||
Def::Popup { name: n, items } => {
|
||||
b[15] = 7;
|
||||
name(&mut b, n);
|
||||
b[56..60].copy_from_slice(&1u32.to_be_bytes());
|
||||
b[60..62].copy_from_slice(&u16::try_from(items.len()).unwrap().to_be_bytes());
|
||||
extra.push(nested(*b"pdnm", items.join("|").as_bytes()));
|
||||
}
|
||||
Def::Data { name: n } => {
|
||||
b[15] = 11;
|
||||
name(&mut b, n);
|
||||
}
|
||||
Def::Group { name: n, end } => {
|
||||
b[15] = if *end { 14 } else { 13 };
|
||||
name(&mut b, n);
|
||||
}
|
||||
}
|
||||
let mut v = vec![Chunk::data(*b"pard", b)];
|
||||
v.extend(extra);
|
||||
v
|
||||
}
|
||||
|
||||
/// A stored value.
|
||||
#[derive(Clone)]
|
||||
enum Rec {
|
||||
/// A number in a record of state `tdsb` (1 written, 3 the default state).
|
||||
Num(f64, u32),
|
||||
/// Arbitrary data.
|
||||
Data(Vec<u8>),
|
||||
/// A value record whose arbitrary data (`aRbs`) is missing.
|
||||
NoData,
|
||||
}
|
||||
|
||||
/// A static value record after the one After Effects wrote for Glow's A & B Midpoint (its header,
|
||||
/// one component).
|
||||
fn record(template: &Chunk<'_>, tdsb: u32, v: f64) -> Chunk<'static> {
|
||||
let mut kids = Vec::new();
|
||||
for c in template.children() {
|
||||
match &c.body {
|
||||
Body::Data(_) if c.id == *b"tdsb" => kids.push(Chunk::data(*b"tdsb", tdsb.to_be_bytes().to_vec())),
|
||||
Body::Data(d) if c.id == *b"tdb4" => kids.push(Chunk::data(c.id, d.to_vec())),
|
||||
Body::Data(_) if c.id == *b"cdat" => {
|
||||
let mut d = vec![0u8; 40];
|
||||
d[..8].copy_from_slice(&v.to_be_bytes());
|
||||
kids.push(Chunk::data(*b"cdat", d));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Chunk::list(*b"tdbs", kids)
|
||||
}
|
||||
|
||||
/// The defaults of a fresh instance's data (1200 `lumetri-00-defaults`).
|
||||
fn empty() -> Vec<u8> {
|
||||
vec![0xfe, 0xfe]
|
||||
}
|
||||
|
||||
/// A Lumetri curve: type, point count, points (`f64` x, y), padded to 520 bytes.
|
||||
fn curve(kind: i32, pts: &[(f64, f64)]) -> Vec<u8> {
|
||||
let mut d = kind.to_le_bytes().to_vec();
|
||||
d.extend(u32::try_from(pts.len()).unwrap().to_le_bytes());
|
||||
for (x, y) in pts {
|
||||
d.extend(x.to_le_bytes());
|
||||
d.extend(y.to_le_bytes());
|
||||
}
|
||||
d.resize(520, 0);
|
||||
d
|
||||
}
|
||||
|
||||
fn rgb_curves() -> Vec<u8> {
|
||||
(0..4).flat_map(|_| curve(0, &[(0.0, 0.0), (1.0, 1.0)])).collect()
|
||||
}
|
||||
|
||||
fn wheels(n: usize) -> Vec<u8> {
|
||||
(0..n).flat_map(|_| [0.0f64, 0.5, 0.0].into_iter().flat_map(f64::to_le_bytes)).collect()
|
||||
}
|
||||
|
||||
fn utf16(s: &str) -> Vec<u8> {
|
||||
s.encode_utf16().chain([0]).flat_map(u16::to_le_bytes).collect()
|
||||
}
|
||||
|
||||
/// HSL Secondary's key data as 1200 `lumetri-01-set-all` stores it (Set color RGB 0.02 0.55 0.85).
|
||||
const HSL_PICKED: &str = "01327f0f3fcdcc4c3dcdcccc3d01d519683f2e57c93dcade173e01dfdede3ecdcc4c3dcdcccc3d01";
|
||||
/// A fresh instance's key data (`-00-defaults`).
|
||||
const HSL_FRESH: &str = "000000003f0000000000000000010000003f0000000000000000010000003f000000000000000001";
|
||||
|
||||
fn hex(s: &str) -> Vec<u8> {
|
||||
(0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect()
|
||||
}
|
||||
|
||||
const MASK_STYLE: &[&str] = &["Color/Gray", "Color/Black", "White/Black"];
|
||||
const LOOKS: &[&str] = &["None", "[Custom]", "Browse...", "(-", "SL BIG", "SL BLUE ICE"];
|
||||
|
||||
/// The definitions, an order of our own (not After Effects' `parT` order) and without the ones
|
||||
/// After Effects 2026 added late (-0127 ... -0130).
|
||||
fn defs() -> Vec<(u16, Def)> {
|
||||
let f = |name, default, min, max| Def::Float { name, default, min, max };
|
||||
vec![
|
||||
(1, Def::Data { name: "Blob" }),
|
||||
(62, Def::Check { default: false }),
|
||||
(2, Def::Group { name: "Basic Correction", end: false }),
|
||||
(3, Def::Check { default: true }),
|
||||
(95, Def::Data { name: " " }),
|
||||
(64, f("HDR Specular", 0.0, -150.0, 150.0)),
|
||||
(16, f("Blacks", 0.0, -150.0, 150.0)),
|
||||
(11, f("Exposure", 0.0, -7.0, 7.0)),
|
||||
(12, f("Contrast", 0.0, -150.0, 150.0)),
|
||||
(7, f("Temperature", 0.0, -300.0, 300.0)),
|
||||
(21, Def::Group { name: "", end: true }),
|
||||
(22, Def::Group { name: "Creative", end: false }),
|
||||
(23, Def::Check { default: true }),
|
||||
(96, Def::Data { name: " " }),
|
||||
(25, Def::Popup { name: "Look", items: LOOKS }),
|
||||
(26, f("Intensity", 100.0, 0.0, 200.0)),
|
||||
(32, Def::Data { name: "Split Toning" }),
|
||||
(35, Def::Group { name: "", end: true }),
|
||||
(39, Def::Data { name: "RGB Curve" }),
|
||||
(104, Def::Check { default: true }),
|
||||
(42, Def::Data { name: "Hue vs Sat" }),
|
||||
(106, Def::Data { name: "Hue vs Hue" }),
|
||||
(47, Def::Data { name: "Wheels" }),
|
||||
(68, Def::Check { default: true }),
|
||||
(73, Def::Data { name: "HSL Sliders" }),
|
||||
(74, Def::Check { default: false }),
|
||||
(75, Def::Popup { name: " ", items: MASK_STYLE }),
|
||||
(85, Def::Data { name: "Color Wheels" }),
|
||||
(86, f("Temperature", 0.0, -300.0, 300.0)),
|
||||
(50, Def::Check { default: true }),
|
||||
(51, f("Amount", 0.0, -5.0, 5.0)),
|
||||
(54, f("Feather", 50.0, 0.0, 100.0)),
|
||||
(97, Def::Check { default: false }),
|
||||
]
|
||||
}
|
||||
|
||||
/// The data every instance stores, at the defaults.
|
||||
fn default_data() -> Vec<(u16, Rec)> {
|
||||
vec![
|
||||
(1, Rec::Data(empty())),
|
||||
(95, Rec::Data(empty())),
|
||||
(96, Rec::Data(empty())),
|
||||
(32, Rec::Data(vec![0; 32])),
|
||||
(39, Rec::Data(rgb_curves())),
|
||||
(42, Rec::Data(curve(3, &[]))),
|
||||
(106, Rec::Data(curve(3, &[]))),
|
||||
(47, Rec::Data(wheels(4))),
|
||||
(73, Rec::Data(hex(HSL_FRESH))),
|
||||
(85, Rec::Data(wheels(8))),
|
||||
]
|
||||
}
|
||||
|
||||
/// The Lumetri instance: the definitions, then the stored values `recs` (data replaces the
|
||||
/// default data of the same parameter).
|
||||
fn sspc(defs: &[(u16, Def)], recs: &[(u16, Rec)], template: &Chunk<'_>) -> Chunk<'static> {
|
||||
let mut part = vec![Chunk::data(*b"parn", u32::try_from(defs.len() + 2).unwrap().to_be_bytes().to_vec()), tdmn(&mn(0))];
|
||||
let mut layer = vec![0u8; 148];
|
||||
layer[15] = 0;
|
||||
part.push(Chunk::data(*b"pard", layer));
|
||||
for (s, d) in defs {
|
||||
part.push(tdmn(&mn(*s)));
|
||||
part.extend(pard(d));
|
||||
}
|
||||
part.push(tdmn("ADBE Effect Built In Params"));
|
||||
let mut end = vec![0u8; 148];
|
||||
end[15] = 9;
|
||||
part.push(Chunk::data(*b"pard", end));
|
||||
let mut all: Vec<(u16, Rec)> = default_data().into_iter().filter(|(s, _)| !recs.iter().any(|(r, _)| r == s)).collect();
|
||||
all.extend(recs.iter().cloned());
|
||||
let mut values = vec![Chunk::data(*b"tdsb", 1u32.to_be_bytes().to_vec())];
|
||||
for (s, r) in all {
|
||||
values.push(tdmn(&mn(s)));
|
||||
match r {
|
||||
Rec::Num(v, tdsb) => values.push(record(template, tdsb, v)),
|
||||
Rec::Data(d) => {
|
||||
values.push(record(template, 1, 0.0));
|
||||
values.push(Chunk::list(*b"aRbs", vec![Chunk::data(*b"aRbp", d)]));
|
||||
}
|
||||
Rec::NoData => values.push(record(template, 1, 0.0)),
|
||||
}
|
||||
}
|
||||
Chunk::list(*b"sspc", vec![nested(*b"fnam", b"Lumetri Color"), Chunk::list(*b"parT", part), Chunk::list(*b"tdgp", values)])
|
||||
}
|
||||
|
||||
fn find_glow_template<'a>(chunks: &'a [Chunk<'a>]) -> Option<Chunk<'a>> {
|
||||
for c in chunks {
|
||||
let kids = c.children();
|
||||
if let Some(at) = kids.iter().position(|k| k.is(b"tdmn") && k.bytes().is_some_and(|b| b.starts_with(b"ADBE Glo2-0011\0")))
|
||||
&& let Some(record) = kids.get(at + 1).filter(|k| k.is_list(b"tdbs"))
|
||||
{
|
||||
return Some(record.clone());
|
||||
}
|
||||
if let Some(t) = find_glow_template(kids) {
|
||||
return Some(t);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Puts the instance in place of the Glow effect (in the effect parade and the project's shared
|
||||
/// definitions); the number of places.
|
||||
fn replace_glow(chunks: &mut [Chunk<'_>], instance: &Chunk<'static>) -> usize {
|
||||
let mut n = 0;
|
||||
for ch in chunks {
|
||||
let kids = ch.children_mut();
|
||||
if let Some(at) = kids.iter().position(|k| k.is(b"tdmn") && k.bytes().is_some_and(|b| b.starts_with(b"ADBE Glo2\0")))
|
||||
&& kids.get(at + 1).is_some_and(|k| k.is_list(b"sspc"))
|
||||
{
|
||||
kids[at] = tdmn("ADBE Lumetri");
|
||||
kids[at + 1] = instance.clone();
|
||||
n += 1;
|
||||
}
|
||||
n += replace_glow(kids, instance);
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
fn run_with(defs: &[(u16, Def)], recs: &[(u16, Rec)]) -> (PropGroup, ImportResult) {
|
||||
let file = parse(SYNTHETIC).unwrap();
|
||||
let template = find_glow_template(&file.chunks).expect("Glow's A & B Midpoint record");
|
||||
let instance = sspc(defs, recs, &template);
|
||||
let mut file = parse(SYNTHETIC).unwrap();
|
||||
assert_eq!(replace_glow(&mut file.chunks, &instance), 2, "the Glow effect and its shared definitions");
|
||||
let bytes = write(&file).unwrap();
|
||||
let mut project = Project::default();
|
||||
let none = |_: &str| false;
|
||||
let r = import(&mut project, &bytes, &ImportOptions { allow_losses: true, ..ImportOptions::new(&none) }).unwrap();
|
||||
let g = project
|
||||
.comps()
|
||||
.flat_map(|(_, c)| c.layers.iter())
|
||||
.find_map(|l| l.props.sub("effects").and_then(|fx| fx.children.iter().filter_map(|n| n.as_group()).find(|g| g.match_id == "ec.color.lumetri").cloned()))
|
||||
.expect("an EffectCraft Lumetri Color");
|
||||
(g, r)
|
||||
}
|
||||
|
||||
fn run(recs: &[(u16, Rec)]) -> (PropGroup, ImportResult) {
|
||||
run_with(&defs(), recs)
|
||||
}
|
||||
|
||||
fn losses(r: &ImportResult) -> Vec<String> {
|
||||
r.report
|
||||
.losses
|
||||
.iter()
|
||||
.filter(|l| matches!(l.category, LossCategory::Effects | LossCategory::DamagedData))
|
||||
.flat_map(|l| l.details.clone())
|
||||
.filter(|d| d.contains("Lumetri"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn num(g: &PropGroup, id: &str) -> f64 {
|
||||
g.prop(id).unwrap_or_else(|| panic!("no {id}")).value.as_f64()
|
||||
}
|
||||
|
||||
fn val(g: &PropGroup, id: &str) -> Value {
|
||||
g.prop(id).unwrap_or_else(|| panic!("no {id}")).value.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fresh_lumetri_arrives_without_losses() {
|
||||
let (g, r) = run(&[]);
|
||||
assert_eq!(losses(&r), Vec::<String>::new());
|
||||
assert_eq!((num(&g, "basicCorrection/tone/exposure"), num(&g, "creative/lookIntensity"), num(&g, "vignette/vignetteFeather")), (0.0, 100.0, 50.0));
|
||||
assert_eq!(val(&g, "creative/look"), Value::Enum(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lumetri_values_arrive_by_match_name_also_from_default_state_records() {
|
||||
// 1200 `lumetri-01-set-all` / `-06-sec-light`: High Dynamic Range and HDR Specular are
|
||||
// recorded in the default state (`tdsb` 3) and read back as set.
|
||||
let (g, r) = run(&[
|
||||
(62, Rec::Num(1.0, 3)),
|
||||
(64, Rec::Num(22.0, 3)),
|
||||
(11, Rec::Num(2.589_999_914_169_311_5, 1)),
|
||||
(12, Rec::Num(37.0, 1)),
|
||||
(16, Rec::Num(-150.0, 1)),
|
||||
(7, Rec::Num(300.0, 1)),
|
||||
(26, Rec::Num(134.0, 1)),
|
||||
(51, Rec::Num(1.85, 1)),
|
||||
(74, Rec::Num(1.0, 1)),
|
||||
(75, Rec::Num(3.0, 1)),
|
||||
(73, Rec::Data(hex(HSL_PICKED))),
|
||||
]);
|
||||
assert_eq!(losses(&r), Vec::<String>::new());
|
||||
assert_eq!(val(&g, "highDynamicRange"), Value::Bool(true));
|
||||
for (id, want) in [
|
||||
("basicCorrection/tone/hdrSpecular", 22.0),
|
||||
("basicCorrection/tone/exposure", 2.589_999_914_169_311_5),
|
||||
("basicCorrection/tone/contrast", 37.0),
|
||||
("basicCorrection/tone/blacks", -150.0),
|
||||
// Past EffectCraft's old slider range: kept (the range is After Effects' now).
|
||||
("basicCorrection/whiteBalance/temperature", 300.0),
|
||||
("creative/lookIntensity", 134.0),
|
||||
("vignette/vignetteAmount", 1.85),
|
||||
] {
|
||||
assert_eq!(num(&g, id), want, "{id}");
|
||||
}
|
||||
assert_eq!((val(&g, "hslSecondary/key/showMask"), val(&g, "hslSecondary/key/maskStyle")), (Value::Bool(true), Value::Enum(2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_switched_off_section_is_reported_and_not_applied() {
|
||||
// 1200 `-03-sec-basic-correction`, `-17-sec-vignette`: a section that is off does nothing.
|
||||
let (g, r) = run(&[(3, Rec::Num(0.0, 1)), (11, Rec::Num(2.0, 1)), (50, Rec::Num(0.0, 1)), (51, Rec::Num(-2.0, 1)), (12, Rec::Num(37.0, 1))]);
|
||||
assert_eq!((num(&g, "basicCorrection/tone/exposure"), num(&g, "basicCorrection/tone/contrast"), num(&g, "vignette/vignetteAmount")), (0.0, 0.0, 0.0));
|
||||
let l = losses(&r);
|
||||
assert_eq!(l.len(), 2, "{l:?}");
|
||||
assert!(l[0].contains("section Basic Correction is switched off") && l[0].contains("Exposure 2, Contrast 37"), "{l:?}");
|
||||
assert!(l[1].contains("section Vignette is switched off") && l[1].contains("Amount -2"), "{l:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_look_is_reported_by_name() {
|
||||
// 1200 `-01-set-all`: the Look's name in -0096, the popup's record in the default state.
|
||||
let (g, r) = run(&[(96, Rec::Data(utf16("SL BLUE ICE"))), (25, Rec::Num(6.0, 3))]);
|
||||
assert_eq!(val(&g, "creative/look"), Value::Enum(0));
|
||||
let l = losses(&r);
|
||||
assert_eq!(l.len(), 1, "{l:?}");
|
||||
assert!(l[0].contains("the Look 'SL BLUE ICE' is not imported"), "{l:?}");
|
||||
// Only the popup: its item.
|
||||
let (_, r) = run(&[(25, Rec::Num(5.0, 1))]);
|
||||
assert!(losses(&r).iter().any(|d| d.contains("the Look 'SL BIG' is not imported")), "{:?}", losses(&r));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hue_vs_sat_arrives_with_its_y_scaled_other_moved_curves_are_reported() {
|
||||
// 1200 `-11-sec-hue-saturation-curves`: the selector put three neutral points on the curve.
|
||||
let neutral = curve(3, &[(0.742_483, 0.0), (0.798_038, 0.0), (0.853_594, 0.0)]);
|
||||
let (g, r) = run(&[(42, Rec::Data(neutral))]);
|
||||
assert_eq!(losses(&r), Vec::<String>::new());
|
||||
assert_eq!(val(&g, "curves/hueSaturationCurves/hueVsSat"), Value::Str(String::new()));
|
||||
// 1200 `-61-preset-curves-huesat`: red raised to 0.97 (After Effects nearly doubles its
|
||||
// saturation), the neighbours pinned near 0. EffectCraft's y: 0.5 + y / 2.
|
||||
let preset = curve(3, &[(0.015_463_917_525_773_196, 0.97), (0.262_886_597_938_144_34, -0.03), (0.764_604_810_996_563_5, -0.02)]);
|
||||
let (g, r) = run(&[(42, Rec::Data(preset))]);
|
||||
assert_eq!(losses(&r), Vec::<String>::new());
|
||||
let Value::Str(text) = val(&g, "curves/hueSaturationCurves/hueVsSat") else { panic!("a point list") };
|
||||
let pts: Vec<(f64, f64)> = text
|
||||
.split(' ')
|
||||
.map(|p| {
|
||||
let (x, y) = p.split_once(',').unwrap();
|
||||
(x.parse().unwrap(), y.parse().unwrap())
|
||||
})
|
||||
.collect();
|
||||
let want = [(0.015_464, 0.985), (0.262_887, 0.485), (0.764_605, 0.49)];
|
||||
assert_eq!(pts.len(), 3, "{text}");
|
||||
for ((x, y), (wx, wy)) in pts.iter().zip(want) {
|
||||
assert!((x - wx).abs() < 1e-5 && (y - wy).abs() < 1e-9, "{text}");
|
||||
}
|
||||
// Hue vs Hue moved: no sample tells its scale, reported and left neutral.
|
||||
let (g, r) = run(&[(106, Rec::Data(curve(3, &[(0.5, 0.25)])))]);
|
||||
let l = losses(&r);
|
||||
assert!(l.len() == 1 && l[0].contains("Hue vs Hue (ADBE Lumetri-0106) is not imported"), "{l:?}");
|
||||
assert_eq!(val(&g, "curves/hueSaturationCurves/hueVsHue"), Value::Str(String::new()));
|
||||
// A point count past the data: not a default, reported as damaged, no panic.
|
||||
let mut bad = curve(3, &[]);
|
||||
bad[4..8].copy_from_slice(&1000u32.to_le_bytes());
|
||||
let (g, r) = run(&[(42, Rec::Data(bad))]);
|
||||
let l = losses(&r);
|
||||
assert!(l.len() == 1 && l[0].contains("Hue vs Sat (ADBE Lumetri-0042) is unreadable"), "{l:?}");
|
||||
assert_eq!(val(&g, "curves/hueSaturationCurves/hueVsSat"), Value::Str(String::new()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_master_rgb_curve_arrives_moved_channel_curves_are_reported() {
|
||||
// 1200 `-60-preset-curves-rgb`: the first of the four curves, its midpoint raised.
|
||||
let master = curve(0, &[(0.0, 0.0), (0.491_408_934_707_903_76, 0.786_885_245_901_639_3), (1.0, 1.0)]);
|
||||
let identity = curve(0, &[(0.0, 0.0), (1.0, 1.0)]);
|
||||
let mut d = master.clone();
|
||||
(0..3).for_each(|_| d.extend(&identity));
|
||||
let (g, r) = run(&[(39, Rec::Data(d))]);
|
||||
assert_eq!(losses(&r), Vec::<String>::new());
|
||||
assert_eq!(val(&g, "curves/rgbCurves/master"), Value::Str("0,0 0.49140893470790376,0.7868852459016393 1,1".into()));
|
||||
assert_eq!(val(&g, "curves/rgbCurves/red"), Value::Str("0,0 1,1".into()));
|
||||
// A moved channel curve: which channel it is is not known, reported.
|
||||
let mut d = identity.clone();
|
||||
d.extend(&master);
|
||||
(0..2).for_each(|_| d.extend(&identity));
|
||||
let (g, r) = run(&[(39, Rec::Data(d))]);
|
||||
let l = losses(&r);
|
||||
assert!(l.len() == 1 && l[0].contains("1 channel curve(s) of RGB Curve (ADBE Lumetri-0039) are not imported"), "{l:?}");
|
||||
assert_eq!(val(&g, "curves/rgbCurves/master"), Value::Str("0,0 1,1".into()));
|
||||
// Damaged: the wrong length.
|
||||
let (_, r) = run(&[(39, Rec::Data(master))]);
|
||||
assert!(losses(&r).iter().any(|x| x.contains("RGB Curve (ADBE Lumetri-0039) is unreadable")), "{:?}", losses(&r));
|
||||
}
|
||||
|
||||
/// A value record without its arbitrary data is damaged, not the default: reported for the RGB
|
||||
/// curve and the HSL key, the neighbours stay as they were.
|
||||
#[test]
|
||||
fn a_value_record_without_its_data_is_reported_as_damaged() {
|
||||
for (suffix, name) in [(39, "RGB Curve (ADBE Lumetri-0039)"), (73, "HSL Sliders (ADBE Lumetri-0073)")] {
|
||||
let (g, r) = run(&[(suffix, Rec::NoData)]);
|
||||
let l = losses(&r);
|
||||
assert!(l.len() == 1 && l[0].contains(name) && l[0].contains("is missing"), "{suffix}: {l:?}");
|
||||
assert!(r.report.losses.iter().any(|x| x.category == LossCategory::DamagedData), "{suffix}");
|
||||
// The grade stays at the default.
|
||||
assert_eq!(val(&g, "curves/rgbCurves/master"), Value::Str("0,0 1,1".into()));
|
||||
assert_eq!(num(&g, "hslSecondary/key/hueRange"), 180.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colour_wheels_arrive_as_colours_their_brightness_is_reported() {
|
||||
// 1200 `-62-preset-wheels`: Shadows towards blue with the slider down, Midtones towards red,
|
||||
// Highlights towards yellow-green with the slider up; the fourth wheel at its default.
|
||||
let w: Vec<u8> = [
|
||||
229.119_895_976_548_18,
|
||||
0.369_892_467_494_294_2,
|
||||
0.547_209_348_004_638,
|
||||
0.104_745_366_878_020_72,
|
||||
0.5,
|
||||
0.394_961_840_132_529_44,
|
||||
93.374_076_744_668_57,
|
||||
0.726_881_718_523_399_9,
|
||||
0.318_976_840_675_999_35,
|
||||
0.0,
|
||||
0.5,
|
||||
0.0,
|
||||
]
|
||||
.into_iter()
|
||||
.flat_map(f64::to_le_bytes)
|
||||
.collect();
|
||||
let (g, r) = run(&[(47, Rec::Data(w.clone()))]);
|
||||
let colour = |id: &str| match val(&g, id) {
|
||||
Value::Color(c) => c,
|
||||
other => panic!("{id}: {other:?}"),
|
||||
};
|
||||
// The hue's direction: blue strongest in Shadows, red in Midtones, green in Highlights.
|
||||
let s = colour("colorWheels/shadowsWheel");
|
||||
assert!(s[2] > 0.77 && s[0] < 0.23 && (s[0] + s[2] - 1.0).abs() < 1e-9, "{s:?}");
|
||||
let m = colour("colorWheels/midtonesWheel");
|
||||
assert!((m[0] - 0.697_480_920_066_264_7).abs() < 1e-9 && m[1] > m[2], "{m:?}");
|
||||
let h = colour("colorWheels/highlightsWheel");
|
||||
assert!(h[1] > h[0] && h[0] > h[2], "{h:?}");
|
||||
let l = losses(&r);
|
||||
assert!(l.len() == 1 && l[0].contains("Shadows brightness 0.370") && l[0].contains("Highlights brightness 0.727"), "{l:?}");
|
||||
assert!(!l[0].contains("Midtones") && !l[0].contains("fourth"), "{l:?}");
|
||||
// The fourth wheel set: reported; damaged data: reported, the wheels stay grey.
|
||||
let mut w4 = w;
|
||||
w4[88..96].copy_from_slice(&0.3f64.to_le_bytes());
|
||||
let (_, r) = run(&[(47, Rec::Data(w4))]);
|
||||
assert!(losses(&r).iter().any(|x| x.contains("the fourth wheel")), "{:?}", losses(&r));
|
||||
let (g, r) = run(&[(47, Rec::Data(vec![1; 95]))]);
|
||||
assert!(losses(&r).iter().any(|x| x.contains("Wheels (ADBE Lumetri-0047) is unreadable")), "{:?}", losses(&r));
|
||||
assert_eq!(val(&g, "colorWheels/shadowsWheel"), Value::Color([0.5, 0.5, 0.5, 1.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_picked_hsl_key_becomes_hue_saturation_and_lightness_ranges() {
|
||||
let (g, r) = run(&[(73, Rec::Data(hex(HSL_PICKED)))]);
|
||||
assert_eq!(losses(&r), Vec::<String>::new());
|
||||
let close = |id: &str, want: f64| assert!((num(&g, id) - want).abs() < 0.01, "{id}: {} != {want}", num(&g, id));
|
||||
// Hue centre 0.5605 of the circle, range 0.05, tolerance 0.1.
|
||||
close("hslSecondary/key/hueCenter", 201.79);
|
||||
close("hslSecondary/key/hueRange", 18.0);
|
||||
close("hslSecondary/key/hueSoftness", 36.0);
|
||||
// Lightness centre 0.435 +- 0.05, tolerance 0.1, as percent.
|
||||
close("hslSecondary/key/lightnessLow", 38.53);
|
||||
close("hslSecondary/key/lightnessHigh", 48.53);
|
||||
close("hslSecondary/key/lightnessSoftness", 10.0);
|
||||
assert_eq!(val(&g, "hslSecondary/key/hueEnabled"), Value::Bool(true));
|
||||
// With the key picked the correction applies; without, After Effects applies it to nothing
|
||||
// (1200 `-16-sec-correction` renders as `-00-defaults`): left out and reported.
|
||||
let (g, _) = run(&[(73, Rec::Data(hex(HSL_PICKED))), (86, Rec::Num(19.0, 1))]);
|
||||
assert_eq!(num(&g, "hslSecondary/correction/temperature"), 19.0);
|
||||
let (g, r) = run(&[(86, Rec::Num(19.0, 1))]);
|
||||
assert_eq!(num(&g, "hslSecondary/correction/temperature"), 0.0);
|
||||
let l = losses(&r);
|
||||
assert!(l.len() == 1 && l[0].contains("HSL Secondary has no key colour picked") && l[0].contains("Temperature 19"), "{l:?}");
|
||||
// Damaged key data: reported, the default key kept.
|
||||
let (g, r) = run(&[(73, Rec::Data(vec![7; 40]))]);
|
||||
assert!(losses(&r).iter().any(|d| d.contains("HSL Secondary key data is unreadable")), "{:?}", losses(&r));
|
||||
assert_eq!(num(&g, "hslSecondary/key/hueRange"), 180.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undecoded_data_and_controls_without_counterpart_are_reported_when_set() {
|
||||
let mut split = vec![0u8; 32];
|
||||
split[0] = 1;
|
||||
let (_, r) = run(&[(32, Rec::Data(split)), (97, Rec::Num(1.0, 3)), (95, Rec::Data(utf16("my.cube")))]);
|
||||
let l = losses(&r);
|
||||
assert_eq!(l.len(), 3, "{l:?}");
|
||||
assert!(l.iter().any(|d| d.contains("Split Toning (ADBE Lumetri-0032) is not imported")), "{l:?}");
|
||||
assert!(l.iter().any(|d| d.contains("Legacy Tone And Saturation (ADBE Lumetri-0097) is on")), "{l:?}");
|
||||
assert!(l.iter().any(|d| d.contains("the Input LUT 'my.cube' is not imported")), "{l:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_tone_on_is_reported_whatever_the_pard_default_says() {
|
||||
// After the project is opened and saved again, the `pard` default of Legacy Tone And
|
||||
// Saturation reads on (versions/1200-lumetri-26 / -25x / -24x; off in 1200 as the script
|
||||
// saved it): a record that says on is still reported, a missing record is not.
|
||||
let mut d = defs();
|
||||
if let Some((_, Def::Check { default })) = d.iter_mut().find(|(s, _)| *s == 97) {
|
||||
*default = true;
|
||||
}
|
||||
let (_, r) = run_with(&d, &[(97, Rec::Num(1.0, 3))]);
|
||||
let l = losses(&r);
|
||||
assert!(l.len() == 1 && l[0].contains("Legacy Tone And Saturation (ADBE Lumetri-0097) is on"), "{l:?}");
|
||||
let (_, r) = run_with(&d, &[]);
|
||||
assert_eq!(losses(&r), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_scale_or_parameter_is_reported_not_guessed() {
|
||||
let mut d = defs();
|
||||
// Exposure with another valid range (an older or newer version): not guessed at.
|
||||
if let Some((_, Def::Float { min, max, .. })) = d.iter_mut().find(|(s, _)| *s == 11) {
|
||||
(*min, *max) = (-100.0, 100.0);
|
||||
}
|
||||
// A parameter this importer does not know, set.
|
||||
d.push((200, Def::Float { name: "Future", default: 0.0, min: -1.0, max: 1.0 }));
|
||||
let (g, r) = run_with(&d, &[(11, Rec::Num(3.0, 1)), (200, Rec::Num(0.5, 1))]);
|
||||
assert_eq!(num(&g, "basicCorrection/tone/exposure"), 0.0);
|
||||
let l = losses(&r);
|
||||
assert!(l.iter().any(|x| x.contains("Exposure (ADBE Lumetri-0011) has the valid maximum Some(100.0), expected 7")), "{l:?}");
|
||||
assert!(l.iter().any(|x| x.contains("Future (ADBE Lumetri-0200) not imported")), "{l:?}");
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
//! After Effects' Puppet (`ADBE FreePin3`, #3093) arrives as EffectCraft's Puppet
|
||||
//! (`ec.distort.puppet`) in its place in the stack, and what its meshes and pins hold is reported
|
||||
//! mesh by mesh instead of the one line "has the effect 'Puppet'". The effect is built into the
|
||||
//! committed `synthetic.aep` in place of its Glow (`fx_inject`, in memory), in the tree
|
||||
//! docs/aep-format.md 3.7 gives for Puppet. That tree comes from an external scripting listing,
|
||||
//! not a byte sample: these tests prove the reader's walk and its report, not the format.
|
||||
|
||||
use effectcraft_aep::Chunk;
|
||||
use effectcraft_aep::import::{ImportOptions, LossCategory, import};
|
||||
use effectcraft_project::Project;
|
||||
|
||||
use crate::import::fx_inject::{bytes_with_effect, import_bytes, keyed_record, losses, nested, record, tdmn, utf8};
|
||||
|
||||
const SYNTHETIC: &[u8] = include_bytes!("../../fixtures/synthetic.aep");
|
||||
const PUPPET: &str = "ADBE FreePin3";
|
||||
const POS_ATOM: &str = "ADBE FreePin3 PosPin Atom";
|
||||
|
||||
fn tdsb(v: u32) -> Chunk<'static> {
|
||||
Chunk::data(*b"tdsb", v.to_be_bytes().to_vec())
|
||||
}
|
||||
|
||||
/// A property group of `entries` (match name, node).
|
||||
fn group(entries: Vec<(&str, Chunk<'static>)>) -> Chunk<'static> {
|
||||
let mut kids = vec![tdsb(1)];
|
||||
for (name, node) in entries {
|
||||
kids.push(tdmn(name));
|
||||
kids.push(node);
|
||||
}
|
||||
Chunk::list(*b"tdgp", kids)
|
||||
}
|
||||
|
||||
/// A pin atom with its hidden vertex records and Position (keyed when `keys`). The values are
|
||||
/// one-component records: the reader only asks whether a pin is keyed.
|
||||
fn pin(t: &Chunk<'_>, atom: &'static str, keys: bool) -> (&'static str, Chunk<'static>) {
|
||||
let position = if keys { keyed_record(t, &[(0, 1.0), (25_600, 2.0)]) } else { record(t, 1.0) };
|
||||
(
|
||||
atom,
|
||||
group(vec![
|
||||
("ADBE FreePin3 PosPin Vtx Offset", record(t, 0.0)),
|
||||
("ADBE FreePin3 PosPin Vtx Index", record(t, 7.0)),
|
||||
("ADBE FreePin3 PosPin Position", position),
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
/// `Mesh 1`: Triangles 500, Expansion 5, four Deform pins (one keyed, one with an atom name
|
||||
/// nobody knows) and a property among them, no Overlap pin, one Stiffness pin, and a group
|
||||
/// nobody knows.
|
||||
fn full_mesh(t: &Chunk<'_>) -> Chunk<'static> {
|
||||
let mesh_data =
|
||||
Chunk::list(*b"tdbs", vec![tdsb(1), Chunk::data(*b"cdat", vec![0u8; 4]), Chunk::list(*b"aRbs", vec![Chunk::data(*b"aRbp", vec![1, 2, 3, 4])])]);
|
||||
let deform = group(vec![
|
||||
pin(t, POS_ATOM, false),
|
||||
pin(t, POS_ATOM, true),
|
||||
pin(t, POS_ATOM, false),
|
||||
pin(t, "ADBE FreePin3 Test Atom", false),
|
||||
("ADBE FreePin3 Test Prop", record(t, 3.0)),
|
||||
]);
|
||||
let starch = group(vec![("ADBE FreePin3 StarchPin Atom", group(vec![("ADBE FreePin3 StarchPin Amount", record(t, 0.5))]))]);
|
||||
group(vec![
|
||||
("ADBE FreePin3 Mesh", mesh_data),
|
||||
("ADBE FreePin3 Mesh Tri Count", record(t, 500.0)),
|
||||
("ADBE FreePin3 Mesh Expansion", record(t, 5.0)),
|
||||
("ADBE FreePin3 PosPins", deform),
|
||||
("ADBE FreePin3 HghtPins", group(vec![])),
|
||||
("ADBE FreePin3 StarchPins", starch),
|
||||
("ADBE FreePin3 Test Group", group(vec![])),
|
||||
])
|
||||
}
|
||||
|
||||
/// The Puppet instance: `fx_switch` is the value group's `tdsb`, `arap` its mesh tree (`None`:
|
||||
/// none), `extra` more entries of its value group.
|
||||
fn sspc(fx_switch: u32, arap: Option<Chunk<'static>>, extra: Vec<(&str, Chunk<'static>)>) -> Chunk<'static> {
|
||||
let pard = |kind: u8| {
|
||||
let mut b = vec![0u8; 148];
|
||||
b[15] = kind;
|
||||
Chunk::data(*b"pard", b)
|
||||
};
|
||||
let part = vec![Chunk::data(*b"parn", 2u32.to_be_bytes().to_vec()), tdmn("ADBE FreePin3-0000"), pard(0), tdmn("ADBE Effect Built In Params"), pard(9)];
|
||||
let mut values = vec![tdsb(fx_switch)];
|
||||
if let Some(arap) = arap {
|
||||
values.push(tdmn("ADBE FreePin3 ARAP Group"));
|
||||
values.push(arap);
|
||||
}
|
||||
for (name, node) in extra {
|
||||
values.push(tdmn(name));
|
||||
values.push(node);
|
||||
}
|
||||
Chunk::list(*b"sspc", vec![nested(*b"fnam", vec![utf8("Puppet")]), Chunk::list(*b"parT", part), Chunk::list(*b"tdgp", values)])
|
||||
}
|
||||
|
||||
/// The layer that holds `effect`, the effect's place in that layer's stack and the stack's size.
|
||||
fn stack_place(bytes: &[u8], effect: &str) -> Option<(String, usize, usize)> {
|
||||
let mut project = Project::default();
|
||||
let none = |_: &str| false;
|
||||
import(&mut project, bytes, &ImportOptions { allow_losses: true, ..ImportOptions::new(&none) }).unwrap();
|
||||
project.comps().flat_map(|(_, c)| c.layers.iter()).find_map(|l| {
|
||||
let fx: Vec<_> = l.props.sub("effects")?.children.iter().filter_map(|n| n.as_group()).collect();
|
||||
let at = fx.iter().position(|g| g.match_id == effect)?;
|
||||
Some((l.name.clone(), at, fx.len()))
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puppet_arrives_and_its_pins_are_reported_mesh_by_mesh() {
|
||||
let bytes = bytes_with_effect(PUPPET, |t| {
|
||||
let arap = group(vec![("ADBE FreePin3 Outlines", group(vec![])), ("ADBE FreePin3 Mesh Group", group(vec![("ADBE FreePin3 Mesh Atom", full_mesh(t))]))]);
|
||||
sspc(1, Some(arap), vec![("ADBE FreePin3 On Transparent", record(t, 1.0))])
|
||||
});
|
||||
let (g, r) = import_bytes(&bytes, effectcraft_effects::puppet::ID);
|
||||
let g = g.expect("an EffectCraft Puppet in the Glow's place");
|
||||
assert!(g.enabled);
|
||||
assert_eq!(g.name, "Puppet");
|
||||
// No pin is made up: the rest places are not decoded.
|
||||
assert_eq!(effectcraft_effects::puppet::pin_count(&g), 0);
|
||||
// In the Glow's place in its layer's stack (the second of two effects there).
|
||||
assert!(stack_place(SYNTHETIC, "ec.stylize.glow").is_some_and(|(_, at, n)| at > 0 && n > 1));
|
||||
assert_eq!(stack_place(&bytes, effectcraft_effects::puppet::ID), stack_place(SYNTHETIC, "ec.stylize.glow"));
|
||||
let l = losses(&r, LossCategory::Effects, PUPPET);
|
||||
assert_eq!(l.len(), 2, "{l:#?}");
|
||||
assert!(!l.iter().any(|d| d.contains("has the effect")), "not the old one-line report: {l:#?}");
|
||||
assert!(l.iter().any(|d| d.ends_with("property ADBE FreePin3 On Transparent (1) not imported")), "{l:#?}");
|
||||
let mesh = l.iter().find(|d| d.contains("imported without its meshes and pins")).expect("the mesh report");
|
||||
assert!(
|
||||
mesh.ends_with(
|
||||
": Mesh 1 (Triangles 500, Expansion 5): Deform 4 pins (1 keyed) [ADBE FreePin3 PosPin Atom, ADBE FreePin3 Test Atom], \
|
||||
Overlap no pins, Stiffness 1 pin (0 keyed) [ADBE FreePin3 StarchPin Atom]; not recognised: ADBE FreePin3 Test Prop, ADBE FreePin3 Test Group"
|
||||
),
|
||||
"{mesh}"
|
||||
);
|
||||
assert_eq!(losses(&r, LossCategory::DamagedData, PUPPET), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_meshes_are_listed_up_to_ten_and_names_once() {
|
||||
let bytes = bytes_with_effect(PUPPET, |_| {
|
||||
let mesh = || group(vec![("ADBE FreePin3 Test Group", group(vec![]))]);
|
||||
let meshes = (0..12).map(|_| ("ADBE FreePin3 Mesh Atom", mesh())).collect();
|
||||
sspc(1, Some(group(vec![("ADBE FreePin3 Mesh Group", group(meshes))])), vec![])
|
||||
});
|
||||
let (_, r) = import_bytes(&bytes, effectcraft_effects::puppet::ID);
|
||||
let l = losses(&r, LossCategory::Effects, PUPPET);
|
||||
assert_eq!(l.len(), 1, "{l:#?}");
|
||||
assert!(l[0].ends_with("Mesh 9: no pin groups; Mesh 10: no pin groups; and 2 more; not recognised: ADBE FreePin3 Test Group"), "{}", l[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_puppet_without_a_mesh_loses_nothing_and_keeps_its_fx_switch() {
|
||||
let bytes = bytes_with_effect(PUPPET, |_| sspc(0, Some(group(vec![("ADBE FreePin3 Mesh Group", group(vec![]))])), vec![]));
|
||||
let (g, r) = import_bytes(&bytes, effectcraft_effects::puppet::ID);
|
||||
let g = g.expect("an EffectCraft Puppet");
|
||||
assert!(!g.enabled, "switched off in the Effect Controls");
|
||||
assert_eq!(losses(&r, LossCategory::Effects, PUPPET), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_puppet_whose_mesh_tree_is_not_found_is_reported() {
|
||||
let bytes = bytes_with_effect(PUPPET, |t| sspc(1, None, vec![("ADBE FreePin3 Other Group", group(vec![("x", record(t, 1.0))]))]));
|
||||
let (g, r) = import_bytes(&bytes, effectcraft_effects::puppet::ID);
|
||||
assert!(g.is_some(), "the effect still arrives");
|
||||
let l = losses(&r, LossCategory::Effects, PUPPET);
|
||||
assert_eq!(l.len(), 1, "{l:#?}");
|
||||
assert!(
|
||||
l[0].ends_with(
|
||||
"imported without meshes or pins: its mesh group (ADBE FreePin3 ARAP Group / ADBE FreePin3 Mesh Group) was not found or is not a group; \
|
||||
not recognised: ADBE FreePin3 Other Group"
|
||||
),
|
||||
"{}",
|
||||
l[0]
|
||||
);
|
||||
}
|
||||
@@ -626,9 +626,10 @@ fn bogus_effect_structure_is_reported_not_imported_blindly() {
|
||||
"{r:?}"
|
||||
);
|
||||
|
||||
// No project-level definitions: a Ramp with an empty parT cannot be read and is reported; one
|
||||
// with its own parT still arrives. (In the single-case file one of the two had its own; in
|
||||
// the combined file an earlier case holds the definitions, so both take the project copy.)
|
||||
// No project-level definitions: a Ramp with an empty parT borrows the definitions of a Ramp
|
||||
// in the file that has its own parT (#3170). In the single-case file one of the two has its
|
||||
// own, so both arrive; in the case cut from the combined file an earlier case (not kept)
|
||||
// held them, so neither has readable definitions and both are reported.
|
||||
let own = {
|
||||
let bytes = skip!(crate::common::try_fixture("383-fx-ramp-on-100x100-start-10-20.aep"));
|
||||
let file = parse(&bytes).unwrap();
|
||||
@@ -646,8 +647,9 @@ fn bogus_effect_structure_is_reported_not_imported_blindly() {
|
||||
}
|
||||
}));
|
||||
let r = report_of(&bytes);
|
||||
assert_eq!(r.imported.effects, own, "{r:?}");
|
||||
assert_eq!(r.count(effectcraft_aep::import::LossCategory::Effects), 2 - own, "{r:?}");
|
||||
let arrive = if own > 0 { 2 } else { 0 };
|
||||
assert_eq!(r.imported.effects, arrive, "{r:?}");
|
||||
assert_eq!(r.count(effectcraft_aep::import::LossCategory::Effects), 2 - arrive, "{r:?}");
|
||||
assert!(try_all(&bytes)); // 368, the first Gradient Ramp of its file, has its own parT: it still arrives.
|
||||
let bytes = skip!(mutate("368-fx-ramp-default.aep", &mut |c| {
|
||||
if let Body::List { list_type, .. } = &mut c.body
|
||||
|
||||
@@ -77,6 +77,167 @@ fn a_sequence_with_long_names_is_not_expanded_past_the_byte_budget() {
|
||||
assert_eq!(longest, 0, "the sequence was not expanded");
|
||||
}
|
||||
|
||||
/// The project with the sequence's naming (the chunks between `Als2` and `opti` in its `Pin `)
|
||||
/// replaced by `naming`, and the `sspc` numbering (start, end, digits at 172, 176, 180) set to
|
||||
/// `numbering` when given.
|
||||
fn with_sequence_naming(naming: &[Chunk<'static>], numbering: Option<[u32; 3]>) -> Vec<u8> {
|
||||
let mut file: AepFile<'_> = parse(SYNTHETIC).unwrap();
|
||||
let mut patched = 0;
|
||||
visit(&mut file.chunks, &mut |c| {
|
||||
if !c.is_list(b"Pin ") {
|
||||
return;
|
||||
}
|
||||
let Body::List { children, .. } = &mut c.body else { return };
|
||||
let Some(als) = children.iter().position(|k| k.is_list(b"Als2")) else { return };
|
||||
let Some(opti) = children.iter().position(|k| k.is(b"opti")) else { return };
|
||||
assert!(als < opti);
|
||||
let tail = children.split_off(opti);
|
||||
children.truncate(als + 1);
|
||||
children.extend(naming.iter().map(shorten));
|
||||
children.extend(tail);
|
||||
if let Some(numbering) = numbering {
|
||||
let sspc = children.iter_mut().find(|k| k.is(b"sspc")).unwrap();
|
||||
for (at, v) in [172, 176, 180].into_iter().zip(numbering) {
|
||||
set_data(sspc, at, &v.to_be_bytes());
|
||||
}
|
||||
}
|
||||
patched += 1;
|
||||
});
|
||||
assert_eq!(patched, 1, "the synthetic project has one sequence");
|
||||
write(&file).unwrap()
|
||||
}
|
||||
|
||||
/// `bytes` with the first file name the sequence stores in its png `opti` (byte 58, NUL-terminated)
|
||||
/// set to `name`.
|
||||
fn with_first_file(bytes: &[u8], name: &str) -> Vec<u8> {
|
||||
let mut file: AepFile<'_> = parse(bytes).unwrap();
|
||||
let mut patched = 0;
|
||||
visit(&mut file.chunks, &mut |c| {
|
||||
if c.is(b"opti") && c.bytes().is_some_and(|d| d.get(..4) == Some(&b"png!"[..])) {
|
||||
let mut z = name.as_bytes().to_vec();
|
||||
z.push(0);
|
||||
set_data(c, 58, &z);
|
||||
patched += 1;
|
||||
}
|
||||
});
|
||||
assert_eq!(patched, 1, "the synthetic project has one png item");
|
||||
write(&file).unwrap()
|
||||
}
|
||||
|
||||
/// The imported sequence item's footage.
|
||||
fn the_footage(p: &Project) -> effectcraft_project::Footage {
|
||||
p.items.values().find_map(|i| if let ItemKind::Footage(f) = &i.kind { Some(f.clone()) } else { None }).expect("the sequence")
|
||||
}
|
||||
|
||||
/// The file names (without the folder) of a footage's sequence.
|
||||
fn file_names(f: &effectcraft_project::Footage) -> Vec<String> {
|
||||
f.sequence.iter().map(|s| s.rsplit(['\\', '/']).next().unwrap_or_default().to_string()).collect()
|
||||
}
|
||||
|
||||
/// A copy of `c` that borrows for any lifetime (a chunk is covariant in its lifetime).
|
||||
fn shorten<'a>(c: &Chunk<'static>) -> Chunk<'a> {
|
||||
c.clone()
|
||||
}
|
||||
|
||||
/// A `Utf8` string chunk holding `s`.
|
||||
fn utf8(s: &str) -> Chunk<'static> {
|
||||
Chunk::data(*b"Utf8", s.as_bytes().to_vec())
|
||||
}
|
||||
|
||||
/// A sequence whose naming has only a suffix (no filename prefix) imports as a sequence of its files.
|
||||
#[test]
|
||||
fn a_sequence_without_a_filename_prefix_imports_its_files() {
|
||||
// `0001.png` .. `0003.png`: After Effects omits the empty prefix, so only `.png` follows the
|
||||
// alias. Before, `.png` was read as the prefix, the suffix was missing and the item became a
|
||||
// video linked to its folder.
|
||||
let (p, r) = run(&with_sequence_naming(&[utf8(".png")], None), true);
|
||||
r.unwrap();
|
||||
let f = the_footage(&p);
|
||||
assert_eq!(f.kind, FootageKind::Sequence);
|
||||
assert_eq!(file_names(&f), ["0001.png", "0002.png", "0003.png"]);
|
||||
assert!(f.path.ends_with("0001.png"), "{}", f.path);
|
||||
}
|
||||
|
||||
/// A single `Utf8` naming part is read as the suffix or the prefix depending on how the stored first file name matches it.
|
||||
#[test]
|
||||
fn a_single_naming_part_is_placed_by_the_stored_first_file_name() {
|
||||
// `0001_v2.png` ..: the one `Utf8` is the suffix, as the first file name (`opti`) ends with it.
|
||||
let bytes = with_first_file(&with_sequence_naming(&[utf8("_v2.png")], None), "0001_v2.png");
|
||||
let (p, r) = run(&bytes, true);
|
||||
r.unwrap();
|
||||
let f = the_footage(&p);
|
||||
assert_eq!(f.kind, FootageKind::Sequence);
|
||||
assert_eq!(file_names(&f), ["0001_v2.png", "0002_v2.png", "0003_v2.png"]);
|
||||
// `shot_0001` ..: a name without an extension; the one `Utf8` is the prefix.
|
||||
let bytes = with_first_file(&with_sequence_naming(&[utf8("shot_")], None), "shot_0001");
|
||||
let (p, r) = run(&bytes, true);
|
||||
r.unwrap();
|
||||
assert_eq!(file_names(&the_footage(&p)), ["shot_0001", "shot_0002", "shot_0003"]);
|
||||
// Without that evidence (the stored first file is `shot_0001.png`), a part that is no extension
|
||||
// is not guessed: the item is reported.
|
||||
let bytes = with_sequence_naming(&[utf8("_v2.png")], None);
|
||||
reported_and_refused(&bytes, LossCategory::DamagedData, "image sequence naming not read, linked to its folder");
|
||||
}
|
||||
|
||||
/// A file list containing a name with a separator, drive colon, dot directory or control character is refused and reported.
|
||||
#[test]
|
||||
fn a_file_list_with_a_name_that_is_not_a_plain_file_name_is_reported() {
|
||||
// The listed names are joined to the folder: a separator, a drive colon or a dot directory
|
||||
// would point outside it, and a NUL or another control character is no file name, so such a
|
||||
// list is not read.
|
||||
for hostile in ["..\\..\\x.png", "../x.png", "C:x.png", "..", ".", "x\0.png", "x\n.png"] {
|
||||
let list = Chunk::list(*b"StVc", vec![Chunk::data(*b"StVS", 2u32.to_le_bytes().to_vec()), utf8("a.png"), utf8(hostile)]);
|
||||
let bytes = with_sequence_naming(&[list], Some([u32::MAX, u32::MAX, 0]));
|
||||
reported_and_refused(&bytes, LossCategory::DamagedData, "image sequence naming not read, linked to its folder");
|
||||
}
|
||||
}
|
||||
|
||||
/// A file list with a missing or too short count chunk is refused and reported.
|
||||
#[test]
|
||||
fn a_file_list_without_a_readable_count_is_reported() {
|
||||
// No `StVS`, or one too short for its `u32`: the list is not read rather than taken whole.
|
||||
for count in [None, Some(vec![2u8, 0])] {
|
||||
let mut kids: Vec<Chunk<'static>> = count.map(|c| Chunk::data(*b"StVS", c)).into_iter().collect();
|
||||
kids.extend([utf8("a.png"), utf8("b.png")]);
|
||||
let bytes = with_sequence_naming(&[Chunk::list(*b"StVc", kids)], Some([u32::MAX, u32::MAX, 0]));
|
||||
reported_and_refused(&bytes, LossCategory::DamagedData, "image sequence naming not read, linked to its folder");
|
||||
}
|
||||
}
|
||||
|
||||
/// A force-alphabetical sequence imports its stored file list in the stored order.
|
||||
#[test]
|
||||
fn a_sequence_imported_with_force_alphabetical_order_imports_its_file_list() {
|
||||
// Force alphabetical order: `LIST StVc` with `StVS` (the count, little-endian) and one `Utf8`
|
||||
// per file instead of the prefix and suffix; the numbering in `sspc` is not set. Before, the
|
||||
// item became a video linked to its folder.
|
||||
let list = Chunk::list(*b"StVc", vec![Chunk::data(*b"StVS", 3u32.to_le_bytes().to_vec()), utf8("b_take.png"), utf8("a_take.png"), utf8("c_take.png")]);
|
||||
let (p, r) = run(&with_sequence_naming(&[list], Some([u32::MAX, u32::MAX, 0])), true);
|
||||
let r = r.unwrap();
|
||||
let f = the_footage(&p);
|
||||
assert_eq!(f.kind, FootageKind::Sequence);
|
||||
// The stored order is kept.
|
||||
assert_eq!(file_names(&f), ["b_take.png", "a_take.png", "c_take.png"]);
|
||||
assert!(f.path.ends_with("b_take.png"), "{}", f.path);
|
||||
assert!(losses(&r, LossCategory::DamagedData).is_empty(), "{:?}", r.report.losses);
|
||||
// The count bounds the list.
|
||||
let list = Chunk::list(*b"StVc", vec![Chunk::data(*b"StVS", 2u32.to_le_bytes().to_vec()), utf8("x.png"), utf8("y.png"), utf8("z.png")]);
|
||||
let (p, r) = run(&with_sequence_naming(&[list], Some([u32::MAX, u32::MAX, 0])), true);
|
||||
r.unwrap();
|
||||
assert_eq!(file_names(&the_footage(&p)), ["x.png", "y.png"]);
|
||||
}
|
||||
|
||||
/// A sequence with no naming or an empty file list is reported and linked to its folder.
|
||||
#[test]
|
||||
fn a_sequence_whose_naming_cannot_be_read_is_reported() {
|
||||
// No naming at all after the alias. Before, the item silently linked its folder.
|
||||
let bytes = with_sequence_naming(&[], None);
|
||||
reported_and_refused(&bytes, LossCategory::DamagedData, "image sequence naming not read, linked to its folder");
|
||||
// An empty file list is no naming either.
|
||||
let empty = Chunk::list(*b"StVc", vec![Chunk::data(*b"StVS", 0u32.to_le_bytes().to_vec())]);
|
||||
let bytes = with_sequence_naming(&[empty], Some([u32::MAX, u32::MAX, 0]));
|
||||
reported_and_refused(&bytes, LossCategory::DamagedData, "image sequence naming not read, linked to its folder");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_solid_with_a_nan_channel_is_repaired_and_reported() {
|
||||
let nan = f32::NAN.to_be_bytes();
|
||||
@@ -273,6 +434,47 @@ fn layer_times_with_a_zero_denominator_are_reported() {
|
||||
assert!(losses(&r.unwrap(), LossCategory::DamagedData).is_empty());
|
||||
}
|
||||
|
||||
/// The time stretch of every imported layer.
|
||||
fn stretches(p: &Project) -> Vec<f64> {
|
||||
p.items.values().filter_map(|i| if let ItemKind::Comp(c) = &i.kind { Some(c) } else { None }).flat_map(|c| c.layers.iter().map(|l| l.stretch)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_time_stretch_outside_one_to_9900_percent_is_clamped_and_reported() {
|
||||
// ldta byte 8 is the stretch numerator (i32), byte 108 the denominator (u32). 1/(2^31 - 1) is
|
||||
// about 5e-8 %: imported as stored, the layer and audio time divided by ~0 (#3247).
|
||||
for (num, den, want) in [(1i32, i32::MAX as u32, 1.0), (-1, i32::MAX as u32, -1.0), (i32::MAX, 1, 9900.0), (i32::MIN, 1, -9900.0)] {
|
||||
let bytes = mutate(&mut |c| {
|
||||
if c.id == *b"ldta" {
|
||||
set_data(c, 8, &num.to_be_bytes());
|
||||
set_data(c, 108, &den.to_be_bytes());
|
||||
}
|
||||
});
|
||||
let (p, r) = run(&bytes, true);
|
||||
let r = r.unwrap();
|
||||
let found = losses(&r, LossCategory::DamagedData);
|
||||
assert!(found.iter().any(|d| d.contains("time stretch") && d.contains("outside")), "{num}/{den}: {found:?}");
|
||||
let s = stretches(&p);
|
||||
assert!(!s.is_empty() && s.iter().all(|v| *v == want), "{num}/{den}: {s:?}");
|
||||
let (p, r) = run(&bytes, false);
|
||||
assert!(r.is_err(), "{num}/{den}: strict import must refuse");
|
||||
assert!(p.items.is_empty());
|
||||
}
|
||||
// A small stretch within the range (1/64: 1.5625 %) and -9900 % itself are no damage.
|
||||
for (num, den, want) in [(1i32, 64u32, 1.5625), (-99, 1, -9900.0)] {
|
||||
let bytes = mutate(&mut |c| {
|
||||
if c.id == *b"ldta" {
|
||||
set_data(c, 8, &num.to_be_bytes());
|
||||
set_data(c, 108, &den.to_be_bytes());
|
||||
}
|
||||
});
|
||||
let (p, r) = run(&bytes, true);
|
||||
let r = r.unwrap();
|
||||
assert!(!losses(&r, LossCategory::DamagedData).iter().any(|d| d.contains("time stretch")), "{num}/{den}: {:?}", r.report.losses);
|
||||
assert!(stretches(&p).iter().all(|v| *v == want), "{num}/{den}: {:?}", stretches(&p));
|
||||
}
|
||||
}
|
||||
|
||||
/// The project with `nhed` bytes from `at` replaced.
|
||||
fn header(at: usize, bytes: &[u8]) -> Vec<u8> {
|
||||
mutate(&mut |c| {
|
||||
@@ -392,6 +594,186 @@ fn default_state_effect_records_import_like_written_ones() {
|
||||
assert_eq!(clip, vec![("clipToOutputBlack".to_string(), effectcraft_keyframe::Value::Enum(1))]);
|
||||
}
|
||||
|
||||
/// Sets the `cdat` of the value record `param` in the value group `tdgp` (the first `f64`), and
|
||||
/// its state to `tdsb` when given. True when the group has that record.
|
||||
fn patch_record(tdgp: &mut Chunk<'_>, param: &str, tdsb: Option<u32>, value: f64) -> bool {
|
||||
let kids = tdgp.children_mut();
|
||||
let Some(at) = kids.iter().position(|k| k.is(b"tdmn") && k.bytes().is_some_and(|b| b.split(|x| *x == 0).next() == Some(param.as_bytes()))) else {
|
||||
return false;
|
||||
};
|
||||
let Some(record) = kids.get_mut(at + 1).filter(|k| k.is_list(b"tdbs")) else { return false };
|
||||
for k in record.children_mut() {
|
||||
if k.is(b"tdsb")
|
||||
&& let Some(state) = tdsb
|
||||
{
|
||||
set_data(k, 0, &state.to_be_bytes());
|
||||
} else if k.is(b"cdat") {
|
||||
set_data(k, 0, &value.to_be_bytes());
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// The project without its project-level effect definitions (`EfdG`; some files have none, seen
|
||||
/// in the projects inside After Effects-made Motion Graphics templates) and with a second Glow
|
||||
/// just above the first on the layer: a copy of it whose `parT` is empty, as such files write
|
||||
/// every instance of a type but one. `edit_copy` is applied to every chunk of the copy; with
|
||||
/// `empty_original` the original's `parT` is emptied too.
|
||||
fn second_glow_without_definitions(edit_copy: &mut dyn FnMut(&mut Chunk<'_>), empty_original: bool) -> Vec<u8> {
|
||||
fn empty_part(fx: &mut Chunk<'_>) {
|
||||
for k in fx.children_mut() {
|
||||
if let Body::List { list_type, children, .. } = &mut k.body
|
||||
&& list_type == b"parT"
|
||||
{
|
||||
children.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut file: AepFile<'_> = parse(SYNTHETIC).unwrap();
|
||||
let mut added = 0;
|
||||
visit(&mut file.chunks, &mut |c| {
|
||||
if let Body::List { list_type, children, .. } = &mut c.body {
|
||||
if list_type == b"EfdG" {
|
||||
*list_type = *b"XfdG";
|
||||
return;
|
||||
}
|
||||
if list_type != b"tdgp" {
|
||||
return;
|
||||
}
|
||||
let Some(at) = children.iter().position(|k| k.is(b"tdmn") && k.bytes().is_some_and(|b| b.split(|x| *x == 0).next() == Some(b"ADBE Glo2"))) else {
|
||||
return;
|
||||
};
|
||||
let (Some(name), Some(mut fx)) = (children.get(at).cloned(), children.get(at + 1).filter(|k| k.is_list(b"sspc")).cloned()) else { return };
|
||||
empty_part(&mut fx);
|
||||
edit_copy(&mut fx);
|
||||
visit(fx.children_mut(), edit_copy);
|
||||
if empty_original && let Some(original) = children.get_mut(at + 1) {
|
||||
empty_part(original);
|
||||
}
|
||||
children.insert(at, fx);
|
||||
children.insert(at, name);
|
||||
added += 1;
|
||||
}
|
||||
});
|
||||
assert_eq!(added, 1, "one Glow on the layer");
|
||||
write(&file).unwrap()
|
||||
}
|
||||
|
||||
/// The imported Glows in stack order, each as its property values by property id.
|
||||
fn glow_values(p: &Project) -> Vec<Vec<(String, effectcraft_keyframe::Value)>> {
|
||||
let glow = effectcraft_project::GroupKind::Effect { effect: "ec.stylize.glow".to_string() };
|
||||
let mut glows = Vec::new();
|
||||
for item in p.items.values() {
|
||||
if let ItemKind::Comp(c) = &item.kind {
|
||||
for layer in &c.layers {
|
||||
for node in layer.effects().map(|fx| fx.children.as_slice()).unwrap_or_default() {
|
||||
if let effectcraft_project::Node::Group(g) = node
|
||||
&& g.kind == glow
|
||||
{
|
||||
let mut values = Vec::new();
|
||||
g.walk("", &mut |_, prop| values.push((prop.match_id.clone(), prop.value.clone())));
|
||||
glows.push(values);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
glows
|
||||
}
|
||||
|
||||
/// The value of property `id` in `values`.
|
||||
fn value_of<'v>(values: Option<&'v Vec<(String, effectcraft_keyframe::Value)>>, id: &str) -> Option<&'v effectcraft_keyframe::Value> {
|
||||
values?.iter().find(|(i, _)| i == id).map(|(_, v)| v)
|
||||
}
|
||||
|
||||
/// Every instance of an effect type but one has an empty `parT`; without a project-level copy the
|
||||
/// others borrow the definitions of that one (#3170: 129 Color Controls of a template were
|
||||
/// reported "without readable parameter definitions" and dropped).
|
||||
#[test]
|
||||
fn an_instance_without_definitions_imports_with_those_of_another_instance() {
|
||||
// Both Glows import; before, the copy was reported and not imported.
|
||||
let (p, r) = run(&second_glow_without_definitions(&mut |_| {}, false), true);
|
||||
let r = r.unwrap();
|
||||
let found = losses(&r, LossCategory::Effects);
|
||||
assert!(!found.iter().any(|d| d.contains("without readable parameter definitions") || d.contains("no parameter definition")), "{found:?}");
|
||||
assert_eq!(r.report.imported.effects, 3, "Levels and both Glows: {:?}", r.report.losses);
|
||||
let glows = glow_values(&p);
|
||||
assert_eq!(glows.len(), 2);
|
||||
assert_eq!(glows.first(), glows.get(1), "the copy has the same records, so the same values");
|
||||
|
||||
// The values come from each instance's own records: the copy's A & B Midpoint (`-0011`) set
|
||||
// to 0.75 arrives as 75 % in the copy only; the Glow it borrows from keeps its own.
|
||||
let mut patched = 0;
|
||||
let bytes = second_glow_without_definitions(
|
||||
&mut |c| {
|
||||
if c.is_list(b"tdgp") && patch_record(c, "ADBE Glo2-0011", None, 0.75) {
|
||||
patched += 1;
|
||||
}
|
||||
},
|
||||
false,
|
||||
);
|
||||
assert_eq!(patched, 1, "the copy's record");
|
||||
let (p, r) = run(&bytes, true);
|
||||
r.unwrap();
|
||||
let glows = glow_values(&p);
|
||||
let seventy_five = effectcraft_keyframe::Value::Scalar(75.0);
|
||||
assert_eq!(value_of(glows.first(), "abMidpoint"), Some(&seventy_five));
|
||||
let own = value_of(glows.get(1), "abMidpoint");
|
||||
assert!(own.is_some() && own != Some(&seventy_five), "{own:?}");
|
||||
}
|
||||
|
||||
/// A value record that the borrowed definitions do not name is reported, not dropped silently.
|
||||
#[test]
|
||||
fn a_value_record_the_borrowed_definitions_do_not_name_is_reported() {
|
||||
let bytes = second_glow_without_definitions(
|
||||
&mut |c| {
|
||||
if c.is(b"tdmn") && c.bytes().is_some_and(|b| b.split(|x| *x == 0).next() == Some(b"ADBE Glo2-0011")) {
|
||||
set_data(c, 0, b"ADBE Glo2-0099\0");
|
||||
}
|
||||
},
|
||||
false,
|
||||
);
|
||||
let (_, r) = run(&bytes, true);
|
||||
let r = r.unwrap();
|
||||
let found = losses(&r, LossCategory::Effects);
|
||||
assert!(found.iter().any(|d| d.contains("ADBE Glo2-0099") && d.contains("no parameter definition")), "{found:?}");
|
||||
assert_eq!(r.report.imported.effects, 3, "{:?}", r.report.losses);
|
||||
}
|
||||
|
||||
/// When no instance of the type keeps its definitions, every instance is reported.
|
||||
#[test]
|
||||
fn instances_without_any_definitions_are_reported() {
|
||||
let (p, r) = run(&second_glow_without_definitions(&mut |_| {}, true), true);
|
||||
let r = r.unwrap();
|
||||
let found = losses(&r, LossCategory::Effects);
|
||||
assert_eq!(found.iter().filter(|d| d.contains("ADBE Glo2") && d.contains("without readable parameter definitions, not imported")).count(), 2, "{found:?}");
|
||||
assert_eq!(r.report.imported.effects, 1, "Levels only: {:?}", r.report.losses);
|
||||
assert!(glow_values(&p).is_empty());
|
||||
}
|
||||
|
||||
/// The reader's side: the copy's definitions come from the other instance.
|
||||
#[test]
|
||||
fn an_instance_without_definitions_reads_those_of_another_instance() {
|
||||
let bytes = second_glow_without_definitions(&mut |_| {}, false);
|
||||
let file = parse(&bytes).unwrap();
|
||||
let project = effectcraft_aep::Project::new(&file).unwrap();
|
||||
let defs = project.effect_definition_index();
|
||||
let mut sources = Vec::new();
|
||||
for item in project.all_items() {
|
||||
let Some(comp) = item.comp() else { continue };
|
||||
for layer in comp.layers() {
|
||||
for fx in layer.effects().iter().filter(|fx| fx.match_name() == "ADBE Glo2") {
|
||||
sources.push(defs.source(fx));
|
||||
let params = defs.params(fx);
|
||||
assert_eq!(params.len(), 16, "Glow's parameters");
|
||||
assert_eq!(params.iter().all(|p| p.borrowed_definition), defs.source(fx) == effectcraft_aep::DefinitionSource::OtherInstance);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The copy is above the instance it borrows from (earlier in the stack).
|
||||
assert_eq!(sources, [effectcraft_aep::DefinitionSource::OtherInstance, effectcraft_aep::DefinitionSource::Instance]);
|
||||
}
|
||||
|
||||
/// A record in a state other than 1 or 3 is not read and is reported.
|
||||
#[test]
|
||||
fn an_effect_record_in_another_state_is_reported_as_damaged() {
|
||||
@@ -403,3 +785,60 @@ fn an_effect_record_in_another_state_is_reported_as_damaged() {
|
||||
assert!(damaged.iter().any(|l| l.contains("unreadable value")), "tdsb {tdsb}: {:?}", r.report.losses);
|
||||
}
|
||||
}
|
||||
|
||||
/// The project with a value record for Glow's Color A (`ADBE Glo2-0012`, which the project does
|
||||
/// not store) in state `tdsb` holding the ARGB doubles `argb` (0..255): a copy of the A & B
|
||||
/// Midpoint record (`-0011`) made a 4-component colour without its expression.
|
||||
fn with_color_a(tdsb: u32, argb: [f64; 4]) -> Vec<u8> {
|
||||
let mut file: AepFile<'_> = parse(SYNTHETIC).unwrap();
|
||||
let mut added = 0;
|
||||
visit(&mut file.chunks, &mut |c| {
|
||||
let Body::List { list_type, children, .. } = &mut c.body else { return };
|
||||
if list_type != b"tdgp" {
|
||||
return;
|
||||
}
|
||||
let Some(at) = children.iter().position(|k| k.is(b"tdmn") && k.bytes().is_some_and(|b| b.split(|x| *x == 0).next() == Some(b"ADBE Glo2-0011"))) else {
|
||||
return;
|
||||
};
|
||||
let (Some(mut name), Some(mut record)) = (children.get(at).cloned(), children.get(at + 1).filter(|k| k.is_list(b"tdbs")).cloned()) else { return };
|
||||
set_data(&mut name, 0, b"ADBE Glo2-0012\0");
|
||||
if let Body::List { children: kids, .. } = &mut record.body {
|
||||
kids.retain(|k| !k.is(b"Utf8"));
|
||||
for k in kids.iter_mut() {
|
||||
if k.is(b"tdsb") {
|
||||
set_data(k, 0, &tdsb.to_be_bytes());
|
||||
} else if k.is(b"tdb4") {
|
||||
// 4 components; no expression (bytes 76, 77 and 120).
|
||||
set_data(k, 3, &[4]);
|
||||
for byte in [76, 77, 120] {
|
||||
set_data(k, byte, &[0]);
|
||||
}
|
||||
} else if k.is(b"cdat") {
|
||||
*k = Chunk::data(*b"cdat", argb.iter().flat_map(|v| v.to_be_bytes()).collect::<Vec<u8>>());
|
||||
}
|
||||
}
|
||||
}
|
||||
children.insert(at + 2, record);
|
||||
children.insert(at + 2, name);
|
||||
added += 1;
|
||||
});
|
||||
assert_eq!(added, 2, "the instance and the EfdG copy");
|
||||
write(&file).unwrap()
|
||||
}
|
||||
|
||||
/// A colour recorded in the default state (`tdsb` 3) reads like a written one (#3170's "parameter
|
||||
/// Color ... holds an unreadable value" on 0.21.1, which read only written records).
|
||||
#[test]
|
||||
fn a_default_state_colour_record_holds_the_value() {
|
||||
let argb = [255.0, 51.0, 153.0, 255.0];
|
||||
let (written, r1) = run(&with_color_a(1, argb), true);
|
||||
let (default_state, r3) = run(&with_color_a(3, argb), true);
|
||||
let (r1, r3) = (r1.unwrap(), r3.unwrap());
|
||||
assert!(losses(&r3, LossCategory::DamagedData).is_empty(), "{:?}", r3.report.losses);
|
||||
assert_eq!(r3.report.losses, r1.report.losses);
|
||||
let colour = |p: &Project| value_of(glow_values(p).first(), "colorA").cloned();
|
||||
let (unchanged, _) = run(SYNTHETIC, true);
|
||||
assert!(colour(&written).is_some());
|
||||
assert_eq!(colour(&default_state), colour(&written));
|
||||
assert_ne!(colour(&default_state), colour(&unchanged), "the recorded colour, not the default");
|
||||
}
|
||||
|
||||
@@ -19,3 +19,4 @@ effectcraft-model = { workspace = true }
|
||||
[dev-dependencies]
|
||||
effectcraft-script = { workspace = true }
|
||||
effectcraft-host = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
|
||||
@@ -346,32 +346,60 @@ impl ConfinedFootage {
|
||||
fn inside(&self, f: &Footage) -> bool {
|
||||
std::iter::once(&f.path).chain(&f.sequence).filter(|p| !p.is_empty()).all(|p| self.confinement.check(p, Access::Read).is_ok())
|
||||
}
|
||||
|
||||
/// [`Self::inside`] for a read: a refused one is counted on this thread, so the render
|
||||
/// [`recording`](FootageSource::recording) it can't list what it made without the footage.
|
||||
fn readable(&self, f: &Footage) -> bool {
|
||||
let inside = self.inside(f);
|
||||
if !inside {
|
||||
let _ = REFUSED_READS.try_with(|n| n.set(n.get().wrapping_add(1)));
|
||||
}
|
||||
inside
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// Footage reads refused on this thread (see [`ConfinedFootage::readable`]).
|
||||
static REFUSED_READS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// The number of reads this thread has refused for lying outside the allowed roots.
|
||||
fn refused_reads() -> u64 {
|
||||
REFUSED_READS.try_with(std::cell::Cell::get).unwrap_or(0)
|
||||
}
|
||||
|
||||
impl FootageSource for ConfinedFootage {
|
||||
/// The frame from the inner source, or `None` when the footage lies outside the allowed roots.
|
||||
fn frame(&self, item: ItemId, footage: &Footage, t: Tick) -> Option<Arc<Image>> {
|
||||
self.inside(footage).then(|| self.inner.frame(item, footage, t)).flatten()
|
||||
self.readable(footage).then(|| self.inner.frame(item, footage, t)).flatten()
|
||||
}
|
||||
/// The frame scaled to `width` by `height`, or `None` when the footage lies outside the allowed roots.
|
||||
fn frame_at_size(&self, item: ItemId, footage: &Footage, t: Tick, width: u32, height: u32) -> Option<Arc<Image>> {
|
||||
self.inside(footage).then(|| self.inner.frame_at_size(item, footage, t, width, height)).flatten()
|
||||
self.readable(footage).then(|| self.inner.frame_at_size(item, footage, t, width, height)).flatten()
|
||||
}
|
||||
/// The frame with an explicit size and float curve, or `None` when the footage lies outside the allowed roots.
|
||||
fn frame_with(&self, item: ItemId, footage: &Footage, t: Tick, size: Option<(u32, u32)>, float: FloatCurve) -> Option<Arc<Image>> {
|
||||
self.inside(footage).then(|| self.inner.frame_with(item, footage, t, size, float)).flatten()
|
||||
self.readable(footage).then(|| self.inner.frame_with(item, footage, t, size, float)).flatten()
|
||||
}
|
||||
/// The footage audio from the inner source, or `None` when the footage lies outside the allowed roots.
|
||||
fn audio(&self, item: ItemId, footage: &Footage, t: Tick, frames: usize, rate: u32) -> Option<Vec<f32>> {
|
||||
self.inside(footage).then(|| self.inner.audio(item, footage, t, frames, rate)).flatten()
|
||||
self.readable(footage).then(|| self.inner.audio(item, footage, t, frames, rate)).flatten()
|
||||
}
|
||||
/// The auxiliary channels of the footage frame, or `None` when the footage lies outside the allowed roots.
|
||||
fn aux(&self, item: ItemId, footage: &Footage, t: Tick) -> Option<Arc<AuxChannels>> {
|
||||
self.inside(footage).then(|| self.inner.aux(item, footage, t)).flatten()
|
||||
self.readable(footage).then(|| self.inner.aux(item, footage, t)).flatten()
|
||||
}
|
||||
/// Forwards the auxiliary footage paths to the inner source.
|
||||
fn set_aux_footage(&self, paths: &[String]) {
|
||||
self.inner.set_aux_footage(paths)
|
||||
}
|
||||
/// The 3D model for the footage, or `None` when the footage lies outside the allowed roots.
|
||||
fn model(&self, item: ItemId, footage: &Footage) -> Option<Arc<effectcraft_model::Model>> {
|
||||
self.inside(footage).then(|| self.inner.model(item, footage)).flatten()
|
||||
self.readable(footage).then(|| self.inner.model(item, footage)).flatten()
|
||||
}
|
||||
/// The rasterised vector footage at `scale`, or `None` when the footage lies outside the allowed roots.
|
||||
fn vector_frame(&self, item: ItemId, footage: &Footage, scale: f64) -> Option<Arc<Image>> {
|
||||
self.inside(footage).then(|| self.inner.vector_frame(item, footage, scale)).flatten()
|
||||
self.readable(footage).then(|| self.inner.vector_frame(item, footage, scale)).flatten()
|
||||
}
|
||||
/// Says why footage outside the roots shows nothing, so the agent can act on it.
|
||||
fn problem(&self, footage: &Footage) -> Option<String> {
|
||||
@@ -392,15 +420,29 @@ impl FootageSource for ConfinedFootage {
|
||||
fn set_conform_folder(&self, folder: Option<PathBuf>) {
|
||||
self.inner.set_conform_folder(folder)
|
||||
}
|
||||
/// The inner source's cache budget.
|
||||
fn cache_budget(&self) -> Option<usize> {
|
||||
self.inner.cache_budget()
|
||||
}
|
||||
/// Runs `f` under the inner source's cancel flag.
|
||||
fn cancellable(&self, cancel: &Arc<AtomicBool>, f: &mut dyn FnMut()) {
|
||||
self.inner.cancellable(cancel, f)
|
||||
}
|
||||
/// Whether the inner source has been called off.
|
||||
fn called_off(&self) -> bool {
|
||||
self.inner.called_off()
|
||||
}
|
||||
/// The files read inside the roots, as the inner source notes them. Footage outside reads
|
||||
/// nothing, and what is made without it can't be listed by its files: a refused read makes
|
||||
/// the list incomplete, so it never outlives the session in a disk cache (another session
|
||||
/// with other roots would read the footage).
|
||||
fn recording(&self, reads: &effectcraft_engine::render::reads::ReadLog, f: &mut dyn FnMut()) {
|
||||
let refused = refused_reads();
|
||||
self.inner.recording(reads, f);
|
||||
if refused_reads() != refused {
|
||||
reads.mark_incomplete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The session's file reads, only inside the read roots: what a project or an imported file
|
||||
@@ -593,6 +635,55 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Footage outside the roots shows nothing: a render that asked for it can't be listed by its
|
||||
/// files (#3184), or a disk cache would keep the empty picture for a session with other
|
||||
/// roots. Reads inside the roots record as the inner source tells.
|
||||
#[test]
|
||||
fn a_refused_footage_read_makes_the_render_unlisted() {
|
||||
use effectcraft_engine::render::reads::{FileStamp, ReadFile, ReadLog};
|
||||
/// Reads every file, noting it.
|
||||
struct Noting;
|
||||
impl FootageSource for Noting {
|
||||
/// Returns a 1x1 frame and notes the footage path it was asked for.
|
||||
fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option<Arc<Image>> {
|
||||
NOTED.with(|n| n.borrow_mut().push(f.path.clone()));
|
||||
Some(Arc::new(Image::new(1, 1)))
|
||||
}
|
||||
/// Runs `f`, then reports every path noted meanwhile to `reads` as a file with a missing stamp.
|
||||
fn recording(&self, reads: &ReadLog, f: &mut dyn FnMut()) {
|
||||
f();
|
||||
for p in NOTED.with(|n| std::mem::take(&mut *n.borrow_mut())) {
|
||||
reads.note(ReadFile::new(p, FileStamp::Missing));
|
||||
}
|
||||
}
|
||||
}
|
||||
thread_local! {
|
||||
static NOTED: std::cell::RefCell<Vec<String>> = const { std::cell::RefCell::new(Vec::new()) };
|
||||
}
|
||||
// (a fresh directory, removed when the test ends, panicking or not)
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let base = tmp.path();
|
||||
let (inside, outside) = (base.join("in"), base.join("elsewhere"));
|
||||
for d in [&inside, &outside] {
|
||||
std::fs::create_dir_all(d).unwrap();
|
||||
}
|
||||
let c = Confinement::new(std::slice::from_ref(&inside), &[]).unwrap();
|
||||
let src = ConfinedFootage::new(Arc::new(Noting), c);
|
||||
let footage = |d: &std::path::Path| Footage { path: d.join("a.png").to_string_lossy().into_owned(), ..Default::default() };
|
||||
let read = |f: &Footage| {
|
||||
let log = ReadLog::new();
|
||||
let mut got = None;
|
||||
src.recording(&log, &mut || got = src.frame(ItemId(1), f, Tick::ZERO));
|
||||
(got.is_some(), log.into_files())
|
||||
};
|
||||
let (shown, files) = read(&footage(&inside));
|
||||
assert!(shown && files.is_complete() && files.len() == 1, "inside the roots: {files:?}");
|
||||
let (shown, files) = read(&footage(&outside));
|
||||
assert!(!shown && !files.is_complete(), "outside the roots: {files:?}");
|
||||
// The next render on the thread is not affected.
|
||||
assert!(read(&footage(&inside)).1.is_complete());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_and_outside_paths_of_newly_listed_commands_are_refused() {
|
||||
let base = std::env::temp_dir().join(format!("ec-confine-1243-{}", std::process::id()));
|
||||
|
||||
@@ -106,7 +106,7 @@ fn track_matte(qa: &mut Qa) {
|
||||
fn adjustment_layer(qa: &mut Qa, normal: &Img) {
|
||||
// Adjustment layer: Lumetri exposure, inside a feathered, expanded elliptical mask; then Glow.
|
||||
qa.exec("layer.newAdjustment", json!({"name": "Grade"}));
|
||||
let fx = qa.exec("effect.apply", json!({"effect": "Lumetri Color", "layers": ["Grade"]}));
|
||||
let fx = qa.exec("effect.apply", json!({"effect": "Color Grading", "layers": ["Grade"]}));
|
||||
assert_eq!(fx["paths"], json!(["effects/#1"]), "effect.apply answers with the instance path: {fx}");
|
||||
qa.tool("set_property", json!({"layer": "Grade", "path": "effects/#1/basicCorrection/tone/exposure", "value": 2}));
|
||||
let before = qa.frame(None, 0.5);
|
||||
|
||||
+259
-51
@@ -157,7 +157,8 @@ impl Default for TxScratch {
|
||||
/// Saved encoder state of a block area (reconstruction, BlockDecoded flags).
|
||||
struct Snapshot {
|
||||
planes: [Vec<u16>; 3],
|
||||
decoded: Box<[[[bool; 35]; 35]; 3]>,
|
||||
/// BlockDecoded flags of the block area only (the only flags coding inside it can change).
|
||||
decoded: [[[bool; 16]; 16]; 3],
|
||||
}
|
||||
|
||||
pub(crate) struct Decider<'a> {
|
||||
@@ -267,6 +268,13 @@ impl<'a> Decider<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The BlockDecoded cells `mark_decoded` can set for blocks inside (r, c, n4): rows and
|
||||
/// columns `by + 1 .. by + 1 + step` / `bx + 1 ..` of `decoded[plane]`.
|
||||
fn decoded_region(plane: usize, r: usize, c: usize, n4: usize) -> (usize, usize, usize) {
|
||||
let s = (plane > 0) as usize;
|
||||
((r & 15) >> s, (c & 15) >> s, (n4 >> s).max(1))
|
||||
}
|
||||
|
||||
fn snapshot(&self, r: usize, c: usize, n4: usize) -> Snapshot {
|
||||
let planes = std::array::from_fn(|p| {
|
||||
let s = (p > 0) as usize;
|
||||
@@ -278,7 +286,14 @@ impl<'a> Decider<'a> {
|
||||
}
|
||||
v
|
||||
});
|
||||
Snapshot { planes, decoded: self.decoded.clone() }
|
||||
let mut decoded = [[[false; 16]; 16]; 3];
|
||||
for (p, d) in decoded.iter_mut().enumerate() {
|
||||
let (by, bx, step) = Self::decoded_region(p, r, c, n4);
|
||||
for i in 0..step {
|
||||
d[i][..step].copy_from_slice(&self.decoded[p][by + i + 1][bx + 1..bx + 1 + step]);
|
||||
}
|
||||
}
|
||||
Snapshot { planes, decoded }
|
||||
}
|
||||
|
||||
fn restore(&mut self, snap: &Snapshot, r: usize, c: usize, n4: usize) {
|
||||
@@ -290,7 +305,12 @@ impl<'a> Decider<'a> {
|
||||
pl.data[y * pl.stride + x0..y * pl.stride + x0 + n].copy_from_slice(&snap.planes[p][i * n..i * n + n]);
|
||||
}
|
||||
}
|
||||
self.decoded.clone_from(&snap.decoded);
|
||||
for p in 0..3 {
|
||||
let (by, bx, step) = Self::decoded_region(p, r, c, n4);
|
||||
for i in 0..step {
|
||||
self.decoded[p][by + i + 1][bx + 1..bx + 1 + step].copy_from_slice(&snap.decoded[p][i][..step]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_mvs(&mut self, r: usize, c: usize, n4: usize, mv: [i32; 2]) {
|
||||
@@ -588,15 +608,22 @@ impl<'a> Decider<'a> {
|
||||
let comp = |d: i32| if d == 0 { 0.5 } else { 2.0 + 2.0 * log2_of(lg, (d.abs() / 8) as usize + 1) };
|
||||
comp(m[0] - pred_mv[0]) + comp(m[1] - pred_mv[1])
|
||||
};
|
||||
let cost =
|
||||
|m: [i32; 2]| -> f64 { self.sad_ref(&refr.planes[0], x0 + (m[1] >> 3), y0 + (m[0] >> 3), c * 4, r * 4, n) as f64 + self.lambda_sad * mv_bits(m) };
|
||||
// `bound` is the cost to beat: the SAD stops early once the cost cannot be below it (the
|
||||
// callers only accept a strictly lower cost, so the result is unchanged)
|
||||
let cost = |m: [i32; 2], bound: f64| -> f64 {
|
||||
let lb = self.lambda_sad * mv_bits(m);
|
||||
self.sad_ref(&refr.planes[0], [x0 + (m[1] >> 3), y0 + (m[0] >> 3)], [c * 4, r * 4], n, [lb, bound]) as f64 + lb
|
||||
};
|
||||
// positions already evaluated cannot beat `best` again (costs are deterministic)
|
||||
let mut seen: Vec<[i32; 2]> = Vec::with_capacity(64);
|
||||
let mut best = ([0, 0], f64::MAX);
|
||||
for &m in &cands {
|
||||
let m = [m[0] & !7, m[1] & !7];
|
||||
if !valid(m) {
|
||||
if !valid(m) || seen.contains(&m) {
|
||||
continue;
|
||||
}
|
||||
let cst = cost(m);
|
||||
seen.push(m);
|
||||
let cst = cost(m, best.1);
|
||||
if cst < best.1 {
|
||||
best = (m, cst);
|
||||
}
|
||||
@@ -617,10 +644,11 @@ impl<'a> Decider<'a> {
|
||||
let center = best.0;
|
||||
for d in [[-step, 0], [step, 0], [0, -step], [0, step]] {
|
||||
let m = [center[0] + d[0], center[1] + d[1]];
|
||||
if !valid(m) {
|
||||
if !valid(m) || seen.contains(&m) {
|
||||
continue;
|
||||
}
|
||||
let cst = cost(m);
|
||||
seen.push(m);
|
||||
let cst = cost(m, best.1);
|
||||
if cst < best.1 {
|
||||
best = (m, cst);
|
||||
moved = true;
|
||||
@@ -631,15 +659,18 @@ impl<'a> Decider<'a> {
|
||||
}
|
||||
// half- then quarter-sample refinement (plus the exact neighbour candidates)
|
||||
let mut buf = vec![0u16; n * n];
|
||||
let sub_cost = |m: [i32; 2], buf: &mut Vec<u16>| -> f64 {
|
||||
let sub_cost = |m: [i32; 2], buf: &mut Vec<u16>, bound: f64| -> f64 {
|
||||
self.search_block(m, c * 4, r * 4, n, buf);
|
||||
self.sad(0, c * 4, r * 4, n, buf) as f64 + self.lambda_sad * mv_bits(m)
|
||||
let lb = self.lambda_sad * mv_bits(m);
|
||||
self.sad_bounded(0, [c * 4, r * 4], n, buf, [lb, bound]) as f64 + lb
|
||||
};
|
||||
let mut seen_sub: Vec<[i32; 2]> = Vec::with_capacity(24);
|
||||
for m in cands {
|
||||
if m[0] & 7 != 0 || m[1] & 7 != 0 {
|
||||
let m = [m[0] & !1, m[1] & !1];
|
||||
if valid(m) {
|
||||
let cst = sub_cost(m, &mut buf);
|
||||
if valid(m) && !seen_sub.contains(&m) {
|
||||
seen_sub.push(m);
|
||||
let cst = sub_cost(m, &mut buf, best.1);
|
||||
if cst < best.1 {
|
||||
best = (m, cst);
|
||||
}
|
||||
@@ -650,10 +681,11 @@ impl<'a> Decider<'a> {
|
||||
let center = best.0;
|
||||
for d in [[-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 1], [1, -1], [1, 0], [1, 1]] {
|
||||
let m = [center[0] + d[0] * step, center[1] + d[1] * step];
|
||||
if !valid(m) {
|
||||
if !valid(m) || seen_sub.contains(&m) {
|
||||
continue;
|
||||
}
|
||||
let cst = sub_cost(m, &mut buf);
|
||||
seen_sub.push(m);
|
||||
let cst = sub_cost(m, &mut buf, best.1);
|
||||
if cst < best.1 {
|
||||
best = (m, cst);
|
||||
}
|
||||
@@ -739,7 +771,8 @@ impl<'a> Decider<'a> {
|
||||
}
|
||||
|
||||
/// SAD of the source block at (x, y) (luma) against the reference block at (rx, ry).
|
||||
fn sad_ref(&self, refp: &Plane, rx: i32, ry: i32, x: usize, y: usize, n: usize) -> u64 {
|
||||
/// Stops after a row once `sad + lb >= bound` (the cost can no longer win).
|
||||
fn sad_ref(&self, refp: &Plane, [rx, ry]: [i32; 2], [x, y]: [usize; 2], n: usize, [lb, bound]: [f64; 2]) -> u64 {
|
||||
let (vw, vh) = (self.g.width, self.g.height);
|
||||
let w = n.min(vw.saturating_sub(x));
|
||||
let h = n.min(vh.saturating_sub(y));
|
||||
@@ -759,45 +792,16 @@ impl<'a> Decider<'a> {
|
||||
sad += (s[j] as i32 - b as i32).unsigned_abs() as u64;
|
||||
}
|
||||
}
|
||||
if sad as f64 + lb >= bound {
|
||||
return sad;
|
||||
}
|
||||
}
|
||||
sad
|
||||
}
|
||||
|
||||
/// Sum of absolute Hadamard-transformed differences (4x4 or 8x8 sub-blocks) of a prediction
|
||||
/// against the source, scaled to about the size of the SAD.
|
||||
fn satd(&self, p: usize, x: usize, y: usize, n: usize, pred: &[u16]) -> u64 {
|
||||
let src = &self.src[p];
|
||||
let k = n.min(8);
|
||||
let mut total = 0u64;
|
||||
let mut d = [0i32; 64];
|
||||
for by in (0..n).step_by(k) {
|
||||
for bx in (0..n).step_by(k) {
|
||||
for i in 0..k {
|
||||
let row = &src.row(y + by + i)[x + bx..x + bx + k];
|
||||
for j in 0..k {
|
||||
d[i * k + j] = row[j] as i32 - pred[(by + i) * n + bx + j] as i32;
|
||||
}
|
||||
}
|
||||
for i in 0..k {
|
||||
hadamard(&mut d[i * k..i * k + k]);
|
||||
}
|
||||
let mut col = [0i32; 8];
|
||||
let mut s = 0u64;
|
||||
for j in 0..k {
|
||||
for i in 0..k {
|
||||
col[i] = d[i * k + j];
|
||||
}
|
||||
hadamard(&mut col[..k]);
|
||||
s += col[..k].iter().map(|v| v.unsigned_abs() as u64).sum::<u64>();
|
||||
}
|
||||
total += s / (k as u64 / 2);
|
||||
}
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
/// SAD of a prediction against the source over the visible part of the block.
|
||||
fn sad(&self, p: usize, x: usize, y: usize, n: usize, pred: &[u16]) -> u64 {
|
||||
/// SAD of a prediction against the source over the visible part of the block. Stops after a row
|
||||
/// once `sad + lb >= bound` (the cost can no longer win).
|
||||
fn sad_bounded(&self, p: usize, [x, y]: [usize; 2], n: usize, pred: &[u16], [lb, bound]: [f64; 2]) -> u64 {
|
||||
let (vw, vh) = self.g.visible(p);
|
||||
let w = n.min(vw.saturating_sub(x));
|
||||
let h = n.min(vh.saturating_sub(y));
|
||||
@@ -806,10 +810,60 @@ impl<'a> Decider<'a> {
|
||||
for i in 0..h {
|
||||
let row = &src.row(y + i)[x..x + w];
|
||||
s += row.iter().zip(&pred[i * n..i * n + w]).map(|(&a, &b)| (a as i32 - b as i32).unsigned_abs()).sum::<u32>() as u64;
|
||||
if s as f64 + lb >= bound {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Sum of absolute Hadamard-transformed differences (4x4 or 8x8 sub-blocks) of a prediction
|
||||
/// against the source, scaled to about the size of the SAD.
|
||||
fn satd(&self, p: usize, x: usize, y: usize, n: usize, pred: &[u16]) -> u64 {
|
||||
if n >= 8 { self.satd_k::<8>(p, x, y, n, pred) } else { self.satd_k::<4>(p, x, y, n, pred) }
|
||||
}
|
||||
|
||||
/// `satd` with K x K Hadamard sub-blocks (fixed sizes so the butterflies vectorise).
|
||||
fn satd_k<const K: usize>(&self, p: usize, x: usize, y: usize, n: usize, pred: &[u16]) -> u64 {
|
||||
let src = &self.src[p];
|
||||
let mut total = 0u64;
|
||||
for by in (0..n).step_by(K) {
|
||||
for bx in (0..n).step_by(K) {
|
||||
// d[i] holds row i of the differences
|
||||
let mut d = [[0i32; K]; K];
|
||||
for (i, di) in d.iter_mut().enumerate() {
|
||||
let row = &src.row(y + by + i)[x + bx..x + bx + K];
|
||||
let pr = &pred[(by + i) * n + bx..(by + i) * n + bx + K];
|
||||
for j in 0..K {
|
||||
di[j] = row[j] as i32 - pr[j] as i32;
|
||||
}
|
||||
}
|
||||
// vertical butterflies across rows (all columns at once)
|
||||
let mut h = 1;
|
||||
while h < K {
|
||||
for i0 in (0..K).step_by(2 * h) {
|
||||
for i in i0..i0 + h {
|
||||
let (a, b) = (d[i], d[i + h]);
|
||||
for j in 0..K {
|
||||
d[i][j] = a[j] + b[j];
|
||||
d[i + h][j] = a[j] - b[j];
|
||||
}
|
||||
}
|
||||
}
|
||||
h *= 2;
|
||||
}
|
||||
// horizontal butterflies within each row
|
||||
let mut s = 0u64;
|
||||
for di in d.iter_mut() {
|
||||
hadamard(di);
|
||||
s += di.iter().map(|v| v.unsigned_abs() as u64).sum::<u64>();
|
||||
}
|
||||
total += s / (K as u64 / 2);
|
||||
}
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
fn sse(&self, p: usize, x: usize, y: usize, n: usize, blk: &[u16]) -> f64 {
|
||||
let (vw, vh) = self.g.visible(p);
|
||||
let w = n.min(vw.saturating_sub(x));
|
||||
@@ -1062,4 +1116,158 @@ mod tests {
|
||||
assert_eq!(log2_of(t, v).to_bits(), (v as f64).log2().to_bits(), "{v}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The SATD as it was before the fixed-size version: runtime block size, column gather.
|
||||
fn satd_ref(src: &Plane, x: usize, y: usize, n: usize, pred: &[u16]) -> u64 {
|
||||
let k = n.min(8);
|
||||
let mut total = 0u64;
|
||||
let mut d = [0i32; 64];
|
||||
for by in (0..n).step_by(k) {
|
||||
for bx in (0..n).step_by(k) {
|
||||
for i in 0..k {
|
||||
let row = &src.row(y + by + i)[x + bx..x + bx + k];
|
||||
for j in 0..k {
|
||||
d[i * k + j] = row[j] as i32 - pred[(by + i) * n + bx + j] as i32;
|
||||
}
|
||||
}
|
||||
for i in 0..k {
|
||||
hadamard(&mut d[i * k..i * k + k]);
|
||||
}
|
||||
let mut col = [0i32; 8];
|
||||
let mut s = 0u64;
|
||||
for j in 0..k {
|
||||
for i in 0..k {
|
||||
col[i] = d[i * k + j];
|
||||
}
|
||||
hadamard(&mut col[..k]);
|
||||
s += col[..k].iter().map(|v| v.unsigned_abs() as u64).sum::<u64>();
|
||||
}
|
||||
total += s / (k as u64 / 2);
|
||||
}
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn satd_fixed_size_matches_runtime_size() {
|
||||
let g = FrameGeom::new(100, 80);
|
||||
let mut state = 0x9e37_79b9_7f4a_7c15u64;
|
||||
let mut next = move || {
|
||||
state ^= state << 13;
|
||||
state ^= state >> 7;
|
||||
state ^= state << 17;
|
||||
state
|
||||
};
|
||||
for bd in [8u32, 10] {
|
||||
let max = (1u64 << bd) - 1;
|
||||
let mut mk = |w: usize, h: usize| {
|
||||
let mut p = Plane::new(w, h);
|
||||
p.data.iter_mut().for_each(|v| *v = (next() % (max + 1)) as u16);
|
||||
p
|
||||
};
|
||||
let src = [mk(128, 128), mk(64, 64), mk(64, 64)];
|
||||
let dec = Decider::new(&g, bd, 100, true, &src, None);
|
||||
for n in [4usize, 8, 16, 32] {
|
||||
let pred: Vec<u16> = (0..n * n).map(|_| (next() % (max + 1)) as u16).collect();
|
||||
for (p, x, y) in [(0, 0, 0), (0, 32, 40), (1, 8, 4)] {
|
||||
assert_eq!(dec.satd(p, x, y, n, &pred), satd_ref(&src[p], x, y, n, &pred), "n {n} plane {p}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn xorshift(seed: u64) -> impl FnMut() -> u64 {
|
||||
let mut state = seed;
|
||||
move || {
|
||||
state ^= state << 13;
|
||||
state ^= state >> 7;
|
||||
state ^= state << 17;
|
||||
state
|
||||
}
|
||||
}
|
||||
|
||||
/// A snapshot keeps only the BlockDecoded cells of the block area. Coding inside the block
|
||||
/// marks sub-blocks of every smaller size; restoring must give back the whole 3x35x35 array
|
||||
/// as it was, for every block size of a 64x64 superblock and every aligned position.
|
||||
#[test]
|
||||
fn snapshot_restore_brings_back_every_blockdecoded_flag() {
|
||||
let g = FrameGeom::new(256, 256);
|
||||
let mut next = xorshift(0x2545_f491_4f6c_dd1d);
|
||||
let src = [Plane::new(256, 256), Plane::new(128, 128), Plane::new(128, 128)];
|
||||
let mut dec = Decider::new(&g, 8, 100, true, &src, None);
|
||||
for random_start in [false, true] {
|
||||
for n4 in [1usize, 2, 4, 8, 16] {
|
||||
for sb in [0usize, 16] {
|
||||
for r in (sb..sb + 16).step_by(n4) {
|
||||
for c in (sb..sb + 16).step_by(n4) {
|
||||
for plane in dec.decoded.iter_mut() {
|
||||
for row in plane.iter_mut() {
|
||||
for v in row.iter_mut() {
|
||||
*v = random_start && next() & 1 == 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let before = dec.decoded.clone();
|
||||
let snap = dec.snapshot(r, c, n4);
|
||||
// every sub-block of every size inside the block, as coding marks them
|
||||
let mut m = n4;
|
||||
loop {
|
||||
for rr in (r..r + n4).step_by(m) {
|
||||
for cc in (c..c + n4).step_by(m) {
|
||||
for p in 0..3 {
|
||||
dec.mark_decoded(p, rr, cc, m);
|
||||
}
|
||||
}
|
||||
}
|
||||
if m == 1 {
|
||||
break;
|
||||
}
|
||||
m /= 2;
|
||||
}
|
||||
dec.restore(&snap, r, c, n4);
|
||||
assert!(dec.decoded == before, "n4 {n4} at ({r}, {c}), random start {random_start}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The bounded SADs return the full SAD whenever the cost can still win (`sad + lb < bound`)
|
||||
/// and otherwise a partial sum that already loses, so the callers' strict `<` decides the same.
|
||||
#[test]
|
||||
fn bounded_sad_stops_only_once_the_cost_cannot_win() {
|
||||
let g = FrameGeom::new(100, 80);
|
||||
let mut next = xorshift(0x9e37_79b9_7f4a_7c15);
|
||||
let mut mk = |w: usize, h: usize| {
|
||||
let mut p = Plane::new(w, h);
|
||||
p.data.iter_mut().for_each(|v| *v = (next() % 256) as u16);
|
||||
p
|
||||
};
|
||||
let src = [mk(128, 128), mk(64, 64), mk(64, 64)];
|
||||
let refp = mk(128, 128);
|
||||
let mut next = xorshift(0x1234_5678_9abc_def1);
|
||||
let dec = Decider::new(&g, 8, 100, false, &src, None);
|
||||
for n in [4usize, 8, 16, 32] {
|
||||
for (x, y, rx, ry) in [(0usize, 0usize, 0i32, 0i32), (32, 40, 29, 43), (96, 72, 90, 70), (8, 8, -5, -3)] {
|
||||
let full_ref = dec.sad_ref(&refp, [rx, ry], [x, y], n, [0.0, f64::INFINITY]);
|
||||
let pred: Vec<u16> = (0..n * n).map(|_| (next() % 256) as u16).collect();
|
||||
let full_pred = dec.sad_bounded(0, [x, y], n, &pred, [0.0, f64::INFINITY]);
|
||||
for _ in 0..50 {
|
||||
let lb = (next() % 64) as f64 * 0.75;
|
||||
let b_ref = (next() % (2 * full_ref + 2)) as f64;
|
||||
let b_pred = (next() % (2 * full_pred + 2)) as f64;
|
||||
let got_ref = dec.sad_ref(&refp, [rx, ry], [x, y], n, [lb, b_ref]);
|
||||
let got_pred = dec.sad_bounded(0, [x, y], n, &pred, [lb, b_pred]);
|
||||
for (got, full, bound) in [(got_ref, full_ref, b_ref), (got_pred, full_pred, b_pred)] {
|
||||
if full as f64 + lb < bound {
|
||||
assert_eq!(got, full, "n {n} at ({x}, {y}) bound {bound}");
|
||||
} else {
|
||||
assert!(got as f64 + lb >= bound && got <= full, "n {n} at ({x}, {y}) bound {bound}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Blur & Sharpen, batch 2: edge-preserving blurs (Bilateral, Smart), per-channel and map-driven
|
||||
//! blurs (Channel, Compound, CC Vector), bokeh (Camera Lens Blur), radial streaks and small
|
||||
//! utilities (Reduce Interlace Flicker, CC Cross Blur).
|
||||
//! utilities (Reduce Interlace Flicker, Cross Blur).
|
||||
|
||||
use effectcraft_color::luminance;
|
||||
use effectcraft_keyframe::Value;
|
||||
@@ -507,9 +507,9 @@ fn camera_lens_blur(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// CC Radial Fast Blur
|
||||
// Fast Radial Blur
|
||||
|
||||
/// Transparent pixels CC Radial Fast Blur adds on every side of a `w` × `h` buffer with Center
|
||||
/// Transparent pixels Fast Radial Blur adds on every side of a `w` × `h` buffer with Center
|
||||
/// at `c` (buffer pixels) and smear `s`: content `r` from Center streaks out to `r / (1 − s)`,
|
||||
/// so the farthest corner reaches `d · s / (1 − s)` past itself (at most 4096, as the other
|
||||
/// growing effects). Shared with the GPU path so both grow the same way.
|
||||
@@ -562,7 +562,7 @@ fn radial_fast_blur(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// CC Vector Blur
|
||||
// Vector Blur
|
||||
|
||||
fn vector_blur(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let amount = ctx.params.f("amount").max(0.0) * b.scale;
|
||||
@@ -616,7 +616,7 @@ fn vector_blur(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Reduce Interlace Flicker / CC Cross Blur
|
||||
// Reduce Interlace Flicker / Cross Blur
|
||||
|
||||
fn reduce_flicker(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let s = ctx.params.f("softness").max(0.0) * b.scale;
|
||||
@@ -735,7 +735,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.blur.ccradialfast",
|
||||
"CC Radial Fast Blur",
|
||||
"Fast Radial Blur",
|
||||
vec![
|
||||
p("center", "Center", Value::Vec2([0.5, 0.5]), ParamUi::Point),
|
||||
p("amount", "Amount", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
|
||||
@@ -745,7 +745,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.blur.ccvector",
|
||||
"CC Vector Blur",
|
||||
"Vector Blur",
|
||||
vec![
|
||||
p("type", "Type", Value::Enum(0), popup(&["Natural", "Constant Length", "Perpendicular", "Direction Center", "Direction Fading"])),
|
||||
p("amount", "Amount", num(10.0), slider(0.0, 500.0, 0.0, 100.0, 1)),
|
||||
@@ -765,7 +765,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
},
|
||||
spec(
|
||||
"ec.blur.cccross",
|
||||
"CC Cross Blur",
|
||||
"Cross Blur",
|
||||
vec![
|
||||
p("radiusX", "Radius X", num(10.0), slider(0.0, 500.0, 0.0, 100.0, 1)),
|
||||
p("radiusY", "Radius Y", num(10.0), slider(0.0, 500.0, 0.0, 100.0, 1)),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Blur & Sharpen: CC Radial Blur and Camera-Shake Deblur.
|
||||
//! Blur & Sharpen: Variable Radial Blur and Camera-Shake Deblur.
|
||||
//!
|
||||
//! * **CC Radial Blur** — zoom (straight, fading, centred both ways) and rotational (plain or
|
||||
//! * **Variable Radial Blur** — zoom (straight, fading, centred both ways) and rotational (plain or
|
||||
//! fading) blurs around a centre point, sampled along the ray / arc through each pixel with a
|
||||
//! sample count set by Quality.
|
||||
//! * **Camera-Shake Deblur** — frame-based restoration in the spirit of published video deblurring
|
||||
@@ -22,7 +22,7 @@ fn spec(id: &'static str, name: &'static str, params: Vec<crate::ParamSpec>, ren
|
||||
EffectSpec { id, name, category: "Blur & Sharpen", params, render, gpu: false, float: true }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Radial Blur
|
||||
// ---------------------------------------------------------------- Variable Radial Blur
|
||||
|
||||
pub const RADIAL_TYPES: [&str; 5] = ["Straight Zoom", "Fading Zoom", "Centered", "Rotate", "Rotate Fading"];
|
||||
|
||||
@@ -246,7 +246,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
vec![
|
||||
spec(
|
||||
"ec.blur.ccradial",
|
||||
"CC Radial Blur",
|
||||
"Variable Radial Blur",
|
||||
vec![
|
||||
p("type", "Type", Value::Enum(1), popup(&RADIAL_TYPES)),
|
||||
p("amount", "Amount", num(10.0), slider(-100.0, 100.0, -100.0, 100.0, 1)),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Channel effects, batch 2: layer-to-layer combinations (Blend, Calculations, Compound
|
||||
//! Arithmetic) and CC Composite. Second layers come from layer parameters through the effect
|
||||
//! Arithmetic) and Source Composite. Second layers come from layer parameters through the effect
|
||||
//! host; with no layer chosen the effect uses the layer itself, as After Effects does.
|
||||
|
||||
use effectcraft_color::{BlendMode, blend_pixel, hsl_to_rgb, luminance, rgb_to_hsl};
|
||||
@@ -69,7 +69,7 @@ fn blend(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
|
||||
// ---------------------------------------------------------------- Calculations
|
||||
|
||||
/// Blending modes offered by Calculations / CC Composite (menu order).
|
||||
/// Blending modes offered by Calculations / Source Composite (menu order).
|
||||
const CALC_MODES: [BlendMode; 22] = [
|
||||
BlendMode::Normal,
|
||||
BlendMode::Darken,
|
||||
@@ -150,7 +150,7 @@ fn calculations(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
b
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Composite
|
||||
// ---------------------------------------------------------------- Source Composite
|
||||
|
||||
const CC_COMPOSITE_MODES: [&str; 16] = [
|
||||
"In Front",
|
||||
@@ -321,7 +321,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.channel.cccomposite",
|
||||
"CC Composite",
|
||||
"Source Composite",
|
||||
vec![
|
||||
p("opacity", "Opacity", num(100.0), pct()),
|
||||
p("compositeOriginal", "Composite Original", Value::Enum(0), popup(&CC_COMPOSITE_MODES)),
|
||||
|
||||
@@ -619,6 +619,39 @@ fn cryptomatte(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
|
||||
// ---------------------------------------------------------------- EXtractoR
|
||||
|
||||
/// EXtractoR's channel name for After Effects' `(copy)`: the effect input's own channel of the
|
||||
/// same position (red's red ... alpha's alpha), as the earlier effects left it, never a footage
|
||||
/// channel of some name (an OpenEXR's `A` is the file's alpha, not the input's).
|
||||
pub const EXTRACTOR_COPY: &str = "(copy)";
|
||||
|
||||
/// Whether an EXtractoR channel name is [`EXTRACTOR_COPY`].
|
||||
pub fn is_extractor_copy(name: &str) -> bool {
|
||||
name.trim().eq_ignore_ascii_case(EXTRACTOR_COPY)
|
||||
}
|
||||
|
||||
/// The layer's own channel an EXtractoR output channel at position `k` reads when no footage
|
||||
/// channel of that name exists: `R`/`G`/`B`/`A` (or the long names) by name, [`EXTRACTOR_COPY`]
|
||||
/// its own position.
|
||||
pub fn extractor_own_channel(k: usize, name: &str) -> Option<usize> {
|
||||
if is_extractor_copy(name) {
|
||||
return (k < 4).then_some(k);
|
||||
}
|
||||
match name.trim().to_ascii_uppercase().as_str() {
|
||||
"R" | "RED" => Some(0),
|
||||
"G" | "GREEN" => Some(1),
|
||||
"B" | "BLUE" => Some(2),
|
||||
"A" | "ALPHA" => Some(3),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The footage channel an EXtractoR output channel reads by name: none for an empty name or
|
||||
/// [`EXTRACTOR_COPY`] (the input's own channel).
|
||||
pub fn extractor_aux_channel<'a>(a: &'a AuxChannels, name: &str) -> Option<&'a [f32]> {
|
||||
let n = name.trim();
|
||||
if n.is_empty() || is_extractor_copy(n) { None } else { a.get(n) }
|
||||
}
|
||||
|
||||
fn extractor(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let a = aux(ctx);
|
||||
let names = [ctx.params.s("red"), ctx.params.s("green"), ctx.params.s("blue"), ctx.params.s("alpha")];
|
||||
@@ -626,17 +659,11 @@ fn extractor(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let unmult = ctx.params.b("unMult");
|
||||
let clip = ctx.params.b("clip");
|
||||
let idx = a.as_ref().map(|a| index_map(&b, a));
|
||||
// Each output channel: an aux channel by name, or the layer's own R/G/B/A.
|
||||
let own = |n: &str| match n.trim().to_ascii_uppercase().as_str() {
|
||||
"R" | "RED" => Some(0),
|
||||
"G" | "GREEN" => Some(1),
|
||||
"B" | "BLUE" => Some(2),
|
||||
"A" | "ALPHA" => Some(3),
|
||||
"" => None,
|
||||
_ => None,
|
||||
};
|
||||
let src_ch: Vec<Option<&[f32]>> = names.iter().map(|n| a.as_ref().and_then(|a| if n.trim().is_empty() { None } else { a.get(n.trim()) })).collect();
|
||||
if src_ch.iter().all(Option::is_none) && bp == 0.0 && wp == 1.0 && !unmult && names.iter().enumerate().all(|(k, n)| own(n) == Some(k)) {
|
||||
// Each output channel: an aux channel by name, or the layer's own R/G/B/A (`(copy)`: its
|
||||
// own position, never an aux channel).
|
||||
let own = |k: usize, n: &str| extractor_own_channel(k, n);
|
||||
let src_ch: Vec<Option<&[f32]>> = names.iter().map(|n| a.as_ref().and_then(|a| extractor_aux_channel(a, n))).collect();
|
||||
if src_ch.iter().all(Option::is_none) && bp == 0.0 && wp == 1.0 && !unmult && names.iter().enumerate().all(|(k, n)| own(k, n) == Some(k)) {
|
||||
return b;
|
||||
}
|
||||
let src = b.img.clone();
|
||||
@@ -652,7 +679,7 @@ fn extractor(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
o[k] = match (src_ch[k], idx.as_ref().and_then(|m| m[i])) {
|
||||
(Some(ch), Some(j)) => ch[j],
|
||||
(Some(_), None) => 0.0,
|
||||
(None, _) => match own(names[k]) {
|
||||
(None, _) => match own(k, names[k]) {
|
||||
Some(c) => straight[c],
|
||||
None => {
|
||||
if k == 3 {
|
||||
|
||||
@@ -457,6 +457,38 @@ fn extractor_maps_named_channels() {
|
||||
assert_eq!(s.img.get(0, 0), [1.0, 0.3, 0.4, 1.0]);
|
||||
}
|
||||
|
||||
/// `(copy)` (After Effects' EXtractoR choice, imported from .aep) is the effect input's own
|
||||
/// channel as earlier effects left it, never the OpenEXR's plane of the same name: here the input
|
||||
/// buffer differs from the footage's R, G, B and A planes, as after an earlier effect.
|
||||
#[test]
|
||||
fn extractor_copy_reads_the_current_input_not_the_footage_plane() {
|
||||
let (w, h) = (4usize, 3usize);
|
||||
let mut a = AuxChannels::new(w as u32, h as u32, 1.0);
|
||||
// The file's own planes (what the footage held before any effect) and a depth channel.
|
||||
a.channels = ["R", "G", "B", "A"].iter().map(|n| (n.to_string(), vec![0.9; w * h])).collect();
|
||||
a.channels.push(("Z".into(), vec![0.7; w * h]));
|
||||
let host = AuxHost(Arc::new(a));
|
||||
let env = || EffectEnv { host: Some(&host), ..Default::default() };
|
||||
// The input after an earlier effect: straight (0.2, 0.3, 0.4) at alpha 0.5, premultiplied.
|
||||
let input = Image::filled(w as u32, h as u32, [0.1, 0.15, 0.2, 0.5]);
|
||||
let copy = || Value::Str(EXTRACTOR_COPY.into());
|
||||
// All four `(copy)`: the input unchanged.
|
||||
let all = [("red", copy()), ("green", copy()), ("blue", copy()), ("alpha", copy())];
|
||||
let o = run_fx("ec.3d.extractor", &all, input.clone(), 0.0, env()).img;
|
||||
assert_eq!(o.data, input.data);
|
||||
// Mixed with a named channel (no pass-through): red from Z, green and alpha copied, blue the
|
||||
// footage's B plane.
|
||||
let vals = [("red", Value::Str("Z".into())), ("green", copy()), ("blue", Value::Str("B".into())), ("alpha", copy())];
|
||||
let px = run_fx("ec.3d.extractor", &vals, input.clone(), 0.0, env()).img.get(1, 1);
|
||||
let want = [0.7 * 0.5, 0.3 * 0.5, 0.9 * 0.5, 0.5];
|
||||
for (c, (got, want)) in px.iter().zip(want).enumerate() {
|
||||
assert!((got - want).abs() < 1e-6, "channel {c}: {got} != {want} ({px:?})");
|
||||
}
|
||||
// `A` by name is the footage's alpha plane (0.9), not the input's alpha (0.5).
|
||||
let named = [("alpha", Value::Str("A".into())), ("red", Value::Str("Z".into()))];
|
||||
assert!((run_fx("ec.3d.extractor", &named, input, 0.0, env()).img.get(1, 1)[3] - 0.9).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_extract_antialias_averages_sub_pixels() {
|
||||
// Depth at twice the layer resolution, alternating 0 / 1000 per aux column.
|
||||
|
||||
@@ -878,7 +878,7 @@ fn broadcast_colors(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// CC Toner, CC Color Offset, CC Kernel
|
||||
// Multi-Tone Tint, Channel Offset, Convolution Kernel
|
||||
|
||||
fn cc_toner(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let g = |id: &str| {
|
||||
@@ -1101,7 +1101,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.color.cctoner",
|
||||
"CC Toner",
|
||||
"Multi-Tone Tint",
|
||||
vec![
|
||||
p("tones", "Tones", Value::Enum(1), popup(&["Duotone", "Tritone", "Pentone"])),
|
||||
p("highlights", "Highlights", col(1.0, 1.0, 1.0), ParamUi::Color),
|
||||
@@ -1115,7 +1115,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.color.cccoloroffset",
|
||||
"CC Color Offset",
|
||||
"Channel Offset",
|
||||
vec![
|
||||
p("redPhase", "Red Phase", num(0.0), ParamUi::Angle),
|
||||
p("greenPhase", "Green Phase", num(0.0), ParamUi::Angle),
|
||||
@@ -1124,7 +1124,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
],
|
||||
cc_color_offset,
|
||||
),
|
||||
spec("ec.color.cckernel", "CC Kernel", k_params, cc_kernel),
|
||||
spec("ec.color.cckernel", "Convolution Kernel", k_params, cc_kernel),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Color Correction effects, batch 3: Change Color, Video Limiter, CC Color Neutralizer,
|
||||
//! PS Arbitrary Map and Lumetri Color (Basic Correction, Creative, Curves, Color Wheels,
|
||||
//! Color Correction effects, batch 3: Change Color, Video Limiter, Color Neutralizer,
|
||||
//! PS Arbitrary Map and Color Grading (Basic Correction, Creative, Curves, Color Wheels,
|
||||
//! Vignette).
|
||||
|
||||
use effectcraft_color::{hsl_to_rgb, luminance, rgb_to_hsl};
|
||||
@@ -154,7 +154,7 @@ fn video_limiter(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
b
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Color Neutralizer
|
||||
// ---------------------------------------------------------------- Color Neutralizer
|
||||
|
||||
fn color_neutralizer(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let pairs = [("shadowsUnbalance", "shadowsBalance"), ("midtonesUnbalance", "midtonesBalance"), ("highlightsUnbalance", "highlightsBalance")];
|
||||
@@ -225,7 +225,7 @@ fn arbitrary_map(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
b
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Lumetri Color
|
||||
// ---------------------------------------------------------------- Color Grading
|
||||
|
||||
/// Smooth tone-curve adjustment: offsets for shadows/midtones/highlights (each -1..1), with the
|
||||
/// end points fixed.
|
||||
@@ -431,7 +431,7 @@ impl HslKey {
|
||||
}
|
||||
}
|
||||
|
||||
/// Lumetri Color: Basic Correction (input LUT, white balance, HDR range, tone, saturation),
|
||||
/// Color Grading: Basic Correction (input LUT, white balance, HDR range, tone, saturation),
|
||||
/// Creative (look, adjustments), Curves (tone sliders, RGB curves, hue/saturation curves),
|
||||
/// Color Wheels, HSL Secondary (key, refine, correction), Vignette, in that order.
|
||||
fn lumetri(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
@@ -705,32 +705,37 @@ fn lumetri(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
|
||||
pub fn specs() -> Vec<EffectSpec> {
|
||||
let grey = || col(0.5, 0.5, 0.5);
|
||||
// After Effects' valid ranges (a value past the slider's drag range is still kept: .aep
|
||||
// import, 1200), the slider over the range After Effects' slider covers.
|
||||
let wb = || slider(-300.0, 300.0, -100.0, 100.0, 1);
|
||||
let tone = || slider(-150.0, 150.0, -100.0, 100.0, 1);
|
||||
let sat = || slider(0.0, 300.0, 0.0, 200.0, 1);
|
||||
let mut lum = vec![
|
||||
p("highDynamicRange", "High Dynamic Range", Value::Bool(false), ParamUi::Checkbox),
|
||||
p("basicCorrection/inputLut", "Input LUT", Value::Enum(0), popup(&["None", "Custom"])),
|
||||
// A .cube file path (or the file's text) for Input LUT = Custom.
|
||||
p("basicCorrection/inputLutFile", "Input LUT File", Value::Str(String::new()), ParamUi::Text),
|
||||
p("basicCorrection/whiteBalance/temperature", "Temperature", num(0.0), bipolar()),
|
||||
p("basicCorrection/whiteBalance/tint", "Tint", num(0.0), bipolar()),
|
||||
p("basicCorrection/tone/exposure", "Exposure", num(0.0), slider(-5.0, 5.0, -5.0, 5.0, 2)),
|
||||
p("basicCorrection/tone/contrast", "Contrast", num(0.0), bipolar()),
|
||||
p("basicCorrection/tone/highlights", "Highlights", num(0.0), bipolar()),
|
||||
p("basicCorrection/tone/shadows", "Shadows", num(0.0), bipolar()),
|
||||
p("basicCorrection/tone/whites", "Whites", num(0.0), bipolar()),
|
||||
p("basicCorrection/tone/blacks", "Blacks", num(0.0), bipolar()),
|
||||
p("basicCorrection/whiteBalance/temperature", "Temperature", num(0.0), wb()),
|
||||
p("basicCorrection/whiteBalance/tint", "Tint", num(0.0), wb()),
|
||||
p("basicCorrection/tone/exposure", "Exposure", num(0.0), slider(-7.0, 7.0, -5.0, 5.0, 2)),
|
||||
p("basicCorrection/tone/contrast", "Contrast", num(0.0), tone()),
|
||||
p("basicCorrection/tone/highlights", "Highlights", num(0.0), tone()),
|
||||
p("basicCorrection/tone/shadows", "Shadows", num(0.0), tone()),
|
||||
p("basicCorrection/tone/whites", "Whites", num(0.0), tone()),
|
||||
p("basicCorrection/tone/blacks", "Blacks", num(0.0), tone()),
|
||||
p("basicCorrection/tone/hdrWhite", "HDR White", num(100.0), slider(100.0, 10000.0, 100.0, 4000.0, 0)),
|
||||
p("basicCorrection/tone/hdrSpecular", "HDR Specular", num(0.0), bipolar()),
|
||||
p("basicCorrection/saturation", "Saturation", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)),
|
||||
p("basicCorrection/tone/hdrSpecular", "HDR Specular", num(0.0), tone()),
|
||||
p("basicCorrection/saturation", "Saturation", num(100.0), sat()),
|
||||
p("creative/look", "Look", Value::Enum(0), popup(&LUMETRI_LOOKS)),
|
||||
p("creative/lookFile", "Look File", Value::Str(String::new()), ParamUi::Text),
|
||||
p("creative/lookIntensity", "Intensity", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)),
|
||||
p("creative/adjustments/fadedFilm", "Faded Film", num(0.0), pct()),
|
||||
p("creative/adjustments/fadedFilm", "Faded Film", num(0.0), slider(0.0, 150.0, 0.0, 100.0, 1)),
|
||||
p("creative/adjustments/sharpen", "Sharpen", num(0.0), bipolar()),
|
||||
p("creative/adjustments/vibrance", "Vibrance", num(0.0), bipolar()),
|
||||
p("creative/adjustments/creativeSaturation", "Saturation", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)),
|
||||
p("creative/adjustments/creativeSaturation", "Saturation", num(100.0), sat()),
|
||||
p("creative/adjustments/shadowTint", "Shadow Tint", grey(), ParamUi::Color),
|
||||
p("creative/adjustments/highlightTint", "Highlight Tint", grey(), ParamUi::Color),
|
||||
p("creative/adjustments/tintBalance", "Tint Balance", num(0.0), bipolar()),
|
||||
p("creative/adjustments/tintBalance", "Tint Balance", num(0.0), tone()),
|
||||
];
|
||||
const CURVES: [(&str, &str); 12] = [
|
||||
("curves/curveMasterShadows", "RGB Curve Shadows"),
|
||||
@@ -786,13 +791,13 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
p("hslSecondary/key/maskStyle", "Mask Style", Value::Enum(0), popup(&HSL_MASK_STYLES)),
|
||||
p("hslSecondary/key/invertMask", "Invert Mask", Value::Bool(false), ParamUi::Checkbox),
|
||||
p("hslSecondary/refine/denoise", "Denoise", num(0.0), pct()),
|
||||
p("hslSecondary/refine/blur", "Blur", num(0.0), pct()),
|
||||
p("hslSecondary/refine/blur", "Blur", num(0.0), slider(0.0, 1000.0, 0.0, 100.0, 1)),
|
||||
p("hslSecondary/correction/correctionWheel", "Correction", grey(), ParamUi::Color),
|
||||
p("hslSecondary/correction/temperature", "Temperature", num(0.0), bipolar()),
|
||||
p("hslSecondary/correction/tint", "Tint", num(0.0), bipolar()),
|
||||
p("hslSecondary/correction/contrast", "Contrast", num(0.0), bipolar()),
|
||||
p("hslSecondary/correction/temperature", "Temperature", num(0.0), wb()),
|
||||
p("hslSecondary/correction/tint", "Tint", num(0.0), wb()),
|
||||
p("hslSecondary/correction/contrast", "Contrast", num(0.0), tone()),
|
||||
p("hslSecondary/correction/sharpen", "Sharpen", num(0.0), bipolar()),
|
||||
p("hslSecondary/correction/saturation", "Saturation", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)),
|
||||
p("hslSecondary/correction/saturation", "Saturation", num(100.0), sat()),
|
||||
p("vignette/vignetteAmount", "Amount", num(0.0), slider(-5.0, 5.0, -5.0, 5.0, 2)),
|
||||
p("vignette/vignetteMidpoint", "Midpoint", num(50.0), pct()),
|
||||
p("vignette/vignetteRoundness", "Roundness", num(0.0), bipolar()),
|
||||
@@ -829,7 +834,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.color.cccolorneutralizer",
|
||||
"CC Color Neutralizer",
|
||||
"Color Neutralizer",
|
||||
vec![
|
||||
p("shadowsUnbalance", "Shadows Unbalance", col(0.0, 0.0, 0.0), ParamUi::Color),
|
||||
p("shadowsBalance", "Shadows Balance", col(0.0, 0.0, 0.0), ParamUi::Color),
|
||||
@@ -855,7 +860,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
],
|
||||
arbitrary_map,
|
||||
),
|
||||
spec("ec.color.lumetri", "Lumetri Color", lum, lumetri),
|
||||
spec("ec.color.lumetri", "Color Grading", lum, lumetri),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
//! Display naming must not change file, command or script identities.
|
||||
use crate::{find, instantiate, lookup, name_matches};
|
||||
use effectcraft_project::build::Ids;
|
||||
|
||||
const RENAMED: &[(&str, &str, &str)] = &[
|
||||
("ec.sim.ccballaction", "CC Ball Action", "Ball Grid"),
|
||||
("ec.distort.ccbendit", "CC Bend It", "Two-Point Bend"),
|
||||
("ec.distort.ccbender", "CC Bender", "Flexible Bend"),
|
||||
("ec.distort.ccblobbylize", "CC Blobbylize", "Organic Displacement"),
|
||||
("ec.stylize.ccblockload", "CC Block Load", "Block Reveal"),
|
||||
("ec.sim.ccbubbles", "CC Bubbles", "Bubbles"),
|
||||
("ec.stylize.ccburnfilm", "CC Burn Film", "Film Burn"),
|
||||
("ec.color.cccolorneutralizer", "CC Color Neutralizer", "Color Neutralizer"),
|
||||
("ec.color.cccoloroffset", "CC Color Offset", "Channel Offset"),
|
||||
("ec.channel.cccomposite", "CC Composite", "Source Composite"),
|
||||
("ec.blur.cccross", "CC Cross Blur", "Cross Blur"),
|
||||
("ec.perspective.cccylinder", "CC Cylinder", "Cylinder Wrap"),
|
||||
("ec.sim.ccdrizzle", "CC Drizzle", "Water Ripples"),
|
||||
("ec.perspective.ccenvironment", "CC Environment", "Environment Map"),
|
||||
("ec.distort.ccflomotion", "CC Flo Motion", "Dual Lens Warp"),
|
||||
("ec.time.ccforcemotionblur", "CC Force Motion Blur", "Temporal Motion Blur"),
|
||||
("ec.stylize.ccglass", "CC Glass", "Glass Relief"),
|
||||
("ec.transition.ccglasswipe", "CC Glass Wipe", "Glass Wipe"),
|
||||
("ec.generate.ccgluegun", "CC Glue Gun", "Paint Beads"),
|
||||
("ec.transition.ccgridwipe", "CC Grid Wipe", "Grid Wipe"),
|
||||
("ec.distort.ccgriddler", "CC Griddler", "Tile Grid"),
|
||||
("ec.sim.cchair", "CC Hair", "Surface Fibers"),
|
||||
("ec.stylize.cchextile", "CC HexTile", "Hexagonal Tiles"),
|
||||
("ec.transition.ccimagewipe", "CC Image Wipe", "Luminance Image Wipe"),
|
||||
("ec.transition.ccjaws", "CC Jaws", "Serrated Wipe"),
|
||||
("ec.stylize.cckaleida", "CC Kaleida", "Kaleidoscope"),
|
||||
("ec.color.cckernel", "CC Kernel", "Convolution Kernel"),
|
||||
("ec.distort.cclens", "CC Lens", "Lens Distortion"),
|
||||
("ec.generate.cclightburst", "CC Light Burst 2.5", "Radial Light Burst"),
|
||||
("ec.generate.cclightrays", "CC Light Rays", "Light Rays"),
|
||||
("ec.generate.cclightsweep", "CC Light Sweep", "Light Sweep"),
|
||||
("ec.transition.cclightwipe", "CC Light Wipe", "Light Wipe"),
|
||||
("ec.transition.cclinesweep", "CC Line Sweep", "Line Sweep"),
|
||||
("ec.sim.ccmrmercury", "CC Mr. Mercury", "Liquid Particles"),
|
||||
("ec.stylize.ccmrsmoothie", "CC Mr. Smoothie", "Palette Map"),
|
||||
("ec.utility.ccoverbrights", "CC Overbrights", "Out-of-Range Display"),
|
||||
("ec.distort.ccpageturn", "CC Page Turn", "Page Curl"),
|
||||
("ec.sim.ccparticlesystems2", "CC Particle Systems II", "Particle Emitter 2D"),
|
||||
("ec.sim.ccparticleworld", "CC Particle World", "Particle Emitter 3D"),
|
||||
("ec.sim.ccpixelpolly", "CC Pixel Polly", "Pixel Shatter"),
|
||||
("ec.stylize.ccplastic", "CC Plastic", "Plastic Shading"),
|
||||
("ec.distort.ccpowerpin", "CC Power Pin", "Perspective Pin"),
|
||||
("ec.blur.ccradial", "CC Radial Blur", "Variable Radial Blur"),
|
||||
("ec.blur.ccradialfast", "CC Radial Fast Blur", "Fast Radial Blur"),
|
||||
("ec.transition.ccradialscalewipe", "CC Radial ScaleWipe", "Radial Scale Wipe"),
|
||||
("ec.sim.ccrainfall", "CC Rainfall", "Rainfall"),
|
||||
("ec.stylize.ccrepetile", "CC RepeTile", "Edge Tile Repeat"),
|
||||
("ec.distort.ccripplepulse", "CC Ripple Pulse", "Pulsed Ripple"),
|
||||
("ec.transition.ccscalewipe", "CC Scale Wipe", "Scale Wipe"),
|
||||
("ec.sim.ccscatterize", "CC Scatterize", "Pixel Scatter"),
|
||||
("ec.key.ccsimplewireremoval", "CC Simple Wire Removal", "Line Removal"),
|
||||
("ec.distort.ccslant", "CC Slant", "Slant"),
|
||||
("ec.distort.ccsmear", "CC Smear", "Directional Smear"),
|
||||
("ec.sim.ccsnowfall", "CC Snowfall", "Snowfall"),
|
||||
("ec.perspective.ccsphere", "CC Sphere", "Sphere Wrap"),
|
||||
("ec.distort.ccsplit", "CC Split", "Split Warp"),
|
||||
("ec.distort.ccsplit2", "CC Split 2", "Dual Split Warp"),
|
||||
("ec.perspective.ccspotlight", "CC Spotlight", "Surface Spotlight"),
|
||||
("ec.sim.ccstarburst", "CC Star Burst", "Starfield"),
|
||||
("ec.generate.ccthreads", "CC Threads", "Woven Threads"),
|
||||
("ec.stylize.ccthreshold", "CC Threshold", "Channel Threshold"),
|
||||
("ec.stylize.ccthresholdrgb", "CC Threshold RGB", "RGB Threshold"),
|
||||
("ec.distort.cctiler", "CC Tiler", "Scaled Tiles"),
|
||||
("ec.color.cctoner", "CC Toner", "Multi-Tone Tint"),
|
||||
("ec.transition.cctwister", "CC Twister", "Twist Wipe"),
|
||||
("ec.blur.ccvector", "CC Vector Blur", "Vector Blur"),
|
||||
("ec.stylize.ccvignette", "CC Vignette", "Vignette"),
|
||||
("ec.transition.ccwarpomatic", "CC WarpoMatic", "Warp Dissolve"),
|
||||
("ec.time.ccwidetime", "CC Wide Time", "Temporal Blend"),
|
||||
("ec.color.lumetri", "Lumetri Color", "Color Grading"),
|
||||
("ec.obsolete.mochashape", "Mocha shape", "Tracked Shape Matte"),
|
||||
("ec.keying.keylight", "Key Light", "Screen Color Key"),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn descriptive_names_keep_every_legacy_lookup_and_search() {
|
||||
for &(id, old, new) in RENAMED {
|
||||
let spec = find(id).unwrap();
|
||||
assert_eq!(spec.name, new, "{id}");
|
||||
for query in [id.to_string(), old.to_string(), old.to_lowercase(), new.to_string()] {
|
||||
assert_eq!(lookup(&query).map(|s| s.id), Some(id), "{query}");
|
||||
}
|
||||
assert!(name_matches(spec, &old.to_lowercase()), "{old}");
|
||||
assert!(name_matches(spec, &new.to_lowercase()), "{new}");
|
||||
}
|
||||
for alias in ["Keylight", "Keylight (1.2)", "Key Light"] {
|
||||
assert_eq!(lookup(alias).map(|s| s.id), Some("ec.keying.keylight"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descriptive_display_names_do_not_collide() {
|
||||
let mut names = std::collections::HashSet::new();
|
||||
for spec in crate::registry() {
|
||||
assert!(names.insert(spec.name.to_lowercase()), "duplicate name {}", spec.name);
|
||||
let label = spec.name.to_lowercase();
|
||||
assert!(!label.starts_with("cc "), "{}", spec.name);
|
||||
for mark in ["lumetri", "mercury", "mocha", "keylight"] {
|
||||
assert!(!label.contains(mark), "{}", spec.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_instance_names_and_parameter_data_survive_refresh() {
|
||||
for &(id, old, _) in RENAMED {
|
||||
let spec = find(id).unwrap();
|
||||
for stored in [old.to_string(), format!("{old} 2"), "My custom instance".to_string()] {
|
||||
let mut next = 1;
|
||||
let mut ids = Ids(&mut next);
|
||||
let mut instance = instantiate(spec, &mut ids, &stored, [128.0, 72.0]);
|
||||
let before = serde_json::to_value(&instance).unwrap();
|
||||
crate::migrate::upgrade_instance(spec, &mut instance, &mut ids, [128.0, 72.0]);
|
||||
assert_eq!(instance.name, stored, "{id}: never rewrite a stored instance name");
|
||||
assert_eq!(serde_json::to_value(&instance).unwrap(), before, "{id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Distort effects, batch 2: Turbulent Displace, Displacement Map (self as map), Optics
|
||||
//! Compensation, Magnify, Mesh Warp, Bezier Warp, CC Bend It, CC Lens, CC Page Turn, CC Tiler,
|
||||
//! CC Griddler.
|
||||
//! Compensation, Magnify, Mesh Warp, Bezier Warp, Two-Point Bend, Lens Distortion, Page Curl, Scaled Tiles,
|
||||
//! Tile Grid.
|
||||
//!
|
||||
//! Mesh Warp and Bezier Warp share a grid rasteriser: a (nx+1)×(ny+1) grid of destination points
|
||||
//! and matching source points is split into triangles, bucketed per output row and filled
|
||||
@@ -550,7 +550,7 @@ pub fn bezier_grid(ctx: &EffectCtx, offset: [f64; 2], scale: f64) -> (usize, Vec
|
||||
(n, dest, srcp)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Bend It
|
||||
// ---------------------------------------------------------------- Two-Point Bend
|
||||
|
||||
fn bend_it(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let mut beta = ctx.params.f("bend").to_radians();
|
||||
@@ -606,7 +606,7 @@ fn bend_it(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
b
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Lens
|
||||
// ---------------------------------------------------------------- Lens Distortion
|
||||
|
||||
fn cc_lens(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let c = b.to_px(ctx.params.v2("center"));
|
||||
@@ -629,7 +629,7 @@ fn cc_lens(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
b
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Page Turn
|
||||
// ---------------------------------------------------------------- Page Curl
|
||||
|
||||
fn page_turn(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let f = b.to_px(ctx.params.v2("foldPosition"));
|
||||
@@ -679,7 +679,7 @@ fn page_turn(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
b
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Tiler / CC Griddler
|
||||
// ---------------------------------------------------------------- Scaled Tiles / Tile Grid
|
||||
|
||||
fn cc_tiler(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let s = (ctx.params.f("scale") / 100.0).max(0.001);
|
||||
@@ -829,7 +829,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.distort.ccbendit",
|
||||
"CC Bend It",
|
||||
"Two-Point Bend",
|
||||
vec![
|
||||
p("bend", "Bend", num(0.0), ParamUi::Angle),
|
||||
p("start", "Start", pt(0.5, 0.2), ParamUi::Point),
|
||||
@@ -840,7 +840,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.distort.cclens",
|
||||
"CC Lens",
|
||||
"Lens Distortion",
|
||||
vec![
|
||||
p("center", "Center", pt(0.5, 0.5), ParamUi::Point),
|
||||
p("size", "Size", num(50.0), slider(0.0, 400.0, 0.0, 200.0, 1)),
|
||||
@@ -850,7 +850,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.distort.ccpageturn",
|
||||
"CC Page Turn",
|
||||
"Page Curl",
|
||||
vec![
|
||||
p("foldPosition", "Fold Position", pt(0.7, 0.7), ParamUi::Point),
|
||||
p("foldDirection", "Fold Direction", num(315.0), ParamUi::Angle),
|
||||
@@ -864,7 +864,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.distort.cctiler",
|
||||
"CC Tiler",
|
||||
"Scaled Tiles",
|
||||
vec![
|
||||
p("scale", "Scale", num(50.0), slider(1.0, 10000.0, 1.0, 200.0, 1)),
|
||||
p("center", "Center", pt(0.5, 0.5), ParamUi::Point),
|
||||
@@ -874,7 +874,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.distort.ccgriddler",
|
||||
"CC Griddler",
|
||||
"Tile Grid",
|
||||
vec![
|
||||
p("horizontalScale", "Horizontal Scale", num(100.0), slider(1.0, 1000.0, 1.0, 400.0, 1)),
|
||||
p("verticalScale", "Vertical Scale", num(100.0), slider(1.0, 1000.0, 1.0, 400.0, 1)),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Distort effects, batch 3: CC Bender, CC Blobbylize, CC Power Pin, CC Ripple Pulse, CC Slant,
|
||||
//! CC Smear, CC Split, CC Split 2, Smear and Reshape (mask driven), Twirl (Legacy), Warp (the
|
||||
//! Distort effects, batch 3: Flexible Bend, Organic Displacement, Perspective Pin, Pulsed Ripple, Slant,
|
||||
//! Directional Smear, Split Warp, Dual Split Warp, Smear and Reshape (mask driven), Twirl (Legacy), Warp (the
|
||||
//! fifteen warp styles) and Detail-preserving Upscale.
|
||||
//!
|
||||
//! All warps are inverse mapped: every output pixel finds the source point that lands on it.
|
||||
@@ -59,7 +59,7 @@ fn elastic_exp(i: u32) -> f64 {
|
||||
[1.0, 1.25, 1.5, 2.0, 2.5, 3.0, 3.5, 4.5][(i as usize).min(7)]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Bender
|
||||
// ---------------------------------------------------------------- Flexible Bend
|
||||
|
||||
fn cc_bender(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let amount = ctx.params.f("amount") / 100.0;
|
||||
@@ -107,7 +107,7 @@ fn norm3(v: [f64; 3]) -> [f64; 3] {
|
||||
[v[0] / l, v[1] / l, v[2] / l]
|
||||
}
|
||||
|
||||
/// f32 normal/dot helpers for the CC Blobbylize lighting pass. The GPU kernel runs in f32;
|
||||
/// f32 normal/dot helpers for the Organic Displacement lighting pass. The GPU kernel runs in f32;
|
||||
/// matching its precision here keeps specular highlights within the CPU/GPU parity tolerance.
|
||||
fn norm3f(v: [f32; 3]) -> [f32; 3] {
|
||||
let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt().max(1e-12);
|
||||
@@ -118,7 +118,7 @@ fn dot3f(a: [f32; 3], b: [f32; 3]) -> f32 {
|
||||
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Blobbylize
|
||||
// ---------------------------------------------------------------- Organic Displacement
|
||||
|
||||
fn cc_blobbylize(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let soft = ctx.params.f("softness").max(0.0) * b.scale;
|
||||
@@ -221,7 +221,7 @@ fn cc_blobbylize(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
b
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Power Pin
|
||||
// ---------------------------------------------------------------- Perspective Pin
|
||||
|
||||
fn cc_power_pin(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let (lw, lh) = (ctx.layer_size[0].max(1.0), ctx.layer_size[1].max(1.0));
|
||||
@@ -304,7 +304,7 @@ fn cc_power_pin(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
b
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Ripple Pulse
|
||||
// ---------------------------------------------------------------- Pulsed Ripple
|
||||
|
||||
fn cc_ripple_pulse(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let level = ctx.params.f("pulseLevel") / 100.0;
|
||||
@@ -350,7 +350,7 @@ fn cc_ripple_pulse(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
b
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Slant
|
||||
// ---------------------------------------------------------------- Slant
|
||||
|
||||
fn cc_slant(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let k = ctx.params.f("slant") / 100.0;
|
||||
@@ -375,7 +375,7 @@ fn cc_slant(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
b
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Smear
|
||||
// ---------------------------------------------------------------- Directional Smear
|
||||
|
||||
fn cc_smear(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let reach = ctx.params.f("reach") / 100.0;
|
||||
@@ -403,7 +403,7 @@ fn cc_smear(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
}
|
||||
|
||||
/// The transparent pixels to add on every side of a buffer `size` pixels big (with `offset` and
|
||||
/// `scale`) for CC Smear: it moves pixels by up to Reach × From→To, and they may move out to the
|
||||
/// `scale`) for Directional Smear: it moves pixels by up to Reach × From→To, and they may move out to the
|
||||
/// layer bounds. A text or shape layer's buffer ends at its content while its bounds are
|
||||
/// comp-sized, so the smear was cut off at the content's box (#659).
|
||||
pub fn cc_smear_pad(ctx: &EffectCtx, size: [u32; 2], offset: [f64; 2], scale: f64) -> u32 {
|
||||
@@ -417,7 +417,7 @@ pub fn cc_smear_pad(ctx: &EffectCtx, size: [u32; 2], offset: [f64; 2], scale: f6
|
||||
(moved.min(4096.0) as u32).min(crate::util::layer_cover_pad(ctx, size, offset, scale))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Split / CC Split 2
|
||||
// ---------------------------------------------------------------- Split Warp / Dual Split Warp
|
||||
|
||||
fn split_impl(ctx: &EffectCtx, mut b: Buf, s1: f64, s2: f64) -> Buf {
|
||||
if s1 == 0.0 && s2 == 0.0 {
|
||||
@@ -913,7 +913,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
vec![
|
||||
spec(
|
||||
"ec.distort.ccbender",
|
||||
"CC Bender",
|
||||
"Flexible Bend",
|
||||
vec![
|
||||
p("amount", "Amount", num(0.0), slider(-200.0, 200.0, -100.0, 100.0, 1)),
|
||||
p("style", "Style", Value::Enum(0), popup(&["Bend", "Marilyn", "Sharp", "Boxer"])),
|
||||
@@ -925,7 +925,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.distort.ccblobbylize",
|
||||
"CC Blobbylize",
|
||||
"Organic Displacement",
|
||||
vec![
|
||||
p("blobLayer", "Blob Layer", Value::Layer(None), ParamUi::Layer),
|
||||
p("property", "Property", Value::Enum(0), popup(&crate::util::SRC_NAMES[..8])),
|
||||
@@ -947,7 +947,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.distort.ccpowerpin",
|
||||
"CC Power Pin",
|
||||
"Perspective Pin",
|
||||
vec![
|
||||
p("topLeft", "Top Left", pt(0.0, 0.0), ParamUi::Point),
|
||||
p("topRight", "Top Right", pt(1.0, 0.0), ParamUi::Point),
|
||||
@@ -964,7 +964,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.distort.ccripplepulse",
|
||||
"CC Ripple Pulse",
|
||||
"Pulsed Ripple",
|
||||
vec![
|
||||
p("center", "Center", pt(0.5, 0.5), ParamUi::Point),
|
||||
p("pulseLevel", "Pulse Level (animate)", num(0.0), slider(-1000.0, 1000.0, -100.0, 100.0, 1)),
|
||||
@@ -976,7 +976,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.distort.ccslant",
|
||||
"CC Slant",
|
||||
"Slant",
|
||||
vec![
|
||||
p("slant", "Slant", num(0.0), slider(-1000.0, 1000.0, -100.0, 100.0, 1)),
|
||||
p("stretching", "Stretching", Value::Bool(false), ParamUi::Checkbox),
|
||||
@@ -987,7 +987,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.distort.ccsmear",
|
||||
"CC Smear",
|
||||
"Directional Smear",
|
||||
vec![
|
||||
p("from", "From", pt(0.35, 0.5), ParamUi::Point),
|
||||
p("to", "To", pt(0.65, 0.5), ParamUi::Point),
|
||||
@@ -998,7 +998,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.distort.ccsplit",
|
||||
"CC Split",
|
||||
"Split Warp",
|
||||
vec![
|
||||
p("pointA", "Point A", pt(0.25, 0.5), ParamUi::Point),
|
||||
p("pointB", "Point B", pt(0.75, 0.5), ParamUi::Point),
|
||||
@@ -1008,7 +1008,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.distort.ccsplit2",
|
||||
"CC Split 2",
|
||||
"Dual Split Warp",
|
||||
vec![
|
||||
p("pointA", "Point A", pt(0.25, 0.5), ParamUi::Point),
|
||||
p("pointB", "Point B", pt(0.75, 0.5), ParamUi::Point),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Distort: CC Flo Motion, Liquify and Rolling Shutter Repair.
|
||||
//! Distort: Dual Lens Warp, Liquify and Rolling Shutter Repair.
|
||||
//!
|
||||
//! * **CC Flo Motion** — two knots that pull the image toward themselves (positive amount) or
|
||||
//! * **Dual Lens Warp** — two knots that pull the image toward themselves (positive amount) or
|
||||
//! push it away (negative), with a falloff exponent, optional tiling and supersampled
|
||||
//! antialiasing.
|
||||
//! * **Liquify** — brush strokes build a displacement mesh (a grid of layer-space offsets). The
|
||||
@@ -32,7 +32,7 @@ fn spec(id: &'static str, name: &'static str, params: Vec<crate::ParamSpec>, ren
|
||||
EffectSpec { id, name, category: "Distort", params, render, gpu: false, float: true }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Flo Motion
|
||||
// ---------------------------------------------------------------- Dual Lens Warp
|
||||
|
||||
fn flo_motion(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let knots = [(ctx.params.v2("knot1"), ctx.params.f("amount1") / 100.0), (ctx.params.v2("knot2"), ctx.params.f("amount2") / 100.0)];
|
||||
@@ -560,7 +560,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
vec![
|
||||
spec(
|
||||
"ec.distort.ccflomotion",
|
||||
"CC Flo Motion",
|
||||
"Dual Lens Warp",
|
||||
vec![
|
||||
p("knot1", "Knot 1", Value::Vec2([0.25, 0.5]), ParamUi::Point),
|
||||
p("amount1", "Amount 1", num(50.0), slider(-400.0, 100.0, -100.0, 100.0, 1)),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Generate effects, batch 2: Cell Pattern (Worley), Ellipse, Lens Flare, Beam, Radio Waves,
|
||||
//! Advanced Lightning, CC Light Rays, CC Light Burst 2.5, CC Light Sweep.
|
||||
//! Advanced Lightning, Light Rays, Radial Light Burst, Light Sweep.
|
||||
//!
|
||||
//! All designs are original procedural renderings written from the public descriptions of what
|
||||
//! each effect produces.
|
||||
@@ -897,7 +897,7 @@ fn advanced_lightning(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
b
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Light Rays / Burst
|
||||
// ---------------------------------------------------------------- Light Rays / Burst
|
||||
|
||||
const RAY_STEPS: usize = 32;
|
||||
|
||||
@@ -1007,7 +1007,7 @@ fn light_burst(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
b
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Light Sweep
|
||||
// ---------------------------------------------------------------- Light Sweep
|
||||
|
||||
fn light_sweep(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let c = b.to_px(ctx.params.v2("center"));
|
||||
@@ -1192,7 +1192,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.generate.cclightrays",
|
||||
"CC Light Rays",
|
||||
"Light Rays",
|
||||
vec![
|
||||
p("intensity", "Intensity", num(100.0), slider(0.0, 600.0, 0.0, 200.0, 1)),
|
||||
p("center", "Center", pt(0.5, 0.5), ParamUi::Point),
|
||||
@@ -1207,7 +1207,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.generate.cclightburst",
|
||||
"CC Light Burst 2.5",
|
||||
"Radial Light Burst",
|
||||
vec![
|
||||
p("center", "Center", pt(0.5, 0.5), ParamUi::Point),
|
||||
p("intensity", "Intensity", num(100.0), slider(0.0, 600.0, 0.0, 200.0, 1)),
|
||||
@@ -1220,7 +1220,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.generate.cclightsweep",
|
||||
"CC Light Sweep",
|
||||
"Light Sweep",
|
||||
vec![
|
||||
p("center", "Center", pt(0.5, 0.5), ParamUi::Point),
|
||||
p("direction", "Direction", num(-30.0), ang()),
|
||||
|
||||
@@ -180,7 +180,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.generate.ccgluegun",
|
||||
"CC Glue Gun",
|
||||
"Paint Beads",
|
||||
vec![
|
||||
p("brushPosition", "Brush Position", pt(0.5, 0.5), ParamUi::Point),
|
||||
p("strokeWidth", "Stroke Width", num(25.0), slider(0.0, 500.0, 0.0, 100.0, 1)),
|
||||
@@ -194,7 +194,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.generate.ccthreads",
|
||||
"CC Threads",
|
||||
"Woven Threads",
|
||||
vec![
|
||||
p("width", "Width", num(50.0), slider(1.0, 1000.0, 1.0, 200.0, 1)),
|
||||
p("height", "Height", num(50.0), slider(1.0, 1000.0, 1.0, 200.0, 1)),
|
||||
|
||||
@@ -317,7 +317,7 @@ fn unmult(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
b
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Simple Wire Removal
|
||||
// ---------------------------------------------------------------- Line Removal
|
||||
|
||||
fn wire_removal(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let pa = ctx.params.v2("pointA");
|
||||
@@ -464,7 +464,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.key.ccsimplewireremoval",
|
||||
"CC Simple Wire Removal",
|
||||
"Line Removal",
|
||||
vec![
|
||||
p("pointA", "Point A", Value::Vec2([0.25, 0.25]), ParamUi::Point),
|
||||
p("pointB", "Point B", Value::Vec2([0.75, 0.75]), ParamUi::Point),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Key Light — a full screen-colour keyer with the controls of After Effects' bundled
|
||||
//! Screen Color Key — a full screen-colour keyer with the controls of After Effects' bundled
|
||||
//! "Keylight (1.2)" (that name is a trademark of its vendor, so the effect is registered as
|
||||
//! **Key Light**; `lookup("Keylight (1.2)")` finds it through [`KEYLIGHT_ALIASES`]).
|
||||
//! **Screen Color Key**; `lookup("Keylight (1.2)")` finds it through [`KEYLIGHT_ALIASES`]).
|
||||
//!
|
||||
//! The method is the textbook *screen difference* key, written from the public description of
|
||||
//! the controls:
|
||||
@@ -31,7 +31,7 @@ use crate::util::{Plane, gauss_plane, morph_frac, point_in_poly, premul, unpremu
|
||||
use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider};
|
||||
|
||||
/// Other names the effect answers to in [`crate::lookup`].
|
||||
pub const KEYLIGHT_ALIASES: &[&str] = &["Keylight (1.2)", "Keylight"];
|
||||
pub const KEYLIGHT_ALIASES: &[&str] = &["Keylight (1.2)", "Keylight", "Key Light"];
|
||||
|
||||
pub const VIEWS: [&str; 11] = [
|
||||
"Source",
|
||||
@@ -463,7 +463,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
let cc = |id: &'static str, name: &'static str| p(id, name, num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 1));
|
||||
vec![EffectSpec {
|
||||
id: "ec.keying.keylight",
|
||||
name: "Key Light",
|
||||
name: "Screen Color Key",
|
||||
category: "Keying",
|
||||
params: vec![
|
||||
p("view", "View", Value::Enum(10), popup(&VIEWS)),
|
||||
@@ -622,7 +622,7 @@ mod tests {
|
||||
let spec = crate::find("ec.keying.keylight").unwrap();
|
||||
let mut next = 1;
|
||||
let mut ids = Ids(&mut next);
|
||||
let mut g = crate::instantiate(spec, &mut ids, "Key Light", [10.0, 10.0]);
|
||||
let mut g = crate::instantiate(spec, &mut ids, "Screen Color Key", [10.0, 10.0]);
|
||||
// Flatten it the way older versions saved it.
|
||||
let mut flat = vec![];
|
||||
g.walk_mut(&mut |p| flat.push(p.clone()));
|
||||
|
||||
@@ -23,6 +23,8 @@ mod color3;
|
||||
mod color_fx;
|
||||
pub mod controls;
|
||||
pub mod crypto_select;
|
||||
#[cfg(test)]
|
||||
mod display_names;
|
||||
mod distort;
|
||||
mod distort2;
|
||||
mod distort3;
|
||||
@@ -36,6 +38,7 @@ mod keying;
|
||||
mod keying2;
|
||||
pub mod keylight;
|
||||
mod matte;
|
||||
|
||||
pub mod migrate;
|
||||
mod misc;
|
||||
pub mod mocha_shape;
|
||||
@@ -116,8 +119,9 @@ pub use time_fx::{PixelMotion, TimewarpPlan, TwStep, pmb_plan, posterized_time,
|
||||
pub use ocio::color_stabilizer_maps;
|
||||
// GPU effects, part B (3D Channel, Immersive Video, colour management, simulation render passes).
|
||||
pub use channel3d::{
|
||||
CRYPTO_LAYERS, CryptoAux, MATTE_ONLY_IN_ALPHA, aux_point, crypto_aux_image, crypto_aux_image_with, crypto_layer_choices, crypto_layer_menu,
|
||||
crypto_layer_name, crypto_layer_named, id_color, matte_only_px, reads_aux, selection_hashes,
|
||||
CRYPTO_LAYERS, CryptoAux, EXTRACTOR_COPY, MATTE_ONLY_IN_ALPHA, aux_point, crypto_aux_image, crypto_aux_image_with, crypto_layer_choices, crypto_layer_menu,
|
||||
crypto_layer_name, crypto_layer_named, extractor_aux_channel, extractor_own_channel, id_color, is_extractor_copy, matte_only_px, reads_aux,
|
||||
selection_hashes,
|
||||
};
|
||||
pub use ocio::{ColorOp, Straight, Tf, color_program};
|
||||
pub use sim::{Acc, Blob, Bubble, Post, Shape, Sprite, SpritePlan, Tint, bubble_list, bubble_sample_bounds, drizzle_drops, mercury_blobs, sprite_plan};
|
||||
@@ -559,7 +563,7 @@ pub trait EffectHost: Sync {
|
||||
}
|
||||
/// Parameter `id` (a [`flatten_params`] id such as `birthRate`) of effect `effect_index` in
|
||||
/// the layer's stack (the running effect's [`EffectEnv::effect_index`]) at another **layer
|
||||
/// time**: CC Particle World samples its Birth Rate at every step it simulates (#1373).
|
||||
/// time**: Particle Emitter 3D samples its Birth Rate at every step it simulates (#1373).
|
||||
/// `None` when unavailable.
|
||||
fn param_at(&self, _effect_index: usize, _id: &str, _layer_time: f64) -> Option<Value> {
|
||||
None
|
||||
@@ -775,8 +779,82 @@ 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)];
|
||||
/// Legacy display names accepted for saved scripts and searches. Never display these as
|
||||
/// native feature names; stable effect IDs and external format match names stay unchanged.
|
||||
const ALIASES: &[(&str, &[&str])] = &[
|
||||
("ec.sim.ccballaction", &["CC Ball Action"]),
|
||||
("ec.distort.ccbendit", &["CC Bend It"]),
|
||||
("ec.distort.ccbender", &["CC Bender"]),
|
||||
("ec.distort.ccblobbylize", &["CC Blobbylize"]),
|
||||
("ec.stylize.ccblockload", &["CC Block Load"]),
|
||||
("ec.sim.ccbubbles", &["CC Bubbles"]),
|
||||
("ec.stylize.ccburnfilm", &["CC Burn Film"]),
|
||||
("ec.color.cccolorneutralizer", &["CC Color Neutralizer"]),
|
||||
("ec.color.cccoloroffset", &["CC Color Offset"]),
|
||||
("ec.channel.cccomposite", &["CC Composite"]),
|
||||
("ec.blur.cccross", &["CC Cross Blur"]),
|
||||
("ec.perspective.cccylinder", &["CC Cylinder"]),
|
||||
("ec.sim.ccdrizzle", &["CC Drizzle"]),
|
||||
("ec.perspective.ccenvironment", &["CC Environment"]),
|
||||
("ec.distort.ccflomotion", &["CC Flo Motion"]),
|
||||
("ec.time.ccforcemotionblur", &["CC Force Motion Blur"]),
|
||||
("ec.stylize.ccglass", &["CC Glass"]),
|
||||
("ec.transition.ccglasswipe", &["CC Glass Wipe"]),
|
||||
("ec.generate.ccgluegun", &["CC Glue Gun"]),
|
||||
("ec.transition.ccgridwipe", &["CC Grid Wipe"]),
|
||||
("ec.distort.ccgriddler", &["CC Griddler"]),
|
||||
("ec.sim.cchair", &["CC Hair"]),
|
||||
("ec.stylize.cchextile", &["CC HexTile"]),
|
||||
("ec.transition.ccimagewipe", &["CC Image Wipe"]),
|
||||
("ec.transition.ccjaws", &["CC Jaws"]),
|
||||
("ec.stylize.cckaleida", &["CC Kaleida"]),
|
||||
("ec.color.cckernel", &["CC Kernel"]),
|
||||
("ec.distort.cclens", &["CC Lens"]),
|
||||
("ec.generate.cclightburst", &["CC Light Burst 2.5"]),
|
||||
("ec.generate.cclightrays", &["CC Light Rays"]),
|
||||
("ec.generate.cclightsweep", &["CC Light Sweep"]),
|
||||
("ec.transition.cclightwipe", &["CC Light Wipe"]),
|
||||
("ec.transition.cclinesweep", &["CC Line Sweep"]),
|
||||
("ec.sim.ccmrmercury", &["CC Mr. Mercury"]),
|
||||
("ec.stylize.ccmrsmoothie", &["CC Mr. Smoothie"]),
|
||||
("ec.utility.ccoverbrights", &["CC Overbrights"]),
|
||||
("ec.distort.ccpageturn", &["CC Page Turn"]),
|
||||
("ec.sim.ccparticlesystems2", &["CC Particle Systems II"]),
|
||||
("ec.sim.ccparticleworld", &["CC Particle World"]),
|
||||
("ec.sim.ccpixelpolly", &["CC Pixel Polly"]),
|
||||
("ec.stylize.ccplastic", &["CC Plastic"]),
|
||||
("ec.distort.ccpowerpin", &["CC Power Pin"]),
|
||||
("ec.blur.ccradial", &["CC Radial Blur"]),
|
||||
("ec.blur.ccradialfast", &["CC Radial Fast Blur"]),
|
||||
("ec.transition.ccradialscalewipe", &["CC Radial ScaleWipe"]),
|
||||
("ec.sim.ccrainfall", &["CC Rainfall"]),
|
||||
("ec.stylize.ccrepetile", &["CC RepeTile"]),
|
||||
("ec.distort.ccripplepulse", &["CC Ripple Pulse"]),
|
||||
("ec.transition.ccscalewipe", &["CC Scale Wipe"]),
|
||||
("ec.sim.ccscatterize", &["CC Scatterize"]),
|
||||
("ec.key.ccsimplewireremoval", &["CC Simple Wire Removal"]),
|
||||
("ec.distort.ccslant", &["CC Slant"]),
|
||||
("ec.distort.ccsmear", &["CC Smear"]),
|
||||
("ec.sim.ccsnowfall", &["CC Snowfall"]),
|
||||
("ec.perspective.ccsphere", &["CC Sphere"]),
|
||||
("ec.distort.ccsplit", &["CC Split"]),
|
||||
("ec.distort.ccsplit2", &["CC Split 2"]),
|
||||
("ec.perspective.ccspotlight", &["CC Spotlight"]),
|
||||
("ec.sim.ccstarburst", &["CC Star Burst"]),
|
||||
("ec.generate.ccthreads", &["CC Threads"]),
|
||||
("ec.stylize.ccthreshold", &["CC Threshold"]),
|
||||
("ec.stylize.ccthresholdrgb", &["CC Threshold RGB"]),
|
||||
("ec.distort.cctiler", &["CC Tiler"]),
|
||||
("ec.color.cctoner", &["CC Toner"]),
|
||||
("ec.transition.cctwister", &["CC Twister"]),
|
||||
("ec.blur.ccvector", &["CC Vector Blur"]),
|
||||
("ec.stylize.ccvignette", &["CC Vignette"]),
|
||||
("ec.transition.ccwarpomatic", &["CC WarpoMatic"]),
|
||||
("ec.time.ccwidetime", &["CC Wide Time"]),
|
||||
("ec.color.lumetri", &["Lumetri Color"]),
|
||||
("ec.obsolete.mochashape", &["Mocha shape", "mocha shape"]),
|
||||
("ec.keying.keylight", keylight::KEYLIGHT_ALIASES),
|
||||
];
|
||||
|
||||
/// Other names effect `id` answers to (in [`lookup`] and searches).
|
||||
pub fn aliases(id: &str) -> &'static [&'static str] {
|
||||
@@ -860,7 +938,7 @@ pub fn instantiate(spec: &EffectSpec, ids: &mut Ids, instance_name: &str, layer_
|
||||
/// effects without their own table.
|
||||
pub const PARAM_GROUPS: &[(&str, &str)] = &[
|
||||
// Color Correction: Exposure, Levels (Individual Controls), Change to Color, Colorama,
|
||||
// Shadow/Highlight, Lumetri Color.
|
||||
// Shadow/Highlight, Color Grading.
|
||||
("master", "Master"),
|
||||
("rgb", "RGB"),
|
||||
("red", "Red"),
|
||||
@@ -989,7 +1067,7 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[
|
||||
("particleExploder", "Particle Exploder"),
|
||||
("affects", "Affects"),
|
||||
("options", "Options"),
|
||||
// Keying, Matte, Obsolete (Key Light's groups are in `keylight::GROUPS`).
|
||||
// Keying, Matte, Obsolete (Screen Color Key's groups are in `keylight::GROUPS`).
|
||||
("ultraSettings", "Ultra Settings"),
|
||||
("additionalForeground", "Additional Foreground"),
|
||||
("additionalBackground", "Additional Background"),
|
||||
@@ -1074,7 +1152,7 @@ fn nested_group<'a>(g: &'a mut PropGroup, ids: &mut Ids, path: &str) -> Option<&
|
||||
}
|
||||
|
||||
/// Effects the GPU compositor (`effectcraft-gpu`) implements with the CPU effect's semantics
|
||||
/// (Mercury GPU Acceleration; the Effects & Presets GPU badge). Expression controls are
|
||||
/// (GPU Acceleration; the Effects & Presets GPU badge). Expression controls are
|
||||
/// pass-throughs and count as GPU effects too.
|
||||
pub const GPU_EFFECTS: &[&str] = &[
|
||||
"ec.blur.gaussian",
|
||||
@@ -1494,7 +1572,7 @@ mod tests {
|
||||
assert_eq!(grow_sides(lo, hi, 20, 10), Some([0, 0, 30, 20]));
|
||||
}
|
||||
|
||||
/// #2261: a 20 × 20 layer with CC Radial Fast Blur's Center far away grows to at most 16 Mpx;
|
||||
/// #2261: a 20 × 20 layer with Fast Radial Blur's Center far away grows to at most 16 Mpx;
|
||||
/// big layers may still grow to four times their area, and small pads are untouched.
|
||||
#[test]
|
||||
fn padded_buffers_stay_within_the_budget() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Matte ▸ Mocha shape: renders planar-tracked roto shapes as a matte.
|
||||
//! Matte ▸ Tracked Shape Matte: renders planar-tracked roto shapes as a matte.
|
||||
//!
|
||||
//! Mocha's own clipboard / export formats are not publicly specified, so the effect reads shape
|
||||
//! data in a small documented JSON format of ours (in the hidden `shapeData` parameter, either the
|
||||
@@ -184,7 +184,7 @@ fn mocha_shape(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
pub fn specs() -> Vec<EffectSpec> {
|
||||
vec![EffectSpec {
|
||||
id: "ec.obsolete.mochashape",
|
||||
name: "Mocha shape",
|
||||
name: "Tracked Shape Matte",
|
||||
category: "Matte",
|
||||
params: vec![
|
||||
p("blendMode", "Blend mode", Value::Enum(0), popup(&["Add", "Subtract", "Intersect", "Difference"])),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Perspective effects (batch 2): Bevel Alpha, Bevel Edges, Radial Shadow, CC Cylinder,
|
||||
//! CC Sphere and CC Spotlight.
|
||||
//! Perspective effects (batch 2): Bevel Alpha, Bevel Edges, Radial Shadow, Cylinder Wrap,
|
||||
//! Sphere Wrap and Surface Spotlight.
|
||||
//!
|
||||
//! Angles follow the convention used by Drop Shadow: measured clockwise from "up", so a light
|
||||
//! angle `a` sits in the direction `(sin a, -cos a)` (y grows downwards). 3D vectors use x right,
|
||||
@@ -158,7 +158,7 @@ fn radial_shadow(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
b
|
||||
}
|
||||
|
||||
/// Phong-style shading shared by CC Cylinder and CC Sphere.
|
||||
/// Phong-style shading shared by Cylinder Wrap and Sphere Wrap.
|
||||
struct Shading {
|
||||
l: [f64; 3],
|
||||
color: [f32; 4],
|
||||
@@ -451,11 +451,11 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
],
|
||||
radial_shadow,
|
||||
),
|
||||
spec("ec.perspective.cccylinder", "CC Cylinder", cyl, cylinder),
|
||||
spec("ec.perspective.ccsphere", "CC Sphere", sph, sphere),
|
||||
spec("ec.perspective.cccylinder", "Cylinder Wrap", cyl, cylinder),
|
||||
spec("ec.perspective.ccsphere", "Sphere Wrap", sph, sphere),
|
||||
spec(
|
||||
"ec.perspective.ccspotlight",
|
||||
"CC Spotlight",
|
||||
"Surface Spotlight",
|
||||
vec![
|
||||
p("from", "From", pt(0.3, 0.2), ParamUi::Point),
|
||||
p("to", "To", pt(0.5, 0.5), ParamUi::Point),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Perspective effects, batch 2: CC Environment (reflection mapping of an environment layer
|
||||
//! Perspective effects, batch 2: Environment Map (reflection mapping of an environment layer
|
||||
//! onto the layer's luminance/alpha relief) and 3D Glasses (stereo pairs and anaglyphs from a
|
||||
//! left and right view layer).
|
||||
|
||||
@@ -17,7 +17,7 @@ fn spec(id: &'static str, name: &'static str, params: Vec<crate::ParamSpec>, ren
|
||||
EffectSpec { id, name, category: "Perspective", params, render, gpu: false, float: true }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Environment
|
||||
// ---------------------------------------------------------------- Environment Map
|
||||
|
||||
/// Environment direction (x right, y down, z towards the viewer) → normalised map coordinates.
|
||||
fn env_uv(mapping: u32, r: [f64; 3]) -> (f64, f64) {
|
||||
@@ -194,7 +194,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
vec![
|
||||
spec(
|
||||
"ec.perspective.ccenvironment",
|
||||
"CC Environment",
|
||||
"Environment Map",
|
||||
vec![
|
||||
p("environment", "Environment", Value::Layer(None), ParamUi::Layer),
|
||||
p("mapping", "Mapping", Value::Enum(0), popup(&["Spherical", "Probe", "Vertical Cross"])),
|
||||
|
||||
@@ -32,7 +32,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
vec![
|
||||
spec(
|
||||
"ec.sim.ccrainfall",
|
||||
"CC Rainfall",
|
||||
"Rainfall",
|
||||
vec![
|
||||
p("drops", "Drops", num(5000.0), slider(0.0, 100_000.0, 0.0, 20_000.0, 0)),
|
||||
p("size", "Size", num(1.0), slider(0.0, 50.0, 0.0, 10.0, 2)),
|
||||
@@ -53,7 +53,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.sim.ccsnowfall",
|
||||
"CC Snowfall",
|
||||
"Snowfall",
|
||||
vec![
|
||||
p("flakes", "Flakes", num(10_000.0), slider(0.0, 200_000.0, 0.0, 50_000.0, 0)),
|
||||
p("size", "Size", num(3.0), slider(0.0, 50.0, 0.0, 20.0, 2)),
|
||||
@@ -79,7 +79,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.sim.ccbubbles",
|
||||
"CC Bubbles",
|
||||
"Bubbles",
|
||||
vec![
|
||||
p("bubbleAmount", "Bubble Amount", num(100.0), slider(0.0, 10_000.0, 0.0, 1000.0, 0)),
|
||||
p("bubbleSpeed", "Bubble Speed", num(0.5), slider(-10.0, 10.0, -2.0, 2.0, 2)),
|
||||
@@ -93,7 +93,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.sim.ccdrizzle",
|
||||
"CC Drizzle",
|
||||
"Water Ripples",
|
||||
vec![
|
||||
p("dripRate", "Drip Rate", num(10.0), slider(0.0, 200.0, 0.0, 50.0, 2)),
|
||||
p("longevity", "Longevity (sec)", num(1.0), slider(0.0, 30.0, 0.0, 5.0, 2)),
|
||||
@@ -114,7 +114,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.sim.ccstarburst",
|
||||
"CC Star Burst",
|
||||
"Starfield",
|
||||
vec![
|
||||
p("scatter", "Scatter", num(100.0), slider(0.0, 1000.0, 0.0, 300.0, 1)),
|
||||
p("speed", "Speed", num(1.0), slider(-10.0, 10.0, -4.0, 4.0, 2)),
|
||||
@@ -127,7 +127,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.sim.ccparticleworld",
|
||||
"CC Particle World",
|
||||
"Particle Emitter 3D",
|
||||
vec![
|
||||
p("birthRate", "Birth Rate", num(0.5), slider(0.0, 100.0, 0.0, 10.0, 2)),
|
||||
p("longevity", "Longevity (sec)", num(1.0), slider(0.0, 100.0, 0.0, 10.0, 2)),
|
||||
@@ -170,7 +170,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.sim.ccparticlesystems2",
|
||||
"CC Particle Systems II",
|
||||
"Particle Emitter 2D",
|
||||
vec![
|
||||
p("birthRate", "Birth Rate", num(2.0), slider(0.0, 100.0, 0.0, 20.0, 2)),
|
||||
p("longevity", "Longevity (sec)", num(1.0), slider(0.0, 100.0, 0.0, 10.0, 2)),
|
||||
@@ -198,7 +198,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.sim.ccmrmercury",
|
||||
"CC Mr. Mercury",
|
||||
"Liquid Particles",
|
||||
vec![
|
||||
p("radiusX", "Radius X", num(50.0), slider(0.0, 2000.0, 0.0, 300.0, 1)),
|
||||
p("radiusY", "Radius Y", num(50.0), slider(0.0, 2000.0, 0.0, 300.0, 1)),
|
||||
|
||||
@@ -79,7 +79,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
vec![
|
||||
spec(
|
||||
"ec.sim.ccballaction",
|
||||
"CC Ball Action",
|
||||
"Ball Grid",
|
||||
vec![
|
||||
p("scatter", "Scatter", num(0.0), slider(0.0, 2000.0, 0.0, 300.0, 1)),
|
||||
p("rotationAxis", "Rotation Axis", Value::Enum(0), popup(&["X Axis", "Y Axis", "Z Axis", "XY Axis", "XZ Axis", "YZ Axis", "XYZ Axis"])),
|
||||
@@ -94,7 +94,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.sim.ccpixelpolly",
|
||||
"CC Pixel Polly",
|
||||
"Pixel Shatter",
|
||||
vec![
|
||||
p("force", "Force", num(100.0), slider(-1000.0, 1000.0, -300.0, 300.0, 1)),
|
||||
p("gravity", "Gravity", num(1.0), slider(-20.0, 20.0, -5.0, 5.0, 2)),
|
||||
@@ -111,7 +111,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.sim.ccscatterize",
|
||||
"CC Scatterize",
|
||||
"Pixel Scatter",
|
||||
vec![
|
||||
p("scatter", "Scatter", num(0.0), slider(0.0, 1000.0, 0.0, 200.0, 1)),
|
||||
p("rightTwist", "Right Twist", num(0.0), ParamUi::Angle),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Simulation: Particle Playground and CC Hair.
|
||||
//! Simulation: Particle Playground and Surface Fibers.
|
||||
//!
|
||||
//! **Particle Playground** is a deterministic 2D particle system stepped from layer time 0 at
|
||||
//! [`SPS`] steps per second through [`SimCache`] (seeking anywhere gives the same frame as
|
||||
@@ -17,7 +17,7 @@
|
||||
//! being rendered (their pixels are part of the cache key). Particles replace the layer's own
|
||||
//! pixels, as in After Effects.
|
||||
//!
|
||||
//! **CC Hair** grows strands from the layer's opaque pixels (Density per 1 000 px²), each a
|
||||
//! **Surface Fibers** grows strands from the layer's opaque pixels (Density per 1 000 px²), each a
|
||||
//! quadratic curve of Length that droops with Weight, optionally steered and scaled by a hairfall
|
||||
//! map layer (luminance → length, horizontal/vertical gradient → lean), and shades them with a
|
||||
//! Kajiya–Kay-style strand model (diffuse ∝ sin(tangent, light), specular from the half vector).
|
||||
@@ -934,7 +934,7 @@ impl PgPlan {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Hair
|
||||
// ---------------------------------------------------------------- Surface Fibers
|
||||
|
||||
fn cc_hair(ctx: &EffectCtx, b: Buf) -> Buf {
|
||||
let Some(mut plan) = hair_plan(ctx, &b) else { return b };
|
||||
@@ -942,7 +942,7 @@ fn cc_hair(ctx: &EffectCtx, b: Buf) -> Buf {
|
||||
plan.finish(b)
|
||||
}
|
||||
|
||||
/// CC Hair's strands as line sprites (each coloured from its root, see [`Tint::Root`]);
|
||||
/// Surface Fibers's strands as line sprites (each coloured from its root, see [`Tint::Root`]);
|
||||
/// `None` = no hair (the layer passes through).
|
||||
pub(crate) fn hair_plan(ctx: &EffectCtx, b: &Buf) -> Option<SpritePlan> {
|
||||
let pr = ctx.params;
|
||||
@@ -1144,7 +1144,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
spec("ec.sim.particleplayground", "Particle Playground", pg, particle_playground),
|
||||
spec(
|
||||
"ec.sim.cchair",
|
||||
"CC Hair",
|
||||
"Surface Fibers",
|
||||
vec![
|
||||
sp("length", "Length", px(30.0, 200.0)),
|
||||
p("thickness", "Thickness", num(1.0), slider(0.05, 20.0, 0.1, 5.0, 2)),
|
||||
|
||||
@@ -343,7 +343,7 @@ fn brush_strokes(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
b
|
||||
}
|
||||
|
||||
// ---- CC Kaleida ----
|
||||
// ---- Kaleidoscope ----
|
||||
|
||||
fn kaleida(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let c = b.to_px(ctx.params.v2("center"));
|
||||
@@ -368,7 +368,7 @@ fn kaleida(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
b
|
||||
}
|
||||
|
||||
// ---- CC RepeTile ----
|
||||
// ---- Edge Tile Repeat ----
|
||||
|
||||
#[inline]
|
||||
fn tile_coord(v: f64, size: f64) -> (i64, f64) {
|
||||
@@ -491,7 +491,7 @@ fn color_emboss(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
b
|
||||
}
|
||||
|
||||
// ---- CC Vignette ----
|
||||
// ---- Vignette ----
|
||||
|
||||
fn vignette(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let amt = ctx.params.f("amount") as f32 / 100.0;
|
||||
@@ -523,7 +523,7 @@ fn vignette(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
b
|
||||
}
|
||||
|
||||
// ---- CC Threshold / CC Threshold RGB ----
|
||||
// ---- Channel Threshold / RGB Threshold ----
|
||||
|
||||
fn cc_threshold(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let t = ctx.params.f("threshold") as f32 / 255.0;
|
||||
@@ -669,7 +669,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.stylize.cckaleida",
|
||||
"CC Kaleida",
|
||||
"Kaleidoscope",
|
||||
vec![
|
||||
p("center", "Center", pt(0.5, 0.5), ParamUi::Point),
|
||||
p("size", "Size", num(100.0), slider(1.0, 1000.0, 1.0, 400.0, 1)),
|
||||
@@ -680,7 +680,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.stylize.ccrepetile",
|
||||
"CC RepeTile",
|
||||
"Edge Tile Repeat",
|
||||
vec![
|
||||
p("expandRight", "Expand Right", num(0.0), slider(0.0, 4000.0, 0.0, 500.0, 0)),
|
||||
p("expandLeft", "Expand Left", num(0.0), slider(0.0, 4000.0, 0.0, 500.0, 0)),
|
||||
@@ -718,7 +718,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.stylize.ccvignette",
|
||||
"CC Vignette",
|
||||
"Vignette",
|
||||
vec![
|
||||
p("amount", "Amount", num(50.0), slider(-100.0, 100.0, -100.0, 100.0, 1)),
|
||||
p("angleOfView", "Angle of View", num(60.0), slider(0.0, 179.0, 0.0, 179.0, 1)),
|
||||
@@ -729,7 +729,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.stylize.ccthreshold",
|
||||
"CC Threshold",
|
||||
"Channel Threshold",
|
||||
vec![
|
||||
p("threshold", "Threshold", num(127.0), thr()),
|
||||
p("channel", "Channel", Value::Enum(0), popup(&["Luminance", "RGB", "Saturation", "Alpha"])),
|
||||
@@ -740,7 +740,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.stylize.ccthresholdrgb",
|
||||
"CC Threshold RGB",
|
||||
"RGB Threshold",
|
||||
vec![
|
||||
p("redThreshold", "Red Threshold", num(127.0), thr()),
|
||||
p("greenThreshold", "Green Threshold", num(127.0), thr()),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Stylize effects, batch 3: CC Block Load, CC Burn Film, CC Glass, CC HexTile, CC Mr. Smoothie
|
||||
//! and CC Plastic.
|
||||
//! Stylize effects, batch 3: Block Reveal, Film Burn, Glass Relief, Hexagonal Tiles, Palette Map
|
||||
//! and Plastic Shading.
|
||||
//!
|
||||
//! Written from the public descriptions of what these effects do (progressive block loading,
|
||||
//! burning film, bump-mapped glass / plastic surfaces lit by an effect light, hexagonal tiling,
|
||||
@@ -27,7 +27,7 @@ fn pt(x: f64, y: f64) -> Value {
|
||||
Value::Vec2([x, y])
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ CC Block Load
|
||||
// ------------------------------------------------------------------ Block Reveal
|
||||
|
||||
/// Mean colour of every `s`×`s` block (grid of `ceil(w/s)`×`ceil(h/s)`).
|
||||
fn block_means(img: &Image, s: usize) -> (usize, usize, Vec<Px>) {
|
||||
@@ -135,7 +135,7 @@ fn block_load(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
b
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ CC Burn Film
|
||||
// ------------------------------------------------------------------ Film Burn
|
||||
|
||||
fn burn_film(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let burn = (ctx.params.f("burn") / 100.0).clamp(0.0, 1.0) as f32;
|
||||
@@ -180,7 +180,7 @@ fn burn_film(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
|
||||
const BUMP_PROPS: [&str; 6] = ["Red", "Green", "Blue", "Alpha", "Luminance", "Lightness"];
|
||||
|
||||
/// The bump-map layer parameter: CC Glass's Bump Map, CC Plastic's Bump Layer.
|
||||
/// The bump-map layer parameter: Glass Relief's Bump Map, Plastic Shading's Bump Layer.
|
||||
pub fn bump_layer_id(ctx: &EffectCtx) -> &'static str {
|
||||
if ctx.params.get("bumpLayer").is_some() { "bumpLayer" } else { "bumpMap" }
|
||||
}
|
||||
@@ -208,7 +208,7 @@ fn height_field(ctx: &EffectCtx, b: &Buf) -> Plane {
|
||||
pl
|
||||
}
|
||||
|
||||
/// The effect light and surface of CC Glass / CC Plastic (shared with the GPU compositor).
|
||||
/// The effect light and surface of Glass Relief / Plastic Shading (shared with the GPU compositor).
|
||||
pub struct BumpLight {
|
||||
pub intensity: f32,
|
||||
pub color: [f32; 3],
|
||||
@@ -342,7 +342,7 @@ fn plastic(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
b
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ CC HexTile
|
||||
// ------------------------------------------------------------------ Hexagonal Tiles
|
||||
|
||||
/// Axial hex cell (pointy-top) containing (x, y) for circumradius `r`; returns the cell centre
|
||||
/// and integer cell coordinates.
|
||||
@@ -418,7 +418,7 @@ fn hextile(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
b
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ CC Mr. Smoothie
|
||||
// ------------------------------------------------------------------ Palette Map
|
||||
|
||||
const SMOOTHIE_PROPS: [&str; 8] = ["Red", "Green", "Blue", "Alpha", "Luminance", "Lightness", "Hue", "Saturation"];
|
||||
|
||||
@@ -518,7 +518,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
vec![
|
||||
spec(
|
||||
"ec.stylize.ccblockload",
|
||||
"CC Block Load",
|
||||
"Block Reveal",
|
||||
vec![
|
||||
p("completion", "Completion", num(0.0), pct()),
|
||||
p("scanlines", "Scanlines", Value::Bool(true), ParamUi::Checkbox),
|
||||
@@ -530,7 +530,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.stylize.ccburnfilm",
|
||||
"CC Burn Film",
|
||||
"Film Burn",
|
||||
vec![
|
||||
p("burn", "Burn", num(0.0), pct()),
|
||||
p("center", "Center", pt(0.5, 0.5), ParamUi::Point),
|
||||
@@ -538,10 +538,10 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
],
|
||||
burn_film,
|
||||
),
|
||||
spec("ec.stylize.ccglass", "CC Glass", glass_p, glass),
|
||||
spec("ec.stylize.ccglass", "Glass Relief", glass_p, glass),
|
||||
spec(
|
||||
"ec.stylize.cchextile",
|
||||
"CC HexTile",
|
||||
"Hexagonal Tiles",
|
||||
vec![
|
||||
p("render", "Render", Value::Enum(0), popup(&["Normal", "Mirrored", "Rotated"])),
|
||||
p("radius", "Radius", num(50.0), slider(1.0, 4000.0, 2.0, 500.0, 1)),
|
||||
@@ -553,7 +553,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.stylize.ccmrsmoothie",
|
||||
"CC Mr. Smoothie",
|
||||
"Palette Map",
|
||||
vec![
|
||||
p("flowLayer", "Flow Layer", Value::Layer(None), ParamUi::Layer),
|
||||
p("property", "Property", Value::Enum(4), popup(&SMOOTHIE_PROPS)),
|
||||
@@ -565,7 +565,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
],
|
||||
smoothie,
|
||||
),
|
||||
spec("ec.stylize.ccplastic", "CC Plastic", plastic_p, plastic),
|
||||
spec("ec.stylize.ccplastic", "Plastic Shading", plastic_p, plastic),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
+194
-25
@@ -1,11 +1,13 @@
|
||||
//! Time effects (Effect > Time): Echo, Posterize Time, Time Difference, Time Displacement,
|
||||
//! Timewarp, CC Force Motion Blur, CC Wide Time and Pixel Motion Blur.
|
||||
//! Timewarp, Temporal Motion Blur, Temporal Blend and Pixel Motion Blur.
|
||||
//!
|
||||
//! These read the layer at *other* layer times through [`EffectHost::self_at`]: its source with
|
||||
//! its masks and the effects applied before them, as After Effects renders them (SceneYard:
|
||||
//! Echo after CC Particle Systems II echoes the fireworks, not the black solid under them;
|
||||
//! Posterize Time after Turbulent Displace steps the displacement). Without a host (tests,
|
||||
//! thumbnails) they pass their input through.
|
||||
//! Echo after Particle Emitter 2D echoes the fireworks, not the black solid under them;
|
||||
//! Posterize Time after Turbulent Displace steps the displacement). Timewarp is the exception:
|
||||
//! it retimes the layer's source with its masks and ignores the effects above it (After
|
||||
//! Effects 2026: a red solid with a Gradient Ramp and then Timewarp shows the red solid).
|
||||
//! Without a host (tests, thumbnails) they pass their input through.
|
||||
//!
|
||||
//! Timewarp's Pixel Motion estimates block-matching optical flow between neighbouring frames
|
||||
//! ([`effectcraft_raster::flow`]); Pixel Motion Blur averages sub-frame samples.
|
||||
@@ -121,7 +123,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.time.ccforcemotionblur",
|
||||
"CC Force Motion Blur",
|
||||
"Temporal Motion Blur",
|
||||
vec![
|
||||
p("motionBlurLevels", "Motion Blur Levels", num(8.0), slider(2.0, 64.0, 2.0, 32.0, 0)),
|
||||
p("overrideShutterAngle", "Override Shutter Angle", Value::Bool(true), ParamUi::Checkbox),
|
||||
@@ -132,7 +134,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.time.ccwidetime",
|
||||
"CC Wide Time",
|
||||
"Temporal Blend",
|
||||
vec![
|
||||
p("forwardSteps", "Forward Steps", num(3.0), slider(0.0, 45.0, 0.0, 15.0, 0)),
|
||||
p("backwardSteps", "Backward Steps", num(3.0), slider(0.0, 45.0, 0.0, 15.0, 0)),
|
||||
@@ -163,15 +165,21 @@ struct Frames {
|
||||
imgs: Vec<Image>,
|
||||
}
|
||||
|
||||
/// Fetch the layer (source + masks) at layer times `times`. `None` without a host.
|
||||
/// Fetch the layer (source + masks + the effects before this one) at layer times `times`.
|
||||
/// `None` without a host.
|
||||
fn fetch(ctx: &EffectCtx, times: &[f64]) -> Option<Frames> {
|
||||
fetch_with(ctx, times, usize::MAX)
|
||||
}
|
||||
|
||||
/// [`fetch`] with the first `effects` effects of the stack only (0: source and masks).
|
||||
fn fetch_with(ctx: &EffectCtx, times: &[f64], effects: usize) -> Option<Frames> {
|
||||
let host = ctx.env.host?;
|
||||
// Dedupe times (sub-microsecond differences are the same frame).
|
||||
let mut uniq: Vec<i64> = times.iter().map(|t| (t * 1e6).round() as i64).collect();
|
||||
uniq.sort_unstable();
|
||||
uniq.dedup();
|
||||
// Every effect before this one (the host bounds the count to them).
|
||||
let bufs: Vec<(i64, Option<Buf>)> = uniq.iter().map(|&k| (k, host.self_at(k as f64 / 1e6, usize::MAX))).collect();
|
||||
let bufs: Vec<(i64, Option<Buf>)> = uniq.iter().map(|&k| (k, host.self_at(k as f64 / 1e6, effects))).collect();
|
||||
let first = bufs.iter().find_map(|(_, b)| b.as_ref())?;
|
||||
let scale = first.scale;
|
||||
// Union of every frame's extent in layer pixels (offset = where layer (0,0) sits).
|
||||
@@ -261,6 +269,9 @@ fn mean_of(ctx: &EffectCtx, b: Buf, times: &[f64]) -> Buf {
|
||||
|
||||
// ---------------------------------------------------------------- Echo
|
||||
|
||||
/// Echo: the layer at the current time and `numberOfEchoes` earlier (or later) times
|
||||
/// `echoTime` apart, each weighted Starting Intensity × Decay^i and combined by the Echo Operator
|
||||
/// (Add, Maximum, Minimum, Screen, Composite In Back, Composite In Front, Blend).
|
||||
fn echo(ctx: &EffectCtx, b: Buf) -> Buf {
|
||||
let n = ctx.params.f("numberOfEchoes").round().clamp(0.0, 255.0) as usize;
|
||||
let dt = ctx.params.f("echoTime");
|
||||
@@ -270,7 +281,6 @@ fn echo(ctx: &EffectCtx, b: Buf) -> Buf {
|
||||
let times: Vec<f64> = (0..=n).map(|i| ctx.time + i as f64 * dt).collect();
|
||||
let Some(fr) = fetch(ctx, ×) else { return b };
|
||||
let weights: Vec<f32> = (0..=n).map(|i| start * decay.powi(i as i32)).collect();
|
||||
let count = (n + 1) as f32;
|
||||
let w = fr.grid.img.width;
|
||||
let h = fr.grid.img.height;
|
||||
let mut out = Image::new(w, h);
|
||||
@@ -289,23 +299,26 @@ fn echo(ctx: &EffectCtx, b: Buf) -> Buf {
|
||||
2 => (0..4).for_each(|c| acc[c] = acc[c].min(q[c])),
|
||||
// Screen
|
||||
3 => (0..4).for_each(|c| acc[c] = acc[c] + q[c] - acc[c] * q[c]),
|
||||
// Composite In Back: each later echo goes behind what is there.
|
||||
// Composite In Back: the layer goes in back, each echo is composited over what
|
||||
// is there, the oldest on top, with its weight as opacity (After Effects 2026,
|
||||
// opaque layer, one and three echoes). Whether the weight scales the alpha of a
|
||||
// transparent layer's echo or fades it is not measured.
|
||||
4 => {
|
||||
let ia = 1.0 - acc[3];
|
||||
(0..4).for_each(|c| acc[c] += q[c] * ia);
|
||||
}
|
||||
// Composite In Front: each later echo goes on top.
|
||||
5 => {
|
||||
let ia = 1.0 - q[3];
|
||||
(0..4).for_each(|c| acc[c] = q[c] + acc[c] * ia);
|
||||
}
|
||||
// Blend (average) and Add
|
||||
// Composite In Front: the layer stays in front, each echo goes behind what is
|
||||
// there (After Effects 2026: an opaque layer hides its echo).
|
||||
5 => {
|
||||
let ia = 1.0 - acc[3];
|
||||
(0..4).for_each(|c| acc[c] += q[c] * ia);
|
||||
}
|
||||
// Add, and Blend: After Effects 2026 sums the weighted frames for Blend as well,
|
||||
// with no normalisation (3 echoes at intensity 1 give 4x the layer; intensity
|
||||
// 0.5, decay 0.5 and 2 echoes give alpha 0.875).
|
||||
_ => (0..4).for_each(|c| acc[c] += q[c]),
|
||||
}
|
||||
}
|
||||
if op == 6 {
|
||||
acc.iter_mut().for_each(|c| *c /= count);
|
||||
}
|
||||
if op == 2 && acc[0] == f32::MAX {
|
||||
acc = [0.0; 4];
|
||||
}
|
||||
@@ -851,7 +864,8 @@ pub fn timewarp_plan(ctx: &EffectCtx) -> Option<TimewarpPlan> {
|
||||
let (instants, times, fps) = timewarp_instants(ctx);
|
||||
let method = ctx.params.e("method");
|
||||
let one = method > 0 && ctx.params.b("tuning/buildFromOneImage");
|
||||
let fr = fetch(ctx, ×)?;
|
||||
// The source with its masks, without the effects above Timewarp.
|
||||
let fr = fetch_with(ctx, ×, 0)?;
|
||||
let pm = PixelMotion::from(ctx, fr.grid.scale);
|
||||
let mattes = fetch_layer(ctx, "matteLayer", ×, &fr.grid);
|
||||
let warps = fetch_layer(ctx, "warpLayer", ×, &fr.grid);
|
||||
@@ -980,7 +994,7 @@ fn matte_image(m: &[f32], like: &Image) -> Image {
|
||||
out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Force Motion Blur / CC Wide Time / Pixel Motion Blur
|
||||
// ---------------------------------------------------------------- Temporal Motion Blur / Temporal Blend / Pixel Motion Blur
|
||||
|
||||
fn force_motion_blur(ctx: &EffectCtx, b: Buf) -> Buf {
|
||||
let n = ctx.params.f("motionBlurLevels").round().clamp(2.0, 64.0) as usize;
|
||||
@@ -1082,8 +1096,8 @@ mod tests {
|
||||
None
|
||||
}
|
||||
fn self_at(&self, t: f64, effects: usize) -> Option<Buf> {
|
||||
// Time effects see every effect before them.
|
||||
assert_eq!(effects, usize::MAX);
|
||||
// Time effects see every effect before them; Timewarp only the source and masks.
|
||||
assert!(effects == usize::MAX || effects == 0, "{effects}");
|
||||
let v = if self.static_layer { 0.4 } else { t as f32 };
|
||||
Some(Buf { img: Image::filled(6, 4, [v, v, v, 1.0]), offset: [0.0; 2], scale: 1.0 })
|
||||
}
|
||||
@@ -1094,6 +1108,32 @@ mod tests {
|
||||
run_fx(id, vals, Image::filled(6, 4, [0.9, 0.0, 0.0, 1.0]), t, env)
|
||||
}
|
||||
|
||||
/// After Effects 2026: Timewarp retimes the layer's source (with masks) and ignores the
|
||||
/// effects above it; Echo keeps them.
|
||||
#[test]
|
||||
fn timewarp_reads_the_source_without_the_effects_above_it() {
|
||||
struct Asked(std::sync::Mutex<Vec<usize>>);
|
||||
impl EffectHost for Asked {
|
||||
fn layer(&self, _: u64, _: bool) -> Option<LayerPixels> {
|
||||
None
|
||||
}
|
||||
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
|
||||
None
|
||||
}
|
||||
fn self_at(&self, _: f64, effects: usize) -> Option<Buf> {
|
||||
self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(effects);
|
||||
// The source (effects 0) is red, the layer with its effects green.
|
||||
let c = if effects == 0 { [1.0, 0.0, 0.0, 1.0] } else { [0.0, 1.0, 0.0, 1.0] };
|
||||
Some(Buf { img: Image::filled(6, 4, c), offset: [0.0; 2], scale: 1.0 })
|
||||
}
|
||||
}
|
||||
let host = Asked(std::sync::Mutex::new(vec![]));
|
||||
let env = EffectEnv { host: Some(&host), frame_rate: 10.0, comp_time: 1.0, ..Default::default() };
|
||||
let out = run_fx("ec.time.timewarp", &[("speed", num(25.0))], Image::filled(6, 4, [0.0, 1.0, 0.0, 1.0]), 1.0, env);
|
||||
assert_eq!(out.img.get(2, 2), [1.0, 0.0, 0.0, 1.0]);
|
||||
assert!(host.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner).iter().all(|&e| e == 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn posterize_time_holds_frames() {
|
||||
let h = Clock { static_layer: false };
|
||||
@@ -1112,7 +1152,7 @@ mod tests {
|
||||
let want = 0.8 + 0.5 * 0.7 + 0.25 * 0.6;
|
||||
assert!((out.img.data[0][0] - want as f32).abs() < 1e-4, "{}", out.img.data[0][0]);
|
||||
assert_eq!(out.img.data[0][3], 1.0);
|
||||
// Maximum picks the brightest weighted frame; Blend averages.
|
||||
// Maximum picks the brightest weighted frame; Blend sums like Add (#3124).
|
||||
let mut v = vals.to_vec();
|
||||
v.push(("echoOperator", Value::Enum(1)));
|
||||
let out = run("ec.time.echo", &v, 0.8, &h);
|
||||
@@ -1120,7 +1160,136 @@ mod tests {
|
||||
v.pop();
|
||||
v.push(("echoOperator", Value::Enum(6)));
|
||||
let out = run("ec.time.echo", &v, 0.8, &h);
|
||||
assert!((out.img.data[0][0] - want as f32 / 3.0).abs() < 1e-4);
|
||||
assert!((out.img.data[0][0] - want as f32).abs() < 1e-4, "{}", out.img.data[0][0]);
|
||||
}
|
||||
|
||||
/// Row y = 130, x = 60..111 of the importer sample's SRC test card (#3124): a black-to-white
|
||||
/// ramp across 320 px under an opaque white 30 px square whose centre moves from x = 20 at
|
||||
/// 0 s to x = 300 at 2 s.
|
||||
struct MovingSquare;
|
||||
impl EffectHost for MovingSquare {
|
||||
fn layer(&self, _: u64, _: bool) -> Option<LayerPixels> {
|
||||
None
|
||||
}
|
||||
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
|
||||
None
|
||||
}
|
||||
fn self_at(&self, t: f64, _: usize) -> Option<Buf> {
|
||||
let centre = 20.0 + 140.0 * t;
|
||||
let (s0, s1) = (centre - 15.0, centre + 15.0);
|
||||
let mut img = Image::new(52, 1);
|
||||
for (i, px) in img.data.iter_mut().enumerate() {
|
||||
let x = 60.0 + i as f64;
|
||||
let cover = ((x + 1.0).min(s1) - x.max(s0)).clamp(0.0, 1.0) as f32;
|
||||
let ramp = ((x + 0.5) / 320.0) as f32;
|
||||
let v = cover + (1.0 - cover) * ramp;
|
||||
*px = [v, v, v, 1.0];
|
||||
}
|
||||
Some(Buf { img, offset: [0.0; 2], scale: 1.0 })
|
||||
}
|
||||
}
|
||||
|
||||
/// After Effects 2026 renders of that row at 0.5 s, Echo Time −1/30 s, one echo, Starting
|
||||
/// Intensity 1, Decay 1 (renders/echo-00 ... echo-10 of the 2026-11-05 importer sample, 32 bpc,
|
||||
/// saved as 8-bit PNGs that hold floor(value × 25.5)).
|
||||
#[rustfmt::skip]
|
||||
const AE_ECHO_ROW: [(u32, &str, [u8; 52]); 6] = [
|
||||
(0, "Add", [9, 9, 9, 10, 10, 10, 10, 10, 10, 11, 24, 31, 31, 31, 31, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 39, 33, 33, 33, 33, 16, 16, 17, 17, 17, 17, 17]),
|
||||
(1, "Maximum", [4, 4, 4, 5, 5, 5, 5, 5, 5, 5, 18, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 8, 8, 8, 8, 8, 8, 8]),
|
||||
(2, "Minimum", [4, 4, 4, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 13, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8]),
|
||||
(3, "Screen", [8, 8, 8, 9, 9, 9, 9, 9, 9, 9, 20, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 14, 14, 14, 14, 14, 14, 14]),
|
||||
(4, "Composite In Back", [4, 4, 4, 5, 5, 5, 5, 5, 5, 5, 18, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 13, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8]),
|
||||
(5, "Composite In Front", [4, 4, 4, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 8, 8, 8, 8, 8, 8, 8]),
|
||||
];
|
||||
|
||||
/// #3124: every operator but Blend against the After Effects row. Composite In Back puts
|
||||
/// the echo over the layer (the trailing echo hides the square's leading edge), In Front
|
||||
/// keeps the layer on top (the output is the current frame).
|
||||
#[test]
|
||||
fn echo_operators_match_after_effects_at_the_moving_edge() {
|
||||
let h = MovingSquare;
|
||||
for (op, name, ae) in AE_ECHO_ROW {
|
||||
let vals = [("echoTime", num(-1.0 / 30.0)), ("numberOfEchoes", num(1.0)), ("echoOperator", Value::Enum(op))];
|
||||
let env = EffectEnv { host: Some(&h), frame_rate: 25.0, comp_time: 0.5, ..Default::default() };
|
||||
let out = run_fx("ec.time.echo", &vals, Image::new(52, 1), 0.5, env);
|
||||
for (x, want) in ae.iter().enumerate() {
|
||||
let got = (out.img.data[x][0] * 25.5).floor();
|
||||
assert!((got - f32::from(*want)).abs() <= 1.0, "{name}: x = {} got {got}, After Effects {want}", 60 + x);
|
||||
assert_eq!(out.img.data[x][3], 1.0, "{name}: alpha at x = {}", 60 + x);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Row y = 130, x = 36..111 of the same test card as read back from 16-bit renders: there the
|
||||
/// ramp is x / 320 at the pixel's left edge (the 8-bit row above cannot tell that apart).
|
||||
struct MovingSquareRow16;
|
||||
impl EffectHost for MovingSquareRow16 {
|
||||
fn layer(&self, _: u64, _: bool) -> Option<LayerPixels> {
|
||||
None
|
||||
}
|
||||
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
|
||||
None
|
||||
}
|
||||
fn self_at(&self, t: f64, _: usize) -> Option<Buf> {
|
||||
let centre = 20.0 + 140.0 * t;
|
||||
let (s0, s1) = (centre - 15.0, centre + 15.0);
|
||||
let mut img = Image::new(76, 1);
|
||||
for (i, px) in img.data.iter_mut().enumerate() {
|
||||
let x = 36.0 + i as f64;
|
||||
let cover = ((x + 1.0).min(s1) - x.max(s0)).clamp(0.0, 1.0);
|
||||
let v = (cover + (1.0 - cover) * x / 320.0) as f32;
|
||||
*px = [v, v, v, 1.0];
|
||||
}
|
||||
Some(Buf { img, offset: [0.0; 2], scale: 1.0 })
|
||||
}
|
||||
}
|
||||
|
||||
/// After Effects 2026 renders of that row at 0.5 s (renders/echo-13 ... echo-16 of the
|
||||
/// 2026-11-05 importer sample, group 1110-extra, 32 bpc, 16-bit PNGs holding value × 6553.5):
|
||||
/// operator, Echo Time, Number Of Echoes, Starting Intensity, Decay, alpha code, row codes.
|
||||
#[rustfmt::skip]
|
||||
const AE_ECHO_ROW16: [(u32, f64, f64, f64, f64, u16, [u16; 76]); 4] = [
|
||||
(6, -1.0 / 30.0, 3.0, 1.0, 1.0, 65535, [2949, 3031, 3111, 3193, 3275, 3357, 3439, 3521, 3603, 3685, 3767, 3849, 3931, 4013, 4095, 4177, 4259, 4341, 4423, 4505, 4587, 4669, 4751, 4833, 4915, 10301, 10361, 10423, 10485, 12293, 15809, 15851, 15891, 15933, 19369, 21113, 21135, 21155, 21175, 26205, 26213, 26213, 26213, 26213, 26213, 26213, 26213, 26213, 26213, 26213, 26213, 26213, 26213, 26213, 26213, 21523, 21543, 21565, 21585, 20063, 17039, 17079, 17121, 17161, 14215, 12759, 12819, 12881, 12943, 8607, 8683, 8765, 8847, 8929, 9011, 9093]),
|
||||
(6, -1.0 / 30.0, 2.0, 0.5, 0.5, 57343, [643, 661, 679, 697, 715, 733, 751, 769, 787, 805, 823, 839, 859, 877, 895, 911, 929, 949, 965, 983, 1001, 1021, 1037, 1055, 1073, 1091, 1109, 1127, 1145, 1381, 1831, 1847, 1861, 1877, 2743, 3183, 3193, 3203, 3213, 5727, 5733, 5733, 5733, 5733, 5733, 5733, 5733, 5733, 5733, 5733, 5733, 5733, 5733, 5733, 5733, 5733, 5733, 5733, 5733, 5541, 5159, 5163, 5165, 5167, 4423, 4051, 4059, 4065, 4073, 1883, 1897, 1917, 1933, 1951, 1969, 1987]),
|
||||
(4, -0.08, 3.0, 1.0, 0.5, 65535, [737, 757, 777, 797, 819, 1267, 1571, 1589, 1607, 1625, 1643, 1661, 1679, 1697, 1715, 1733, 2229, 2965, 2977, 2991, 3005, 3017, 3031, 3045, 3059, 3071, 3085, 3443, 4833, 4839, 4845, 4853, 4859, 4865, 4873, 4497, 4251, 4261, 4269, 5923, 5927, 5931, 5933, 5935, 5939, 5941, 5517, 4885, 4891, 4899, 4905, 4913, 4919, 4927, 4933, 4941, 4947, 4649, 3443, 3457, 3471, 3485, 3497, 3511, 3525, 3539, 3553, 3567, 3581, 2151, 2169, 2191, 2211, 2231, 2251, 2273]),
|
||||
(5, -0.08, 3.0, 1.0, 0.5, 65535, [737, 757, 777, 797, 819, 839, 859, 879, 901, 921, 941, 961, 983, 1003, 1023, 1043, 1063, 1085, 1105, 1125, 1145, 1167, 1187, 1207, 1227, 1249, 1269, 1289, 1309, 1331, 1351, 1371, 1391, 1413, 1433, 1453, 1473, 1495, 1515, 6545, 6553, 6553, 6553, 6553, 6553, 6553, 6553, 6553, 6553, 6553, 6553, 6553, 6553, 6553, 6553, 6553, 6553, 6553, 6553, 6553, 6553, 6553, 6553, 6553, 6553, 6553, 6553, 6553, 6553, 2157, 2169, 2191, 2211, 2231, 2251, 2273]),
|
||||
];
|
||||
|
||||
/// #3124: Blend sums the weighted frames like Add (no normalisation, Starting Intensity weights
|
||||
/// the current frame too), and with three echoes Composite In Back stacks the oldest echo on
|
||||
/// top while In Front leaves the opaque current frame alone. The alpha is checked as measured.
|
||||
/// The RGB comparison of the half-transparent Blend row reads the PNG as premultiplied colour
|
||||
/// (`saveFrameToPng` does not document which it writes); the opaque rows do not depend on it.
|
||||
#[test]
|
||||
fn echo_blend_and_three_echo_composites_match_after_effects() {
|
||||
let h = MovingSquareRow16;
|
||||
for (op, dt, n, start, decay, alpha, ae) in AE_ECHO_ROW16 {
|
||||
let vals = [
|
||||
("echoTime", num(dt)),
|
||||
("numberOfEchoes", num(n)),
|
||||
("startingIntensity", num(start)),
|
||||
("decay", num(decay)),
|
||||
("echoOperator", Value::Enum(op)),
|
||||
];
|
||||
let env = EffectEnv { host: Some(&h), frame_rate: 25.0, comp_time: 0.5, ..Default::default() };
|
||||
let out = run_fx("ec.time.echo", &vals, Image::new(76, 1), 0.5, env);
|
||||
// Pixels a square edge passes through: After Effects anti-aliases the card's square a
|
||||
// little differently from the box coverage above, so they get 24 codes (0.4 %).
|
||||
let edges: Vec<f64> = (0..=n as usize)
|
||||
.flat_map(|i| {
|
||||
let c = 20.0 + 140.0 * (0.5 + i as f64 * dt);
|
||||
[(c - 15.0).floor(), (c + 15.0).floor()]
|
||||
})
|
||||
.collect();
|
||||
for (x, want) in ae.iter().enumerate() {
|
||||
let px = out.img.data[x];
|
||||
let got = px[0] * 6553.5;
|
||||
let tol = if edges.contains(&(36.0 + x as f64)) { 24.0 } else { 4.0 };
|
||||
assert!((got - f32::from(*want)).abs() <= tol, "op {op}, n {n}: x = {} got {got}, After Effects {want}", 36 + x);
|
||||
let a = px[3] * 65535.0;
|
||||
assert!((a - f32::from(alpha)).abs() <= 2.0, "op {op}, n {n}: alpha at x = {} got {a}, After Effects {alpha}", 36 + x);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Transition effects (batch 2): Block Dissolve, Gradient Wipe (gradient layer or self), Iris Wipe, Card
|
||||
//! Wipe (3D cards with camera, lighting and jitter), CC Grid Wipe, CC Radial ScaleWipe, CC Scale Wipe and CC Light Wipe.
|
||||
//! Wipe (3D cards with camera, lighting and jitter), Grid Wipe, Radial Scale Wipe, Scale Wipe and Light Wipe.
|
||||
//!
|
||||
//! Every transition is the identity at 0 % completion. Angles are clockwise from "up".
|
||||
|
||||
@@ -595,7 +595,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.transition.ccgridwipe",
|
||||
"CC Grid Wipe",
|
||||
"Grid Wipe",
|
||||
vec![
|
||||
cc_done(),
|
||||
p("center", "Center", pt(0.5, 0.5), ParamUi::Point),
|
||||
@@ -609,13 +609,13 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.transition.ccradialscalewipe",
|
||||
"CC Radial ScaleWipe",
|
||||
"Radial Scale Wipe",
|
||||
vec![cc_done(), p("center", "Center", pt(0.5, 0.5), ParamUi::Point), p("reverse", "Reverse Transition", Value::Bool(false), ParamUi::Checkbox)],
|
||||
radial_scale_wipe,
|
||||
),
|
||||
spec(
|
||||
"ec.transition.ccscalewipe",
|
||||
"CC Scale Wipe",
|
||||
"Scale Wipe",
|
||||
vec![
|
||||
p("stretch", "Stretch", num(0.0), slider(0.0, 1000.0, 0.0, 50.0, 2)),
|
||||
p("center", "Center", pt(0.5, 0.5), ParamUi::Point),
|
||||
@@ -625,7 +625,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.transition.cclightwipe",
|
||||
"CC Light Wipe",
|
||||
"Light Wipe",
|
||||
vec![
|
||||
cc_done(),
|
||||
p("center", "Center", pt(0.5, 0.5), ParamUi::Point),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Transition effects, batch 2: CC Glass Wipe, CC Image Wipe, CC Jaws, CC Line Sweep,
|
||||
//! CC Twister and CC WarpoMatic.
|
||||
//! Transition effects, batch 2: Glass Wipe, Luminance Image Wipe, Serrated Wipe, Line Sweep,
|
||||
//! Twist Wipe and Warp Dissolve.
|
||||
//!
|
||||
//! Written from the public descriptions of these transitions (gradient-driven wipes with glass
|
||||
//! refraction or warping, jagged "jaws" opening, staggered line sweeps, a ribbon twist that
|
||||
@@ -68,7 +68,7 @@ fn grad(pl: &Plane, x: usize, y: usize) -> (f32, f32) {
|
||||
((pl.get_clamped(xi + 1, yi) - pl.get_clamped(xi - 1, yi)) * 0.5, (pl.get_clamped(xi, yi + 1) - pl.get_clamped(xi, yi - 1)) * 0.5)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ CC Glass Wipe
|
||||
// ------------------------------------------------------------------ Glass Wipe
|
||||
|
||||
fn glass_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let c = completion(ctx);
|
||||
@@ -105,7 +105,7 @@ fn glass_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
b
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ CC Image Wipe
|
||||
// ------------------------------------------------------------------ Luminance Image Wipe
|
||||
|
||||
fn image_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let c = completion(ctx);
|
||||
@@ -132,7 +132,7 @@ fn image_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
b
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ CC Jaws
|
||||
// ------------------------------------------------------------------ Serrated Wipe
|
||||
|
||||
fn jaws(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let c = completion(ctx) as f64;
|
||||
@@ -193,7 +193,7 @@ fn jaws(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
b
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ CC Line Sweep
|
||||
// ------------------------------------------------------------------ Line Sweep
|
||||
|
||||
fn line_sweep(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let c = completion(ctx) as f64;
|
||||
@@ -235,7 +235,7 @@ fn line_sweep(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
b
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ CC Twister
|
||||
// ------------------------------------------------------------------ Twist Wipe
|
||||
|
||||
fn twister(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let c = completion(ctx) as f64;
|
||||
@@ -283,7 +283,7 @@ fn twister(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
b
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ CC WarpoMatic
|
||||
// ------------------------------------------------------------------ Warp Dissolve
|
||||
|
||||
fn warpomatic(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let c = completion(ctx);
|
||||
@@ -350,7 +350,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
vec![
|
||||
spec(
|
||||
"ec.transition.ccglasswipe",
|
||||
"CC Glass Wipe",
|
||||
"Glass Wipe",
|
||||
vec![
|
||||
done(),
|
||||
p("layerToReveal", "Layer to Reveal", Value::Layer(None), ParamUi::Layer),
|
||||
@@ -362,7 +362,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.transition.ccimagewipe",
|
||||
"CC Image Wipe",
|
||||
"Luminance Image Wipe",
|
||||
vec![
|
||||
done(),
|
||||
p("borderSoftness", "Border Softness", num(0.0), pct()),
|
||||
@@ -376,7 +376,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.transition.ccjaws",
|
||||
"CC Jaws",
|
||||
"Serrated Wipe",
|
||||
vec![
|
||||
done(),
|
||||
p("center", "Center", pt(0.5, 0.5), ParamUi::Point),
|
||||
@@ -389,7 +389,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.transition.cclinesweep",
|
||||
"CC Line Sweep",
|
||||
"Line Sweep",
|
||||
vec![
|
||||
done(),
|
||||
p("direction", "Direction", num(0.0), ParamUi::Angle),
|
||||
@@ -401,7 +401,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.transition.cctwister",
|
||||
"CC Twister",
|
||||
"Twist Wipe",
|
||||
vec![
|
||||
done(),
|
||||
p("backside", "Backside", Value::Layer(None), ParamUi::Layer),
|
||||
@@ -413,7 +413,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.transition.ccwarpomatic",
|
||||
"CC WarpoMatic",
|
||||
"Warp Dissolve",
|
||||
vec![
|
||||
done(),
|
||||
p("layerToReveal", "Layer to Reveal", Value::Layer(None), ParamUi::Layer),
|
||||
|
||||
@@ -365,7 +365,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
spec("ec.utility.growbounds", "Grow Bounds", vec![p("pixels", "Pixels", num(0.0), slider(0.0, 4000.0, 0.0, 500.0, 0))], grow_bounds),
|
||||
spec(
|
||||
"ec.utility.ccoverbrights",
|
||||
"CC Overbrights",
|
||||
"Out-of-Range Display",
|
||||
vec![
|
||||
p("channel", "Channel", Value::Enum(0), popup(&["Max RGB", "Red", "Green", "Blue", "Alpha", "Luminance"])),
|
||||
p("highlightColor", "Highlight Color", col(1.0, 0.0, 0.0), ParamUi::Color),
|
||||
|
||||
@@ -208,7 +208,7 @@ pub(crate) fn source_frame(
|
||||
t: Tick,
|
||||
expr: Option<&dyn ExprHost>,
|
||||
) -> Option<(Arc<Image>, [f64; 2])> {
|
||||
let ctx = EvalCtx { project, comp_id: cid, comp, time: t, expr, footage: None, property_overrides: &[], media_overrides: &[] };
|
||||
let ctx = EvalCtx { project, comp_id: cid, comp, time: t, expr, footage: None, property_overrides: &[], media_overrides: &[], reads: None };
|
||||
if let effectcraft_project::LayerSource::Footage { item } = &layer.source
|
||||
&& let Some(effectcraft_project::ItemKind::Footage(f)) = project.item(*item).map(|i| &i.kind)
|
||||
&& f.has_video
|
||||
|
||||
@@ -375,7 +375,7 @@ pub(crate) fn job_accel() -> Option<Arc<dyn Accelerator>> {
|
||||
}
|
||||
|
||||
/// Render options of an analysis's input frames: 100 %; with an accelerator, the project's
|
||||
/// renderer (Mercury GPU Acceleration: its GPU effects on the device; Software Only: the CPU).
|
||||
/// renderer (GPU Acceleration: its GPU effects on the device; Software Only: the CPU).
|
||||
pub(crate) fn analysis_opts(accel: Option<&dyn Accelerator>) -> RenderOpts {
|
||||
RenderOpts { scale: 1.0, backend: if accel.is_some() { Backend::Auto } else { Backend::Cpu }, ..Default::default() }
|
||||
}
|
||||
|
||||
@@ -20,13 +20,16 @@
|
||||
//! calls [`FrameServer::resume`] for the next. Backend Auto picks CPU or GPU per comp from
|
||||
//! the frames' total times ([`effectcraft_render::AutoPick`]).
|
||||
//! - Disk cache: a render request may carry the frame's disk-cache key ([`FrameMsg::Render`]
|
||||
//! `disk`); the transport stores the finished frame under it (the browser: in the Origin
|
||||
//! Private File System) and reports [`FrameReply::Stored`].
|
||||
//! `disk`); the transport stores the finished frame under it with the source files it read
|
||||
//! ([`FrameServer::take_files`], `disk_cache::frame_entry`; the browser: in the Origin Private
|
||||
//! File System) and reports [`FrameReply::Stored`]. A frame whose files can't all be named is
|
||||
//! not stored. The page checks an entry's files before it uses it (#3184).
|
||||
//! - Layer buffers on disk: with `layers`, the server's layer cache is backed by a
|
||||
//! [`PrefetchStore`] ([`FrameServer::set_layer_store`]): before the request the transport
|
||||
//! fetches the entries named in `prefetch` (recent misses that are on disk, chosen by the
|
||||
//! page, `disk_cache::LayerPrefetch`), so the lookups in the middle of the render find them;
|
||||
//! the reply lists the layer keys that missed and those served from the store
|
||||
//! page, `disk_cache::LayerPrefetch`), so the lookups in the middle of the render find them
|
||||
//! (each entry keeps the source files its buffer was made of and is used only while they are
|
||||
//! unchanged, #3184); the reply lists the layer keys that missed and those served from the store
|
||||
//! ([`FrameReply::Frame`] `layer_misses` / `layer_hits`), and slow buffers queue up for the
|
||||
//! transport to write after the frame ([`PrefetchStore::take_writes`], reported as
|
||||
//! [`FrameReply::Stored`] with `layer`).
|
||||
@@ -37,6 +40,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_project::{ItemId, Project};
|
||||
use effectcraft_render::reads::{ReadFiles, ReadLog};
|
||||
use effectcraft_render::{Accelerator, AutoKey, Backend, ExprHost, FootageSource, Image, LayerCache, LayerStore, PrefetchStore, RenderOpts, Renderer};
|
||||
use effectcraft_time::Tick;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -177,7 +181,7 @@ pub enum FrameReply {
|
||||
passes: u32,
|
||||
#[serde(default)]
|
||||
gpu: bool,
|
||||
/// Layer-cache lookups that missed the store (salted keys, 32 hex digits).
|
||||
/// Layer-cache lookups that missed the store (disk keys, 32 hex digits).
|
||||
#[serde(default, rename = "layerMisses", skip_serializing_if = "Vec::is_empty")]
|
||||
layer_misses: Vec<String>,
|
||||
/// Layer buffers served from the store (fetched from disk).
|
||||
@@ -260,11 +264,12 @@ pub struct FrameServer {
|
||||
revision: Option<u64>,
|
||||
/// The frame being rendered in passes.
|
||||
pending: Option<Pending>,
|
||||
/// The disk cache's layer buffers (see the module docs) and the project's salt.
|
||||
/// The disk cache's layer buffers (see the module docs).
|
||||
layer_store: Option<Arc<PrefetchStore>>,
|
||||
salt: u64,
|
||||
/// Size and content hash for footage paths with no native metadata (the browser file table).
|
||||
virtual_id: Option<Arc<dyn Fn(&str) -> Option<(u64, u64)> + Send + Sync>>,
|
||||
/// The source files the last finished frame read ([`FrameServer::take_files`]).
|
||||
files: Option<ReadFiles>,
|
||||
}
|
||||
|
||||
/// A frame whose readbacks are in flight.
|
||||
@@ -284,6 +289,7 @@ struct Pending {
|
||||
pub const MAX_PASSES: u32 = 24;
|
||||
|
||||
impl FrameServer {
|
||||
/// A frame server over `footage` and the optional expression host, with an empty layer cache.
|
||||
pub fn new(footage: Arc<dyn FootageSource>, expr: Option<Arc<dyn ExprHost>>) -> FrameServer {
|
||||
FrameServer {
|
||||
footage,
|
||||
@@ -295,17 +301,30 @@ impl FrameServer {
|
||||
revision: None,
|
||||
pending: None,
|
||||
layer_store: None,
|
||||
salt: 0,
|
||||
virtual_id: None,
|
||||
files: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Identity of virtual footage files, used when native metadata cannot describe the path.
|
||||
/// Called from `FrameServer::load`, not from the UI thread's cache lookup.
|
||||
/// Identity of virtual footage files, used when native metadata cannot describe the path:
|
||||
/// what the disk entries of frames and layer buffers keep for them and are checked against.
|
||||
pub fn set_virtual_id(&mut self, id: Option<Arc<dyn Fn(&str) -> Option<(u64, u64)> + Send + Sync>>) {
|
||||
self.cache.set_virtual_id(id.clone());
|
||||
self.virtual_id = id;
|
||||
}
|
||||
|
||||
/// The source files the frame of the last [`FrameReply::Frame`] read, with their identities
|
||||
/// as read: what its disk entry keeps (`disk_cache::frame_entry`). Taken once.
|
||||
pub fn take_files(&mut self) -> Option<ReadFiles> {
|
||||
self.files.take()
|
||||
}
|
||||
|
||||
/// The identity of footage paths with no native metadata (the browser file table), to store
|
||||
/// with a frame's disk entry.
|
||||
pub fn virtual_id(&self) -> Option<&Arc<dyn Fn(&str) -> Option<(u64, u64)> + Send + Sync>> {
|
||||
self.virtual_id.as_ref()
|
||||
}
|
||||
|
||||
/// The store behind the layer cache when a request asks for `layers` (the browser: the
|
||||
/// disk cache's layer buffers, fetched before the request).
|
||||
pub fn set_layer_store(&mut self, store: Option<Arc<PrefetchStore>>) {
|
||||
@@ -316,11 +335,6 @@ impl FrameServer {
|
||||
self.layer_store.as_ref()
|
||||
}
|
||||
|
||||
/// The salt of the replica's layer keys on disk (keys are `(salt, key)`).
|
||||
pub fn salt(&self) -> u64 {
|
||||
self.salt
|
||||
}
|
||||
|
||||
/// Render with `accel` when the request's backend asks for it (the worker's GPU). Its miss
|
||||
/// gate keeps placeholder buffers of unfinished passes out of the layer cache.
|
||||
pub fn set_accel(&mut self, accel: Option<Arc<dyn Accelerator>>) {
|
||||
@@ -347,8 +361,6 @@ impl FrameServer {
|
||||
|
||||
fn load(&mut self, revision: u64) -> Result<(), String> {
|
||||
let p = Project::from_json(&self.doc.to_string()).map_err(|e| e.to_string())?;
|
||||
self.salt = effectcraft_render::disk_cache::footage_salt_with(&p, self.virtual_id.as_deref());
|
||||
self.cache.set_store_salt(self.salt);
|
||||
self.project = Some(Arc::new(p));
|
||||
self.revision = Some(revision);
|
||||
Ok(())
|
||||
@@ -392,7 +404,7 @@ impl FrameServer {
|
||||
}
|
||||
FrameMsg::Render { id, revision, comp, time, opts, layers, .. } => {
|
||||
let store = self.layer_store.clone().filter(|_| layers);
|
||||
self.cache.set_store(store.map(|s| (s as Arc<dyn LayerStore>, self.salt)));
|
||||
self.cache.set_store(store.map(|s| s as Arc<dyn LayerStore>));
|
||||
let fail = |error: String, stale: bool| vec![(FrameReply::Failed { id, error, stale }, None)];
|
||||
if self.revision != Some(revision) {
|
||||
return fail(format!("the replica is at {:?}, not {revision}", self.revision), true);
|
||||
@@ -432,12 +444,16 @@ impl FrameServer {
|
||||
/// Run the next pass of the frame in progress; its reply once it is done.
|
||||
pub fn resume(&mut self) -> Vec<(FrameReply, Option<Vec<u8>>)> {
|
||||
let Some(mut pr) = self.pending.take() else { return vec![] };
|
||||
self.files = None;
|
||||
pr.passes += 1;
|
||||
let accel = self.accel.clone().filter(|_| pr.opts.backend != Backend::Cpu);
|
||||
let give_up = pr.passes > MAX_PASSES;
|
||||
// (each pass renders the whole frame: the last one's files are the frame's)
|
||||
let reads = ReadLog::new();
|
||||
let mut r = Renderer::new(&pr.project, self.footage.as_ref(), pr.opts);
|
||||
r.expr = self.expr.as_deref();
|
||||
r.cache = Some(&self.cache);
|
||||
r.reads = Some(&reads);
|
||||
if !give_up {
|
||||
r.accel = accel.as_deref();
|
||||
}
|
||||
@@ -451,6 +467,7 @@ impl FrameServer {
|
||||
}
|
||||
let ms = pr.t0.elapsed().as_secs_f64() * 1000.0;
|
||||
let gpu = r.accel.is_some() && r.active_accel().is_some();
|
||||
self.files = Some(reads.files());
|
||||
let hex = |v: Vec<u128>| v.into_iter().map(|k| format!("{k:032x}")).collect::<Vec<_>>();
|
||||
let (layer_misses, layer_hits) = match &self.layer_store {
|
||||
Some(st) => (hex(st.take_misses()), hex(st.take_hits())),
|
||||
|
||||
@@ -69,7 +69,8 @@ fn layer_buffers_go_through_the_disk_store() {
|
||||
assert!(!layer_misses.is_empty() && layer_hits.is_empty());
|
||||
let writes = store_a.take_writes();
|
||||
assert!(!writes.is_empty());
|
||||
assert!(writes.iter().all(|(k, _)| (*k >> 64) as u64 == a.salt()), "keys are salted per project");
|
||||
// Sealed with the files each buffer was made of (none here: the demo reads no footage).
|
||||
assert!(writes.iter().all(|(_, e)| effectcraft_render::disk_cache::read_layer_entry(e).is_some_and(|(_, f)| f.is_empty())));
|
||||
let mut index = DiskIndex::default();
|
||||
let mut disk = std::collections::HashMap::new();
|
||||
for (k, e) in writes {
|
||||
@@ -375,3 +376,138 @@ fn every_frame_begin_has_a_frame_end() {
|
||||
assert!(matches!(w.handle(req(6, s.revision, comp, cpu))[0].0, FrameReply::Frame { id: 6, .. }));
|
||||
assert_eq!(load(), (3, 3));
|
||||
}
|
||||
|
||||
/// Footage that reads one file from memory (the browser's file table) for every frame.
|
||||
struct OneVirtualFile {
|
||||
/// Tell which files it reads (else every render's list is incomplete).
|
||||
records: bool,
|
||||
}
|
||||
|
||||
impl FootageSource for OneVirtualFile {
|
||||
fn frame(&self, _: ItemId, _: &effectcraft_project::Footage, _: Tick) -> Option<Arc<Image>> {
|
||||
Some(Arc::new(Image::filled(4, 4, [1.0, 0.5, 0.0, 1.0])))
|
||||
}
|
||||
fn recording(&self, reads: &ReadLog, f: &mut dyn FnMut()) {
|
||||
if self.records {
|
||||
reads.note(effectcraft_render::reads::ReadFile::new("/clip.png", effectcraft_render::reads::FileStamp::Virtual));
|
||||
} else {
|
||||
reads.mark_incomplete();
|
||||
}
|
||||
f()
|
||||
}
|
||||
}
|
||||
|
||||
/// The worker hands the transport the files each frame read, so its disk entry keeps them; the
|
||||
/// page reads the entry back only while those files are unchanged. A frame whose files can't
|
||||
/// all be named is not stored (#3184).
|
||||
#[test]
|
||||
fn the_worker_reports_the_files_of_each_frame_for_its_disk_entry() {
|
||||
use effectcraft_project::{Comp, Footage, ItemKind, LayerSource, Project, build};
|
||||
use effectcraft_render::disk_cache::{frame_entry, read_frame_entry};
|
||||
use effectcraft_time::FrameRate;
|
||||
let mut p = Project::default();
|
||||
let f = Footage { path: "/clip.png".into(), width: 4, height: 4, duration: Tick::from_seconds_f64(1.0), has_video: true, ..Default::default() };
|
||||
let fid = p.add_item("clip.png", effectcraft_color::Label::None, None, ItemKind::Footage(f));
|
||||
let mut comp = Comp::new(4, 4, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
|
||||
comp.layers = vec![build::layer(&mut p, &comp, "clip", LayerSource::Footage { item: fid }, (4, 4), None)].into();
|
||||
let cid = p.add_item("C", effectcraft_color::Label::None, None, ItemKind::Comp(comp.into()));
|
||||
let id = |mark: u64| move |path: &str| (path == "/clip.png").then_some((10, mark));
|
||||
let (one, two) = (id(1), id(2));
|
||||
for records in [true, false] {
|
||||
let mut w = FrameServer::new(Arc::new(OneVirtualFile { records }), None);
|
||||
w.set_virtual_id(Some(Arc::new(one)));
|
||||
let mut m = Mirror::default();
|
||||
assert!(w.handle(round(&m.sync(1, &p).unwrap())).is_empty());
|
||||
assert!(w.take_files().is_none(), "no frame yet");
|
||||
let out = w.handle(round(&FrameMsg::Render {
|
||||
id: 1,
|
||||
revision: 1,
|
||||
comp: cid,
|
||||
time: Tick::ZERO,
|
||||
opts: Box::default(),
|
||||
disk: Some(format!("{:032x}", 5u128)),
|
||||
layers: false,
|
||||
prefetch: vec![],
|
||||
}));
|
||||
let (FrameReply::Frame { width, height, .. }, Some(px)) = &out[0] else { panic!("{:?}", out[0].0) };
|
||||
let files = w.take_files().expect("the frame's files");
|
||||
assert!(w.take_files().is_none(), "taken once");
|
||||
let vid = w.virtual_id().cloned();
|
||||
let entry = frame_entry(*width, *height, px, &files, vid.as_deref());
|
||||
if !records {
|
||||
assert!(!files.is_complete() && entry.is_none(), "an incomplete list is not stored");
|
||||
continue;
|
||||
}
|
||||
assert_eq!(files.files().iter().map(|f| &*f.path).collect::<Vec<_>>(), ["/clip.png"]);
|
||||
let entry = entry.expect("stored");
|
||||
assert_eq!(read_frame_entry(&entry, Some(&one)).map(|f| f.rgba), Some(px.clone()));
|
||||
assert!(read_frame_entry(&entry, Some(&two)).is_none(), "the file was replaced: a miss");
|
||||
}
|
||||
}
|
||||
|
||||
/// No salt work at all (#3184, replacing the background salt of #3106): syncing a project with a
|
||||
/// large image sequence nobody shows, patching it and rendering with the layer store never asks
|
||||
/// the file hook about the sequence (the salt asked about every file at every sync). Only the
|
||||
/// file the rendered buffer reads is asked about: to store its entry and, in another worker, to
|
||||
/// check it before the buffer from the store is used.
|
||||
#[test]
|
||||
fn the_worker_asks_only_about_the_files_its_buffers_read() {
|
||||
use effectcraft_project::{Comp, Footage, FootageKind, ItemKind, LayerSource, Project, build};
|
||||
use effectcraft_time::FrameRate;
|
||||
use std::sync::Mutex;
|
||||
let mut p = Project::default();
|
||||
let f = Footage { path: "/clip.png".into(), width: 4, height: 4, has_video: true, ..Default::default() };
|
||||
let fid = p.add_item("clip.png", effectcraft_color::Label::None, None, ItemKind::Footage(f));
|
||||
let seq = Footage {
|
||||
kind: FootageKind::Sequence,
|
||||
path: "/seq/f_00000.png".into(),
|
||||
sequence: (0..5000).map(|i| format!("/seq/f_{i:05}.png")).collect(),
|
||||
width: 4,
|
||||
height: 4,
|
||||
has_video: true,
|
||||
..Default::default()
|
||||
};
|
||||
p.add_item("seq", effectcraft_color::Label::None, None, ItemKind::Footage(seq));
|
||||
let mut comp = Comp::new(4, 4, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
|
||||
comp.layers = vec![build::layer(&mut p, &comp, "clip", LayerSource::Footage { item: fid }, (4, 4), None)].into();
|
||||
let cid = p.add_item("C", effectcraft_color::Label::None, None, ItemKind::Comp(comp.into()));
|
||||
let asked: Arc<Mutex<Vec<String>>> = Arc::default();
|
||||
let worker = || {
|
||||
let mut w = FrameServer::new(Arc::new(OneVirtualFile { records: true }), None);
|
||||
let seen = asked.clone();
|
||||
w.set_virtual_id(Some(Arc::new(move |path: &str| {
|
||||
seen.lock().unwrap().push(path.to_string());
|
||||
(path == "/clip.png").then_some((10, 1))
|
||||
})));
|
||||
let store = Arc::new(PrefetchStore::new(1 << 20).with_min_ms(0));
|
||||
w.set_layer_store(Some(store.clone()));
|
||||
(w, store)
|
||||
};
|
||||
let render = |w: &mut FrameServer, revision: u64, prefetch: Vec<String>| {
|
||||
let out = w.handle(round(&FrameMsg::Render { id: 1, revision, comp: cid, time: Tick::ZERO, opts: Box::default(), disk: None, layers: true, prefetch }));
|
||||
let FrameReply::Frame { layer_hits, .. } = &out[0].0 else { panic!("{:?}", out[0].0) };
|
||||
layer_hits.len()
|
||||
};
|
||||
// Worker A: synced, patched, rendered; its buffer is queued with its file.
|
||||
let (mut a, store_a) = worker();
|
||||
let mut m = Mirror::default();
|
||||
assert!(a.handle(round(&m.sync(1, &p).unwrap())).is_empty());
|
||||
let mut edited = p.clone();
|
||||
edited.item_mut(cid).unwrap().name = "Renamed".into();
|
||||
assert!(a.handle(round(&m.sync(2, &edited).unwrap())).is_empty());
|
||||
assert!(asked.lock().unwrap().is_empty(), "syncing looks at no file");
|
||||
assert_eq!(render(&mut a, 2, vec![]), 0);
|
||||
let writes = store_a.take_writes();
|
||||
assert!(!writes.is_empty(), "the clip's buffer is stored");
|
||||
// Worker B: fetched before its frame, checked, used.
|
||||
let (mut b, store_b) = worker();
|
||||
let mut m = Mirror::default();
|
||||
assert!(b.handle(round(&m.sync(2, &edited).unwrap())).is_empty());
|
||||
for (k, e) in &writes {
|
||||
assert!(store_b.provide(*k, e));
|
||||
}
|
||||
assert!(render(&mut b, 2, writes.iter().map(|(k, _)| format!("{k:032x}")).collect()) > 0, "served from the store");
|
||||
let asked = asked.lock().unwrap();
|
||||
assert!(!asked.is_empty(), "the hook names the buffer's file");
|
||||
assert!(asked.iter().all(|p| p == "/clip.png"), "only the file the buffer read: {:?}", &asked[..asked.len().min(5)]);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_keyframe::Value as KV;
|
||||
use effectcraft_project::build::{self, Ids};
|
||||
use effectcraft_project::{ItemKind, LayerSource, Solid};
|
||||
use effectcraft_project::{ItemKind, LayerSource, MatteKind, Solid, TrackMatte};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, bad, has_layers, layers_p, str_p};
|
||||
@@ -13,6 +13,8 @@ use crate::{EngineError, Result, Session, cmd};
|
||||
|
||||
const OPS: &str = "editableText|shapesFromText|masksFromText|shapesFromVector|instance3d";
|
||||
|
||||
/// Layer ▸ Create (`layer.create`): the `op` ([`OPS`]) on the given or selected layers; returns
|
||||
/// the created layer ids and what was skipped.
|
||||
fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let op = str_p(p, "op").ok_or_else(|| bad("layer.create", format!("missing `op` ({OPS})")))?;
|
||||
let (cid, lids) = layers_p(s, p)?;
|
||||
@@ -99,8 +101,11 @@ fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
continue;
|
||||
};
|
||||
let bytes = s.services.read_file(&f.path).map_err(|e| EngineError::Other(format!("cannot read {}: {e}", f.path)))?;
|
||||
let doc = crate::vector::vector_doc(&f.path, &bytes, f.layer.as_ref(), f.page)
|
||||
// In the source layer's pixels, as the footage is drawn (a placed page of an After
|
||||
// Effects project fills the stored size, #3099).
|
||||
let doc = crate::vector::footage_doc(&f, &bytes)
|
||||
.unwrap_or_else(|| Err(format!("{}: not a vector file", f.path)))
|
||||
.map(|d| crate::vector::fitted_to_footage(d, &f))
|
||||
.map_err(EngineError::Other)?;
|
||||
// Images become footage layers (PNG files written next to the source and
|
||||
// imported), parented to the shape layer below it.
|
||||
@@ -123,7 +128,7 @@ fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Without images: one shape layer. With images: the shapes between them
|
||||
// in layers of their own, so the stack keeps the document's paint order.
|
||||
let segments = if image_items.is_empty() { vec![doc.clone()] } else { crate::vector::split_at_images(&doc) };
|
||||
let top = crate::vector::shapes_from_vector(proj, &comp, &src, segments.last().unwrap_or(&doc));
|
||||
let mut top = crate::vector::shapes_from_vector(proj, &comp, &src, segments.last().unwrap_or(&doc));
|
||||
// Top to bottom: the last segment, image n, segment n − 1, … image 1, segment 0.
|
||||
let mut stack = vec![];
|
||||
for k in (0..segments.len().saturating_sub(1)).rev() {
|
||||
@@ -140,9 +145,25 @@ fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
sl.name = format!("{} {}", top.name, k + 1);
|
||||
stack.push(sl);
|
||||
}
|
||||
// A placed page shows only its frame (the footage's raster ends there): every
|
||||
// layer made from it takes a hidden frame layer as its track matte (#3099).
|
||||
let matte = crate::vector::frame_matte(proj, &comp, &src, &f);
|
||||
if let Some(m) = &matte {
|
||||
let tm = Some(TrackMatte { layer: m.id, kind: MatteKind::Alpha });
|
||||
top.track_matte = tm;
|
||||
for l in &mut stack {
|
||||
l.track_matte = tm;
|
||||
}
|
||||
}
|
||||
let mut ids = vec![top.id];
|
||||
ids.extend(stack.iter().filter(|l| matches!(l.source, LayerSource::Shape)).map(|l| l.id));
|
||||
// Each directly above the source: the top layer first.
|
||||
// The frame matte is made too (last: the selection stays on the top layer).
|
||||
ids.extend(matte.as_ref().map(|m| m.id));
|
||||
// Each directly above the source: the frame matte (when there is one) above
|
||||
// all, then the top layer first.
|
||||
if let Some(m) = matte {
|
||||
insert_above(proj, cid, lid, m, true)?;
|
||||
}
|
||||
insert_above(proj, cid, lid, top, true)?;
|
||||
for l in stack {
|
||||
insert_above(proj, cid, lid, l, true)?;
|
||||
|
||||
@@ -345,6 +345,7 @@ fn warning(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
footage: None,
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
reads: None,
|
||||
};
|
||||
Ok(json!({"effect": uid, "warning": effectcraft_effects::warning(g, &mut |pr| ctx.value(layer, pr))}))
|
||||
}
|
||||
@@ -378,6 +379,7 @@ fn pick_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
footage: None,
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
reads: None,
|
||||
};
|
||||
let mut r = effectcraft_render::Renderer::new(&s.project, &*s.footage, effectcraft_render::RenderOpts::default());
|
||||
r.expr = s.expr.as_deref();
|
||||
@@ -515,6 +517,7 @@ impl CryptoProbe {
|
||||
footage: None,
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
reads: None,
|
||||
};
|
||||
let params = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr));
|
||||
let aux = r.layer_aux(&ctx, layer).ok_or(NO_CRYPTO)?;
|
||||
@@ -586,6 +589,7 @@ fn probe_key<'a>(s: &'a Session, p: &Value, cmd: &str) -> Result<(ProbeKey, Tick
|
||||
footage: None,
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
reads: None,
|
||||
};
|
||||
let frame = probe_frame(&ctx, layer, pos);
|
||||
let o = s.render_opts(Default::default());
|
||||
@@ -780,6 +784,7 @@ fn crypto_hit(s: &Session, p: &Value, cmd: &str) -> Result<Hit> {
|
||||
footage: None,
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
reads: None,
|
||||
};
|
||||
let selection = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr)).s("selection").to_string();
|
||||
let driven = prop.expr.as_ref().is_some_and(|e| e.enabled);
|
||||
|
||||
@@ -327,7 +327,7 @@ impl Prober {
|
||||
&& self.report_missing_frames
|
||||
&& let Some(gaps) = crate::sequence::missing_report(&f.sequence)
|
||||
{
|
||||
warning = Some(format!("{name}: {gaps} (shown as placeholders)"));
|
||||
warning = Some(format!("{name}: {gaps} (the previous frame is held)"));
|
||||
}
|
||||
// Settings ▸ Import ▸ Interpret Unlabeled Alpha As: images only (a 3D model renders its
|
||||
// own alpha, and audio and data have none).
|
||||
@@ -719,8 +719,8 @@ fn project_settings(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(serde_json::to_value(&s.project.settings).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Video Rendering and Effects: report or set the renderer (Mercury GPU Acceleration when a GPU
|
||||
/// adapter exists, else Mercury Software Only).
|
||||
/// Video Rendering and Effects: report or set the renderer (GPU Acceleration when a GPU
|
||||
/// adapter exists, else Software Rendering).
|
||||
fn render_backend(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(b) = p.get("backend").or_else(|| p.get("renderer")) {
|
||||
let gpu = match b {
|
||||
@@ -748,14 +748,14 @@ pub fn backend_status(s: &Session) -> Value {
|
||||
let gpu = s.project.settings.gpu_acceleration;
|
||||
// Why it renders on the CPU (#262).
|
||||
let why = if !gpu {
|
||||
Some("the project's renderer is Mercury Software Only".to_string())
|
||||
Some("the project's renderer is Software Rendering".to_string())
|
||||
} else if adapter.is_none() {
|
||||
Some(s.accel_note.clone().unwrap_or_else(|| "no GPU compositor is attached".into()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
json!({
|
||||
"renderer": if gpu { "Mercury GPU Acceleration" } else { "Mercury Software Only" },
|
||||
"renderer": if gpu { "GPU Acceleration" } else { "Software Rendering" },
|
||||
"gpuAcceleration": gpu,
|
||||
"adapter": adapter,
|
||||
"active": if gpu && adapter.is_some() { "gpu" } else { "cpu" },
|
||||
|
||||
@@ -31,7 +31,7 @@ use replace::{has_replace_source, replace_source};
|
||||
pub(crate) use select::select_only;
|
||||
use select::{select, select_next, select_prev, select_step};
|
||||
use switches::{blend, rename, set_comment, set_switch, track_matte};
|
||||
use timing::{arrange, move_frames, precompose, slip, timing};
|
||||
use timing::{arrange, move_frames, precompose, slip, timing, unprecompose};
|
||||
use transform::{has_layer_settings, layer_settings, transform_op};
|
||||
pub(crate) use transform::{offset_position, transform_default, update_position};
|
||||
|
||||
@@ -49,7 +49,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Text",
|
||||
["Layer", "New"],
|
||||
Some("Cmd+Alt+Shift+T"),
|
||||
"{text?, name? (layer name; default the text), position? [x,y], box? [x,y,w,h] (paragraph text, comp space), vertical?, edit? (start editing), font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?, baselineShift?, hScale?, vScale?, tsume?, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?, superscript?, subscript?, kerning?, ligatures?, justify?, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?, composer?, hangingPunctuation?, strokeOverFill?, lineJoin?} (attributes as layer.setText)",
|
||||
"{text?, name? (layer name; default the text, which the name then follows; a given name stays), position? [x,y], box? [x,y,w,h] (paragraph text, comp space), vertical?, edit? (start editing), font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?, baselineShift?, hScale?, vScale?, tsume?, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?, superscript?, subscript?, kerning?, ligatures?, justify?, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?, composer?, hangingPunctuation?, strokeOverFill?, lineJoin?} (attributes as layer.setText)",
|
||||
has_comp,
|
||||
new_text
|
||||
),
|
||||
@@ -70,7 +70,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Layer Settings...",
|
||||
["Layer"],
|
||||
Some("Cmd+Shift+Y"),
|
||||
"{layer?, name?, color?, width?, height?, pixelAspect?, affectAll?: bool (default true; false gives this layer its own copy of a solid other layers use)} → {layer, solid?}",
|
||||
"{layer?, name? (an empty name makes a text layer's name follow its text again), color?, width?, height?, pixelAspect?, affectAll?: bool (default true; false gives this layer its own copy of a solid other layers use)} → {layer, solid?}",
|
||||
has_layer_settings,
|
||||
layer_settings
|
||||
),
|
||||
@@ -114,7 +114,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_layers,
|
||||
set_switch
|
||||
),
|
||||
cmd!("layer.rename", "Rename", [], Some("Enter"), "{layer?, name}", has_layers, rename),
|
||||
cmd!(
|
||||
"layer.rename",
|
||||
"Rename",
|
||||
[],
|
||||
Some("Enter"),
|
||||
"{layer?, name} — a text layer's name follows its text until renamed; an empty name follows it again",
|
||||
has_layers,
|
||||
rename
|
||||
),
|
||||
cmd!("layer.setComment", "Layer Comment", [], None, "{layers?, comment}", has_layers, set_comment),
|
||||
cmd!("layer.setBlendMode", "Blending Mode", [], None, "{layers?, mode?: Normal|Multiply|Screen|…, step?: ±1}", has_layers, blend),
|
||||
cmd!(
|
||||
@@ -177,6 +185,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_layers,
|
||||
precompose
|
||||
),
|
||||
cmd!(
|
||||
"layer.unprecompose",
|
||||
"Un-precompose",
|
||||
["Layer"],
|
||||
None,
|
||||
"{layer?, force?, dryRun?} → {layers, warnings}: expands the selected pre-comp layer into its composition's layers (copies, in order, with timing and position carried over). A locked pre-comp layer is refused, changing nothing. What would change the picture (the pre-comp layer has a parent, matte, effects, masks, layer styles, a non-default Scale/Rotation/Opacity, an animated transform, time remap/stretch, a blending mode, Video/Audio off, Solo/Guide on, is 3D/collapsing/adjustment, is used by another layer or named in another layer's expression; or the nested comp has cameras, lights, adjustment, 3D, collapsing, soloed or blend-mode / Preserve Transparency layers, preserves another frame rate, has another motion-blur shutter, or has Position expressions the move can't reach) is an error listing the reasons, changing nothing, unless `force`: then it unpacks anyway in one undo step, drops what the pre-comp layer carried, removes other layers' parent/matte/property links to it and lists the reasons as `warnings`. `dryRun` returns {dryRun, warnings} and changes nothing",
|
||||
has_layers,
|
||||
unprecompose
|
||||
),
|
||||
cmd!(
|
||||
"layer.addMask",
|
||||
"New Mask",
|
||||
@@ -201,7 +218,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Edit Text",
|
||||
[],
|
||||
None,
|
||||
"{layer?, range?: [start, end] (characters; default all), text?, font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?: px|\"auto\", baselineShift? px, hScale? %, vScale? %, tsume? %, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?: normal|superscript|subscript, superscript?, subscript?, kerning?: metrics|optical|number, ligatures?, discretionaryLigatures? (OpenType), contextualAlternates?, stylisticAlternates?, stylisticSets?: [1–20], ss01?, ss02?, ss03?, ss04?, ss05?, ss06?, ss07?, ss08?, ss09?, ss10?, ss11?, ss12?, ss13?, ss14?, ss15?, ss16?, ss17?, ss18?, ss19?, ss20?, swash?, titling?, ordinals?, fractions?, allSmallCaps?, figureStyle?: default|lining|oldStyle, figureWidth?: default|proportional|tabular, figures?, variations?: {tag: value} (variable font axes; null resets), justify?: left|center|right|justifyLeft|justifyCenter|justifyRight|justifyAll, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?: ltr|rtl, composer?: everyLine|singleLine, hangingPunctuation?, strokeOverFill?, lineJoin?: miter|round|bevel, box?: [x,y,w,h]|null (layer space), vertical?}",
|
||||
"{layer?, range?: [start, end] (characters; default all), text? (also renames a text layer whose name still follows its text), font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?: px|\"auto\", baselineShift? px, hScale? %, vScale? %, tsume? %, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?: normal|superscript|subscript, superscript?, subscript?, kerning?: metrics|optical|number, ligatures?, discretionaryLigatures? (OpenType), contextualAlternates?, stylisticAlternates?, stylisticSets?: [1–20], ss01?, ss02?, ss03?, ss04?, ss05?, ss06?, ss07?, ss08?, ss09?, ss10?, ss11?, ss12?, ss13?, ss14?, ss15?, ss16?, ss17?, ss18?, ss19?, ss20?, swash?, titling?, ordinals?, fractions?, allSmallCaps?, figureStyle?: default|lining|oldStyle, figureWidth?: default|proportional|tabular, figures?, variations?: {tag: value} (variable font axes; null resets), justify?: left|center|right|justifyLeft|justifyCenter|justifyRight|justifyAll, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?: ltr|rtl, composer?: everyLine|singleLine, hangingPunctuation?, strokeOverFill?, lineJoin?: miter|round|bevel, box?: [x,y,w,h]|null (layer space), vertical?}",
|
||||
has_layers,
|
||||
set_text
|
||||
),
|
||||
|
||||
@@ -207,14 +207,14 @@ pub(super) fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
KV::Str(t) => TextDoc::plain(&t),
|
||||
_ => TextDoc::default(),
|
||||
};
|
||||
let before = effectcraft_project::text_layer_name(&doc.text);
|
||||
apply_text_params("layer.setText", p, &mut doc, range.clone())?;
|
||||
pr.set_value_at(lt, KV::Text(Box::new(doc.clone())));
|
||||
if let Some(first) = doc.text.lines().next()
|
||||
&& str_p(p, "text").is_some()
|
||||
&& !first.is_empty()
|
||||
&& (l.name.starts_with("Text") || l.name.is_empty())
|
||||
{
|
||||
l.name = first.chars().take(40).collect();
|
||||
// Text layers are named after their text until renamed (#3233).
|
||||
let after = effectcraft_project::text_layer_name(&doc.text);
|
||||
if str_p(p, "text").is_some() && !after.is_empty() && l.name_follows_text(&before) {
|
||||
l.name = after;
|
||||
l.name_follows_text = Some(true);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
@@ -187,8 +187,10 @@ pub(super) fn new_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let edit = b_p(p, "edit").unwrap_or(false);
|
||||
let id = s.edit("New Text Layer", None, |proj, st| {
|
||||
let name: String = layer_name.clone().unwrap_or_else(|| doc.text.lines().next().unwrap_or("").chars().take(40).collect());
|
||||
let name: String = layer_name.clone().unwrap_or_else(|| effectcraft_project::text_layer_name(&doc.text));
|
||||
let mut l = build::layer(proj, &comp, if name.is_empty() { "Text" } else { &name }, LayerSource::Text, (comp.width, comp.height), None);
|
||||
// Named after its text, the name follows the text until renamed; a given name does not.
|
||||
l.name_follows_text = Some(layer_name.as_deref().is_none_or(str::is_empty));
|
||||
if let Some(pr) = l.props.prop_mut("text/sourceText") {
|
||||
pr.value = KV::Text(Box::new(doc.clone()));
|
||||
}
|
||||
|
||||
@@ -175,23 +175,7 @@ fn uniform_rotation(m: effectcraft_geom::Mat4) -> Option<(effectcraft_geom::Mat4
|
||||
Some((rotation, factor))
|
||||
}
|
||||
|
||||
/// The turn (degrees) of a rotation about Z alone, or `None` for any other rotation.
|
||||
fn z_only_turn(correction: effectcraft_geom::Mat4) -> Option<f64> {
|
||||
let m = correction.0;
|
||||
let about_z = [m[0][2], m[1][2], m[2][0], m[2][1]].iter().all(|v| v.abs() < 1e-9) && (m[2][2] - 1.0).abs() < 1e-9;
|
||||
about_z.then(|| m[1][0].atan2(m[0][0]).to_degrees())
|
||||
}
|
||||
|
||||
/// ZYX Euler angles in degrees of a row-major rotation (`Rz * Ry * Rx`), safe at gimbal lock.
|
||||
fn euler_zyx(r: [[f64; 4]; 4]) -> [f64; 3] {
|
||||
// atan2 rather than asin: near ±90 degrees asin loses precision, while cos(y) stays exact.
|
||||
let cy = r[0][0].hypot(r[1][0]);
|
||||
let y = (-r[2][0]).atan2(cy);
|
||||
let (x, z) = if cy > 1e-9 { (r[2][1].atan2(r[2][2]), r[1][0].atan2(r[0][0])) } else { ((-r[1][2]).atan2(r[1][1]), 0.0) };
|
||||
[x.to_degrees(), y.to_degrees(), z.to_degrees()]
|
||||
}
|
||||
|
||||
/// The equivalent ZYX Euler triple nearest `reference`: each axis moved by whole turns, or the
|
||||
/// The equivalent XYZ Euler triple nearest `reference`: each axis moved by whole turns, or the
|
||||
/// `(x + 180, 180 - y, z + 180)` form when that is nearer.
|
||||
fn nearest_euler(euler: [f64; 3], reference: [f64; 3]) -> [f64; 3] {
|
||||
let near = |v: [f64; 3]| -> [f64; 3] { std::array::from_fn(|i| v[i] + ((reference[i] - v[i]) / 360.0).round() * 360.0) };
|
||||
@@ -204,136 +188,30 @@ fn nearest_euler(euler: [f64; 3], reference: [f64; 3]) -> [f64; 3] {
|
||||
/// The Euler Orientation of `correction * Orientation(o)`, in the form nearest `reference`.
|
||||
fn reexpress_euler(correction: effectcraft_geom::Mat4, o: [f64; 3], reference: [f64; 3]) -> [f64; 3] {
|
||||
let world = correction * effectcraft_geom::Mat4::orientation(effectcraft_geom::Vec3::from(o));
|
||||
nearest_euler(euler_zyx(world.0), reference)
|
||||
let angles = world.orientation_angles();
|
||||
nearest_euler([angles.x, angles.y, angles.z], reference)
|
||||
}
|
||||
|
||||
/// Largest angle (degrees) a baked Orientation may drift from the exact rotation between its keys.
|
||||
const BAKE_TOLERANCE_DEGREES: f64 = 0.05;
|
||||
/// How many times a segment may be halved while baking, and the most keys a property may end up with.
|
||||
const BAKE_MAX_DEPTH: u32 = 10;
|
||||
const BAKE_MAX_KEYS: usize = 4096;
|
||||
|
||||
/// The angle (degrees) between two rotations.
|
||||
fn rotation_angle(a: effectcraft_geom::Mat4, b: effectcraft_geom::Mat4) -> f64 {
|
||||
let trace: f64 = (0..3).flat_map(|i| (0..3).map(move |j| (i, j))).map(|(i, j)| a.0[i][j] * b.0[i][j]).sum();
|
||||
((trace - 1.0) / 2.0).clamp(-1.0, 1.0).acos().to_degrees()
|
||||
}
|
||||
|
||||
/// Adds keys between `(ta, ea)` and `(tb, eb)` (already converted) wherever the straight Euler blend
|
||||
/// between them drifts from `correction * Orientation(authored)` by more than the tolerance. `out`
|
||||
/// never grows past `budget` keys: what the whole property may still add, not what one segment may.
|
||||
fn bake_between(
|
||||
correction: effectcraft_geom::Mat4,
|
||||
authored: &effectcraft_project::Property,
|
||||
(ta, ea): (Tick, [f64; 3]),
|
||||
(tb, eb): (Tick, [f64; 3]),
|
||||
depth: u32,
|
||||
budget: usize,
|
||||
out: &mut Vec<effectcraft_keyframe::Keyframe>,
|
||||
) {
|
||||
use effectcraft_geom::{Mat4, Vec3};
|
||||
if depth == 0 || tb.0 - ta.0 < 2 || out.len() >= budget {
|
||||
return;
|
||||
}
|
||||
let tm = Tick(ta.0 + (tb.0 - ta.0) / 2);
|
||||
let blend = [(ea[0] + eb[0]) / 2.0, (ea[1] + eb[1]) / 2.0, (ea[2] + eb[2]) / 2.0];
|
||||
let exact = correction * Mat4::orientation(Vec3::from(authored.value_at(tm).as_vec3()));
|
||||
// Symmetric easing crosses the straight blend at its midpoint. Check both sides too before
|
||||
// accepting a segment whose original easing will be replaced with a linear blend.
|
||||
let quarter = (tb.0 - ta.0) / 4;
|
||||
if [Tick(ta.0 + quarter), tm, Tick(tb.0 - quarter)].into_iter().all(|t| {
|
||||
let u = (t.0 - ta.0) as f64 / (tb.0 - ta.0) as f64;
|
||||
let linear = std::array::from_fn(|i| ea[i] + (eb[i] - ea[i]) * u);
|
||||
let target = correction * Mat4::orientation(Vec3::from(authored.value_at(t).as_vec3()));
|
||||
rotation_angle(Mat4::orientation(Vec3::from(linear)), target) <= BAKE_TOLERANCE_DEGREES
|
||||
}) {
|
||||
return;
|
||||
}
|
||||
let em = nearest_euler(euler_zyx(exact.0), blend);
|
||||
bake_between(correction, authored, (ta, ea), (tm, em), depth - 1, budget, out);
|
||||
if out.len() >= budget {
|
||||
return;
|
||||
}
|
||||
out.push(effectcraft_keyframe::Keyframe::new(tm, KV::Vec3(em)));
|
||||
bake_between(correction, authored, (tm, em), (tb, eb), depth - 1, budget, out);
|
||||
}
|
||||
|
||||
/// Re-expresses Orientation as `correction * Orientation`. A Z-only correction shifts Z exactly in the
|
||||
/// value and every key. For any other correction a static value is converted directly; keys are
|
||||
/// converted, and keys are added between them wherever the Euler blend between two keys would drift
|
||||
/// from the exact rotation (the segments become linear, with the original easing baked in).
|
||||
/// Re-express Orientation as `correction * Orientation`, retaining authored keys and easing.
|
||||
/// Orientation interpolates along quaternion arcs: a constant left rotation preserves those arcs
|
||||
/// and their angular speeds, so converting the endpoints preserves the entire eased animation.
|
||||
fn reexpress_orientation(correction: effectcraft_geom::Mat4, pr: &mut effectcraft_project::Property) {
|
||||
if let Some(turn) = z_only_turn(correction) {
|
||||
// No turn (an identity correction) leaves every authored value as it is.
|
||||
if turn.abs() <= 1e-12 {
|
||||
return;
|
||||
}
|
||||
let shift = |value: &KV| {
|
||||
let [x, y, z] = value.as_vec3();
|
||||
KV::Vec3([x, y, z + turn])
|
||||
};
|
||||
pr.value = shift(&pr.value);
|
||||
for key in &mut pr.keys {
|
||||
key.value = shift(&key.value);
|
||||
}
|
||||
if (0..3).all(|row| (0..3).all(|col| (correction.0[row][col] - if row == col { 1.0 } else { 0.0 }).abs() < 1e-12)) {
|
||||
return;
|
||||
}
|
||||
let value = pr.value.as_vec3();
|
||||
pr.value = KV::Vec3(reexpress_euler(correction, value, value));
|
||||
if pr.keys.is_empty() {
|
||||
return;
|
||||
}
|
||||
let authored = pr.clone();
|
||||
// Each key keeps the authored change from the previous key on top of its converted value.
|
||||
let mut converted: Vec<[f64; 3]> = vec![];
|
||||
let mut prev: Option<([f64; 3], [f64; 3])> = None;
|
||||
for key in &authored.keys {
|
||||
for key in &mut pr.keys {
|
||||
let o = key.value.as_vec3();
|
||||
let reference = match prev {
|
||||
Some((before, after)) => [after[0] + o[0] - before[0], after[1] + o[1] - before[1], after[2] + o[2] - before[2]],
|
||||
None => o,
|
||||
};
|
||||
let n = reexpress_euler(correction, o, reference);
|
||||
converted.push(n);
|
||||
key.value = KV::Vec3(n);
|
||||
prev = Some((o, n));
|
||||
}
|
||||
use effectcraft_keyframe::Interp;
|
||||
let mut keys: Vec<effectcraft_keyframe::Keyframe> = vec![];
|
||||
let mut after_baked_segment = false;
|
||||
for (i, key) in authored.keys.iter().enumerate() {
|
||||
let mut k = key.clone();
|
||||
k.value = KV::Vec3(converted[i]);
|
||||
if after_baked_segment {
|
||||
k.in_interp = Interp::Linear;
|
||||
k.in_ease.clear();
|
||||
k.auto_bezier = false;
|
||||
k.continuous = false;
|
||||
}
|
||||
after_baked_segment = false;
|
||||
let segment = authored.keys.get(i + 1).filter(|_| key.out_interp != Interp::Hold);
|
||||
let mut between = vec![];
|
||||
if let Some(next) = segment {
|
||||
// What the whole property may still add: the keys it will have already (the ones written
|
||||
// and the authored ones still to come) count against the cap.
|
||||
let budget = BAKE_MAX_KEYS.saturating_sub(keys.len() + authored.keys.len() - i);
|
||||
bake_between(correction, &authored, (key.time, converted[i]), (next.time, converted[i + 1]), BAKE_MAX_DEPTH, budget, &mut between);
|
||||
// With the cap reached the segment can't be checked or subdivided: it keeps its authored easing between
|
||||
// the converted keys, rather than becoming a straight blend that was never checked against the original.
|
||||
if between.len() >= budget {
|
||||
between.clear();
|
||||
// An eased segment, or one that gained keys, becomes a run of straight blends.
|
||||
} else if !between.is_empty() || key.out_interp != Interp::Linear || next.in_interp != Interp::Linear {
|
||||
k.out_interp = Interp::Linear;
|
||||
k.out_ease.clear();
|
||||
k.auto_bezier = false;
|
||||
k.continuous = false;
|
||||
after_baked_segment = true;
|
||||
}
|
||||
}
|
||||
keys.push(k);
|
||||
keys.extend(between);
|
||||
}
|
||||
pr.keys = keys;
|
||||
}
|
||||
|
||||
/// Correction for a two-node rig's Orientation: after a uniform parent change its aim is
|
||||
@@ -371,6 +249,7 @@ pub(crate) fn parent_fixes(s: &Session, cid: ItemId, ids: &[LayerId], parent: Op
|
||||
footage: Some(s.footage.as_ref()),
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
reads: None,
|
||||
};
|
||||
let space = |id: Option<LayerId>| -> effectcraft_geom::Mat3 {
|
||||
id.and_then(|i| comp.layer(i)).filter(|l| !l.is_3d()).map(|l| ectx.layer_to_comp(l).0).unwrap_or(effectcraft_geom::Mat3::IDENTITY)
|
||||
|
||||
@@ -139,13 +139,37 @@ fn drop_3d_values(l: &mut Layer) {
|
||||
pub(super) fn rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, lid) = layer_p(s, p, "layer.rename")?;
|
||||
let name = str_p(p, "name").ok_or_else(|| bad("layer.rename", "missing `name`"))?.to_string();
|
||||
let t = s.time();
|
||||
s.edit("Rename Layer", None, |proj, _| {
|
||||
layer_mut(proj, cid, lid)?.name = name.clone();
|
||||
set_layer_name(layer_mut(proj, cid, lid)?, &name, t);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// Give layer `l` the name `name` the user typed (comp time `t` picks the text). A text layer's
|
||||
/// name follows its text until the user renames it; an empty name makes it follow the text again
|
||||
/// (After Effects, #3233). Committing the unchanged name changes nothing.
|
||||
pub(super) fn set_layer_name(l: &mut Layer, name: &str, t: effectcraft_time::Tick) {
|
||||
let text_name =
|
||||
l.props.prop("text/sourceText").filter(|_| matches!(l.source, effectcraft_project::LayerSource::Text)).map(|pr| match pr.value_at(l.layer_time(t)) {
|
||||
KV::Text(d) => effectcraft_project::text_layer_name(&d.text),
|
||||
KV::Str(txt) => effectcraft_project::text_layer_name(&txt),
|
||||
_ => String::new(),
|
||||
});
|
||||
match text_name {
|
||||
Some(first) if name.trim().is_empty() => {
|
||||
l.name = if first.is_empty() { "Text".to_string() } else { first };
|
||||
l.name_follows_text = Some(true);
|
||||
}
|
||||
Some(_) if name != l.name => {
|
||||
l.name = name.to_string();
|
||||
l.name_follows_text = Some(false);
|
||||
}
|
||||
_ => l.name = name.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The Timeline's Comment column.
|
||||
pub(super) fn set_comment(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use effectcraft_project::TrackMatte;
|
||||
|
||||
use super::{
|
||||
EngineError, ItemKind, Label, Layer, LayerId, LayerSource, Result, Session, Tick, Value, b_p, bad, build, f_p, json, layer_mut, layers_p, merge_p,
|
||||
resolve_layer, str_p, unlocked, with,
|
||||
@@ -307,3 +309,317 @@ pub(super) fn precompose(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok(json!({"comp": new.0.0, "layer": new.1.0}))
|
||||
}
|
||||
|
||||
/// Why expanding a pre-comp layer in place would change the picture or drop something, one entry
|
||||
/// per cause. Un-precompose keeps the picture exactly for a pre-comp layer that only places the
|
||||
/// nested composition (Position/Anchor Point, timing); anything the layer itself adds to the look,
|
||||
/// and what only holds inside the nested comp, is listed. These are warnings: `force` unpacks
|
||||
/// anyway and drops it. A lock is not listed here: it always refuses.
|
||||
fn unprecompose_warnings(comp: &effectcraft_project::Comp, layer: &Layer, nested: &effectcraft_project::Comp) -> Vec<String> {
|
||||
let mut why = vec![];
|
||||
if layer.parent.is_some() {
|
||||
why.push("the layer has a parent".to_string());
|
||||
}
|
||||
if layer.track_matte.is_some() {
|
||||
why.push("the layer has a track matte".to_string());
|
||||
}
|
||||
if comp.layers.iter().any(|o| o.id != layer.id && (o.parent == Some(layer.id) || o.track_matte.is_some_and(|m| m.layer == layer.id))) {
|
||||
why.push("another layer uses it as its parent or track matte (that link is removed)".to_string());
|
||||
}
|
||||
if layer.is_3d() || layer.switches.collapse || layer.switches.adjustment || layer.preserve_transparency {
|
||||
why.push("the layer is 3D, collapses transformations, is an adjustment layer or preserves transparency".to_string());
|
||||
}
|
||||
if !layer.switches.video || !layer.switches.audio {
|
||||
why.push("the layer's Video or Audio switch is off".to_string());
|
||||
}
|
||||
if layer.switches.solo || layer.switches.guide {
|
||||
// Solo hides the comp's other layers and a guide layer is left out of renders; the
|
||||
// expanded copies would carry neither, so the picture would change.
|
||||
why.push("the layer's Solo or Guide switch is on".to_string());
|
||||
}
|
||||
if comp.layers.iter().any(|o| o.id != layer.id && refers_to(&o.props, layer.id)) {
|
||||
why.push("a property of another layer refers to it (that reference is cleared)".to_string());
|
||||
}
|
||||
why.extend(expression_warnings(comp, layer, nested.layers.len()));
|
||||
if layer.blend_mode != effectcraft_color::BlendMode::default() {
|
||||
why.push("the layer has a blending mode".to_string());
|
||||
}
|
||||
if layer.stretch != 100.0 || layer.props.prop("timeRemap").is_some() {
|
||||
why.push("the layer is time-stretched or time-remapped".to_string());
|
||||
}
|
||||
if layer.effects().is_some_and(|e| !e.children.is_empty()) {
|
||||
why.push("the layer has effects".to_string());
|
||||
}
|
||||
if layer.masks().is_some_and(|m| !m.children.is_empty()) {
|
||||
why.push("the layer has masks".to_string());
|
||||
}
|
||||
if layer.styles_change_pixels() {
|
||||
why.push("the layer has layer styles".to_string());
|
||||
}
|
||||
if let Some(tr) = layer.transform() {
|
||||
let at = |m: &str, d: [f64; 3]| tr.get(m).map(|p| p.value.as_vec3()).unwrap_or(d);
|
||||
let animated = tr.props().any(|p| !p.keys.is_empty() || p.expr.is_some());
|
||||
let separated = tr.get("positionX").is_some();
|
||||
if animated || separated {
|
||||
why.push("its transform is animated, has an expression or has separated dimensions".to_string());
|
||||
} else {
|
||||
let (sc, rot, ori) = (at("scale", [100.0; 3]), at("rotation", [0.0; 3]), at("orientation", [0.0; 3]));
|
||||
let op = tr.get("opacity").map(|p| p.value.as_f64()).unwrap_or(100.0);
|
||||
if sc[0] != 100.0 || sc[1] != 100.0 || rot[0] != 0.0 || ori != [0.0; 3] || op != 100.0 {
|
||||
why.push("its Scale, Rotation or Opacity is not at its default".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
if nested.layers.iter().any(|l| l.is_camera() || l.is_light()) {
|
||||
why.push("the composition has a camera or light".to_string());
|
||||
}
|
||||
// Inside the nested comp these composite against the nested stack only; in the parent they
|
||||
// would act on everything below.
|
||||
if nested.layers.iter().any(|l| {
|
||||
l.switches.adjustment || l.switches.collapse || l.is_3d() || l.preserve_transparency || l.blend_mode != effectcraft_color::BlendMode::default()
|
||||
}) {
|
||||
why.push("the composition has adjustment, 3D or collapsing layers or layers with a blending mode or Preserve Transparency".to_string());
|
||||
}
|
||||
// A solo hides only the nested comp's other layers; a soloed copy would hide the parent's.
|
||||
if nested.layers.iter().any(|l| l.switches.solo) {
|
||||
why.push("the composition has a soloed layer".to_string());
|
||||
}
|
||||
why.extend(sampling_warnings(comp, nested));
|
||||
why.extend(position_expression_warnings(layer, nested));
|
||||
why
|
||||
}
|
||||
|
||||
/// What the nested comp's own frame sampling does that the expanded layers lose: a preserved
|
||||
/// frame rate other than the parent's (its layers then change only on its own frames), and
|
||||
/// motion blur with a shutter other than the parent's.
|
||||
fn sampling_warnings(comp: &effectcraft_project::Comp, nested: &effectcraft_project::Comp) -> Vec<String> {
|
||||
let mut why = vec![];
|
||||
if nested.preserve_frame_rate && nested.frame_rate != comp.frame_rate {
|
||||
why.push("the composition preserves a frame rate other than this one's (its layers would be sampled at this comp's frames)".to_string());
|
||||
}
|
||||
let shutter = nested.shutter_angle != comp.shutter_angle || nested.shutter_phase != comp.shutter_phase;
|
||||
if shutter && nested.layers.iter().any(|l| l.switches.motion_blur) {
|
||||
why.push("the composition's motion blur shutter differs from this one's".to_string());
|
||||
}
|
||||
why
|
||||
}
|
||||
|
||||
/// The move from the pre-comp layer's Position against its Anchor Point, which expansion adds to
|
||||
/// the Position of the nested comp's unparented layers.
|
||||
fn placement_offset(layer: &Layer) -> (f64, f64) {
|
||||
let tr = layer.transform();
|
||||
let at = |m: &str| tr.and_then(|t| t.get(m)).map(|p| p.value.as_vec3()).unwrap_or([0.0; 3]);
|
||||
let (anchor, position) = (at("anchor"), at("position"));
|
||||
(position[0] - anchor[0], position[1] - anchor[1])
|
||||
}
|
||||
|
||||
/// Unparented nested layers whose Position (or a separated axis) has an expression: the move from
|
||||
/// [`placement_offset`] is added to the stored value and keys, which an expression can ignore.
|
||||
fn position_expression_warnings(layer: &Layer, nested: &effectcraft_project::Comp) -> Vec<String> {
|
||||
let (dx, dy) = placement_offset(layer);
|
||||
if dx == 0.0 && dy == 0.0 {
|
||||
return vec![];
|
||||
}
|
||||
nested
|
||||
.layers
|
||||
.iter()
|
||||
.filter(|l| l.parent.is_none())
|
||||
.filter(|l| l.transform().is_some_and(|tr| ["position", "positionX", "positionY"].iter().any(|m| tr.get(m).is_some_and(|p| p.expr.is_some()))))
|
||||
.map(|l| format!("layer “{}” has a Position expression, so the move to the pre-comp layer's Position may not apply to it", l.name))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One warning per property of another layer in `comp` whose expression names `layer`: by its
|
||||
/// name in quotes, or as `layer(<index>)` with its 1-based index (not on another comp's
|
||||
/// `comp("…").layer(…)`), and one per expression naming a layer by a number below it while the
|
||||
/// `added` layers replacing it renumber those. Detection is by text; the expressions are left as
|
||||
/// they are.
|
||||
fn expression_warnings(comp: &effectcraft_project::Comp, layer: &Layer, added: usize) -> Vec<String> {
|
||||
let index = comp.layers.iter().position(|l| l.id == layer.id).map(|i| i.saturating_add(1));
|
||||
let quoted = [format!("\"{}\"", layer.name), format!("'{}'", layer.name)];
|
||||
let names_it = |text: &str| quoted.iter().any(|q| text.contains(q.as_str())) || index.is_some_and(|i| indexed_layers(text).contains(&i));
|
||||
let renumbered = |text: &str| added != 1 && index.is_some_and(|i| indexed_layers(text).iter().any(|n| *n > i));
|
||||
let mut out = vec![];
|
||||
for o in comp.layers.iter().filter(|o| o.id != layer.id) {
|
||||
o.props.walk("", &mut |_, p| {
|
||||
if p.expr.as_ref().is_some_and(|e| !names_it(&e.text) && renumbered(&e.text)) {
|
||||
out.push(format!("layer “{}” {} names a layer by number, and the layers below “{}” are renumbered", o.name, p.name, layer.name));
|
||||
}
|
||||
if p.expr.as_ref().is_some_and(|e| names_it(&e.text)) {
|
||||
out.push(format!("layer “{}” {} has an expression that refers to “{}” (it will no longer find it)", o.name, p.name, layer.name));
|
||||
}
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The indexes expression `text` calls `layer(<index>)` with (spaces allowed) on this comp: bare,
|
||||
/// `thisComp.layer(…)`, not `comp("Other").layer(…)` or `thisLayer(…)`.
|
||||
fn indexed_layers(text: &str) -> Vec<usize> {
|
||||
text.match_indices("layer(")
|
||||
.filter_map(|(at, _)| {
|
||||
let before = text.get(..at).unwrap_or("");
|
||||
let on_other = before.ends_with(").") || before.chars().next_back().is_some_and(|c| c.is_alphanumeric() || c == '_');
|
||||
if on_other && !before.ends_with("thisComp.") {
|
||||
return None;
|
||||
}
|
||||
let rest = text.get(at + "layer(".len()..).unwrap_or("").trim_start();
|
||||
let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
|
||||
let after = rest.get(digits.len()..).unwrap_or("").trim_start();
|
||||
digits.parse::<usize>().ok().filter(|_| after.starts_with(')'))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether any property (value or key) in `props` holds a reference to layer `id`.
|
||||
fn refers_to(props: &effectcraft_project::PropGroup, id: LayerId) -> bool {
|
||||
let is = |v: &effectcraft_keyframe::Value| matches!(v, effectcraft_keyframe::Value::Layer(Some(l)) if *l == id.0);
|
||||
props.children.iter().any(|c| match c {
|
||||
effectcraft_project::Node::Prop(p) => is(&p.value) || p.keys.iter().any(|k| is(&k.value)),
|
||||
effectcraft_project::Node::Group(g) => refers_to(g, id),
|
||||
})
|
||||
}
|
||||
|
||||
/// Clear every reference to layer `id` (`Value::Layer`, value or key) in `props`; references to
|
||||
/// other layers stay.
|
||||
fn clear_layer_refs(props: &mut effectcraft_project::PropGroup, id: LayerId) {
|
||||
let fix = |v: &mut effectcraft_keyframe::Value| {
|
||||
if matches!(v, effectcraft_keyframe::Value::Layer(Some(l)) if *l == id.0) {
|
||||
*v = effectcraft_keyframe::Value::Layer(None);
|
||||
}
|
||||
};
|
||||
for c in &mut props.children {
|
||||
match c {
|
||||
effectcraft_project::Node::Prop(p) => {
|
||||
fix(&mut p.value);
|
||||
for k in &mut p.keys {
|
||||
fix(&mut k.value);
|
||||
}
|
||||
}
|
||||
effectcraft_project::Node::Group(g) => clear_layer_refs(g, id),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The layers of `comp` other than `id` let go of it: parent, track matte and property references
|
||||
/// to it are removed, so nothing points at a layer that is gone.
|
||||
fn detach_from(comp: &mut effectcraft_project::Comp, id: LayerId) {
|
||||
for l in comp.layers.iter_mut().filter(|l| l.id != id) {
|
||||
if l.parent == Some(id) {
|
||||
l.parent = None;
|
||||
}
|
||||
if l.track_matte.is_some_and(|m| m.layer == id) {
|
||||
l.track_matte = None;
|
||||
}
|
||||
clear_layer_refs(&mut l.props, id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace layer references (`Value::Layer`) found in `props` by `map` (old id → new id), the
|
||||
/// way copies of a set of layers keep pointing at each other. A reference to a layer outside the
|
||||
/// set is cleared: it named a layer of the nested comp that is no longer there.
|
||||
fn remap_layer_values(props: &mut effectcraft_project::PropGroup, map: &[(LayerId, LayerId)]) {
|
||||
let fix = |v: &mut effectcraft_keyframe::Value| {
|
||||
if let effectcraft_keyframe::Value::Layer(Some(id)) = v {
|
||||
*v = effectcraft_keyframe::Value::Layer(map.iter().find(|(o, _)| o.0 == *id).map(|(_, n)| n.0));
|
||||
}
|
||||
};
|
||||
for c in &mut props.children {
|
||||
match c {
|
||||
effectcraft_project::Node::Prop(p) => {
|
||||
fix(&mut p.value);
|
||||
for k in &mut p.keys {
|
||||
fix(&mut k.value);
|
||||
}
|
||||
}
|
||||
effectcraft_project::Node::Group(g) => remap_layer_values(g, map),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Layer ▸ Un-precompose: expand a selected pre-comp layer back into its composition. The nested
|
||||
/// comp's layers become copies in this comp at the pre-comp layer's place, in their order and
|
||||
/// with their keyframes, effects, masks, parenting, mattes, markers and audio; their timing is
|
||||
/// moved by the pre-comp layer's Start and trimmed to the span it showed, and an Anchor Point
|
||||
/// different from Position moves the unparented ones by the difference. The nested comp stays in
|
||||
/// the project. The nested comp's frame no longer clips what the layers draw outside it.
|
||||
///
|
||||
/// A locked pre-comp layer is refused, changing nothing. Anything else that would change the
|
||||
/// picture or drop something (see [`unprecompose_warnings`]) is an error listing the reasons unless
|
||||
/// `force` is set; with `force` the layer is expanded the same way, what the pre-comp layer itself
|
||||
/// carried (effects, masks, styles, transform beyond Position, time remap, blending mode, switches,
|
||||
/// parent, matte) goes with it, and the other layers' parent, matte and property links to it are
|
||||
/// removed. The result lists the reasons as `warnings`; `dryRun` only lists them.
|
||||
pub(super) fn unprecompose(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "layer.unprecompose";
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
let [lid] = ids[..] else { return Err(bad(c, "select exactly one pre-comp layer")) };
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
|
||||
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?.clone();
|
||||
let LayerSource::Comp { item } = layer.source else { return Err(bad(c, format!("“{}” is not a pre-comp layer", layer.name))) };
|
||||
if layer.switches.locked {
|
||||
return Err(bad(c, format!("can't un-precompose “{}”: the layer is locked (unlock it first)", layer.name)));
|
||||
}
|
||||
let nested = s.project.comp(item).ok_or_else(|| bad(c, "the layer's composition is missing"))?.clone();
|
||||
let warnings = unprecompose_warnings(&comp, &layer, &nested);
|
||||
if b_p(p, "dryRun").unwrap_or(false) {
|
||||
return Ok(json!({"dryRun": true, "warnings": warnings}));
|
||||
}
|
||||
if !warnings.is_empty() && !b_p(p, "force").unwrap_or(false) {
|
||||
let why = warnings.join("; ");
|
||||
return Err(bad(
|
||||
c,
|
||||
format!(
|
||||
"can't un-precompose “{}” without changing it: {why} (nothing changed: pass `force` to unpack anyway, or `dryRun` to list the reasons)",
|
||||
layer.name
|
||||
),
|
||||
));
|
||||
}
|
||||
let (dx, dy) = placement_offset(&layer);
|
||||
let shown = (layer.in_point.max(layer.start_time), layer.out_point.min(layer.start_time + nested.duration));
|
||||
let new = s.edit("Un-precompose", None, |proj, st| {
|
||||
let mut next = proj.next_id;
|
||||
let mut copies: Vec<Layer> = nested.layers.iter().cloned().collect();
|
||||
let mut map = vec![];
|
||||
for l in &mut copies {
|
||||
let old = l.id;
|
||||
crate::cmd::edit::reid(l, &mut next);
|
||||
map.push((old, l.id));
|
||||
}
|
||||
let link = |id: LayerId| map.iter().find(|(o, _)| *o == id).map(|(_, n)| *n);
|
||||
for l in &mut copies {
|
||||
l.parent = l.parent.and_then(link);
|
||||
l.track_matte = l.track_matte.and_then(|m| link(m.layer).map(|layer| TrackMatte { layer, ..m }));
|
||||
remap_layer_values(&mut l.props, &map);
|
||||
l.start_time += layer.start_time;
|
||||
l.in_point = (l.in_point + layer.start_time).clamp(shown.0, shown.1.max(shown.0));
|
||||
l.out_point = (l.out_point + layer.start_time).clamp(l.in_point, shown.1.max(l.in_point));
|
||||
if l.parent.is_none()
|
||||
&& let Some(tr) = l.transform_mut()
|
||||
{
|
||||
super::offset_position(tr, dx, dy);
|
||||
}
|
||||
}
|
||||
proj.next_id = next;
|
||||
let target = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
detach_from(target, lid);
|
||||
let at = target.layers.iter().position(|l| l.id == lid).unwrap_or(0);
|
||||
target.layers.retain(|l| l.id != lid);
|
||||
let mut made = vec![];
|
||||
for (k, mut l) in copies.into_iter().enumerate() {
|
||||
l.name = target.unique_layer_name(&l.name);
|
||||
made.push(l.id);
|
||||
target.layers.insert((at + k).min(target.layers.len()), l);
|
||||
}
|
||||
for m in &nested.markers {
|
||||
let mut m = m.clone();
|
||||
m.time += layer.start_time;
|
||||
target.markers.push(m);
|
||||
}
|
||||
// Comp markers are kept in time order (`thisComp.marker.key(n)`, next/previous marker).
|
||||
target.markers.sort_by_key(|m| m.time);
|
||||
st.selected_layers = made.clone();
|
||||
Ok(made)
|
||||
})?;
|
||||
Ok(json!({"layers": new.iter().map(|l| l.0).collect::<Vec<_>>(), "warnings": warnings}))
|
||||
}
|
||||
|
||||
@@ -157,7 +157,7 @@ pub(super) fn transform_op(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// layers, which are solids) edit their Solid Settings. With `affectAll: false`, a solid other
|
||||
/// layers also use is copied into a new solid for this layer instead of being changed for all of
|
||||
/// them (After Effects' "Affect all layers that use this solid"). `name` renames the layer and
|
||||
/// its solid.
|
||||
/// its solid; an empty one makes a text layer's name follow its text again.
|
||||
pub(super) fn layer_settings(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, lid) = layer_p(s, p, "layer.settings")?;
|
||||
// Cameras and lights open their own settings (Camera Settings / Light Settings).
|
||||
@@ -171,11 +171,18 @@ pub(super) fn layer_settings(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let h = p.get("height").and_then(Value::as_u64).map(limits::size);
|
||||
let pixel_aspect = pixel_aspect_p(p)?;
|
||||
let name = str_p(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
|
||||
// An empty name gives a text layer its text's name again, as `layer.rename` does (#3233);
|
||||
// other layers keep theirs.
|
||||
let follow_text = str_p(p, "name").is_some_and(|n| n.trim().is_empty());
|
||||
let affect_all = p.get("affectAll").and_then(Value::as_bool).unwrap_or(true);
|
||||
let t = s.time();
|
||||
let solid = s.edit("Layer Settings", None, |proj, _| {
|
||||
let src = layer_mut(proj, cid, lid)?.source.clone();
|
||||
if let Some(n) = &name {
|
||||
layer_mut(proj, cid, lid)?.name = n.clone();
|
||||
// A user name: a text layer's name stops following its text (#3233).
|
||||
super::switches::set_layer_name(layer_mut(proj, cid, lid)?, n, t);
|
||||
} else if follow_text && matches!(src, LayerSource::Text) {
|
||||
super::switches::set_layer_name(layer_mut(proj, cid, lid)?, "", t);
|
||||
}
|
||||
let LayerSource::Solid { item } = src else { return Ok(None) };
|
||||
let shared = proj.comps().flat_map(|(_, c)| &c.layers).filter(|l| l.source == src).count() > 1;
|
||||
|
||||
@@ -59,9 +59,16 @@ fn time_stretch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(None, Some(d)) if span > 0.0 => old * d / span,
|
||||
_ => old,
|
||||
};
|
||||
if !new.is_finite() {
|
||||
return Err(bad("layer.timeStretch", "the stretch factor must be a number"));
|
||||
}
|
||||
if new.abs() < 1.0 {
|
||||
return Err(bad("layer.timeStretch", "the stretch factor must be at least 1 %"));
|
||||
}
|
||||
// After Effects' largest stretch; a project file holds no more either (#3247).
|
||||
if new.abs() > effectcraft_project::limits::MAX_STRETCH {
|
||||
return Err(bad("layer.timeStretch", "the stretch factor must be at most 9900 %"));
|
||||
}
|
||||
// The hold point keeps showing the same layer time.
|
||||
let h = match hold {
|
||||
"current" => cti,
|
||||
@@ -194,6 +201,7 @@ fn freeze(s: &mut Session, p: &Value, last: bool) -> Result<Value> {
|
||||
footage: Some(s.footage.as_ref()),
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
reads: None,
|
||||
};
|
||||
let now: Vec<(effectcraft_project::LayerId, f64)> = comp
|
||||
.layers
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Commands behind the Progress, Media Browser, Metadata and Lumetri Scopes panels.
|
||||
//! Commands behind the Progress, Media Browser, Metadata and Video Scopes panels.
|
||||
|
||||
use effectcraft_project::ItemId;
|
||||
use effectcraft_raster::Image;
|
||||
@@ -159,7 +159,7 @@ fn project_comment(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Lumetri Scopes
|
||||
// ---------------------------------------------------------------- Video Scopes
|
||||
|
||||
/// The comp frame the scopes read (the viewer's image: over the comp background), at most
|
||||
/// `max_side` pixels on the long side.
|
||||
@@ -257,7 +257,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("project.setProjectComment", "Project Comment", [], None, "{comment}", always, project_comment),
|
||||
query!(
|
||||
"scopes.analyze",
|
||||
"Lumetri Scopes",
|
||||
"Video Scopes",
|
||||
"{comp?, time?|frame?, scope?: waveformRgb|waveformLuma|waveformYc|vectorscopeYuv|vectorscopeHls|histogram|paradeRgb|paradeYuv, standard?: rec601|rec709|rec2020, float?, clamp?, size?}",
|
||||
scopes_analyze
|
||||
),
|
||||
|
||||
@@ -73,6 +73,8 @@ struct PathInfo {
|
||||
span: (f64, f64),
|
||||
}
|
||||
|
||||
/// Reads a mask or shape path at the current time and returns its comp-space vertices, value, expression reference and layer name.
|
||||
/// Errors when the property is missing, is not a path or has no vertices.
|
||||
fn info(s: &Session, cid: ItemId, lid: LayerId, uid: Uid, cmd: &str) -> Result<PathInfo> {
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
|
||||
@@ -87,6 +89,7 @@ fn info(s: &Session, cid: ItemId, lid: LayerId, uid: Uid, cmd: &str) -> Result<P
|
||||
footage: Some(s.footage.as_ref()),
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
reads: None,
|
||||
};
|
||||
let KV::Path(path) = ctx.value(layer, pr) else { return Err(bad(cmd, "that property is not a path")) };
|
||||
if path.vertices.is_empty() {
|
||||
@@ -165,6 +168,7 @@ fn pin_targets(s: &Session, p: &Value, cmd: &str) -> Result<Option<(ItemId, Vec<
|
||||
footage: Some(s.footage.as_ref()),
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
reads: None,
|
||||
};
|
||||
let mut out = vec![];
|
||||
for (_, lid, uid) in pins {
|
||||
|
||||
@@ -1380,6 +1380,8 @@ fn convert_expr_to_keys(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
convert_one_expr(s, cid, lid, uid)
|
||||
}
|
||||
|
||||
/// Replaces the expression on one property with keyframes sampled from its evaluated values.
|
||||
/// Returns the number of properties converted.
|
||||
fn convert_one_expr(s: &mut Session, cid: ItemId, lid: LayerId, uid: Uid) -> Result<Value> {
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
|
||||
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?.clone();
|
||||
@@ -1397,6 +1399,7 @@ fn convert_one_expr(s: &mut Session, cid: ItemId, lid: LayerId, uid: Uid) -> Res
|
||||
footage: Some(s.footage.as_ref()),
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
reads: None,
|
||||
};
|
||||
keys.push(Keyframe::new(layer.layer_time(t), ctx.value(&layer, &pr)));
|
||||
t += fd;
|
||||
|
||||
@@ -39,8 +39,17 @@ pub fn puppet_eval(s: &Session, cid: ItemId, lid: LayerId, fx_uid: Uid, t: Tick)
|
||||
let fx = layer.effects()?;
|
||||
let index = fx.groups().position(|g| g.uid == fx_uid)?;
|
||||
let g = fx.groups().nth(index)?;
|
||||
let ctx =
|
||||
EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None, property_overrides: &[], media_overrides: &[] };
|
||||
let ctx = EvalCtx {
|
||||
project: &s.project,
|
||||
comp_id: cid,
|
||||
comp,
|
||||
time: t,
|
||||
expr: s.expr.as_deref(),
|
||||
footage: None,
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
reads: None,
|
||||
};
|
||||
let mut r = Renderer::new(&s.project, &*s.footage, RenderOpts::default());
|
||||
r.expr = s.expr.as_deref();
|
||||
r.cache = Some(&s.layer_cache);
|
||||
|
||||
@@ -734,7 +734,11 @@ fn stop(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
s.poll_render();
|
||||
// The suspended script's frontend applies post-render edits before resuming it. A
|
||||
// control inspection must not apply those edits while the undo-group guard is active.
|
||||
if s.script_ui.render_host.is_none() {
|
||||
s.poll_render();
|
||||
}
|
||||
let items: Vec<Value> = s.project.render_queue.iter().enumerate().map(|(k, i)| item_json(s, i, k)).collect();
|
||||
Ok(json!({"items": items, "rendering": s.is_rendering(), "progress": s.render_progress()}))
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ impl Env {
|
||||
fn of(s: &Session, cid: ItemId) -> Env {
|
||||
Env { time: s.time_of(cid), expr: s.expr.clone(), footage: s.footage.clone() }
|
||||
}
|
||||
/// The evaluation context of `comp` in `proj` at the session's current time.
|
||||
fn ctx<'a>(&'a self, proj: &'a Project, cid: ItemId, comp: &'a Comp) -> EvalCtx<'a> {
|
||||
EvalCtx {
|
||||
project: proj,
|
||||
@@ -57,6 +58,7 @@ impl Env {
|
||||
footage: Some(self.footage.as_ref()),
|
||||
property_overrides: &[],
|
||||
media_overrides: &[],
|
||||
reads: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! Positions are character indices. The selection is `anchor..caret` (either order).
|
||||
|
||||
use effectcraft_keyframe::{CharStyle, TextDoc, Value as KV};
|
||||
use effectcraft_project::{ItemId, LayerId, LayerSource};
|
||||
use effectcraft_project::{ItemId, LayerId, LayerSource, text_layer_name};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -288,10 +288,6 @@ fn session_on(s: &mut Session, lid: LayerId) -> TextEdit {
|
||||
}
|
||||
}
|
||||
|
||||
fn first_line_name(text: &str) -> String {
|
||||
text.lines().next().unwrap_or("").chars().take(40).collect()
|
||||
}
|
||||
|
||||
/// Edit a layer's Source Text at the CTI (one undo step, or merged by `merge`), then set the
|
||||
/// session's selection.
|
||||
pub(crate) fn edit_doc(
|
||||
@@ -319,12 +315,13 @@ pub(crate) fn edit_doc(
|
||||
// already has) must not turn a static Str into Text or add a keyframe at the CTI: that
|
||||
// would change the project and record an empty undo step.
|
||||
if doc != original {
|
||||
let before = first_line_name(&original.text);
|
||||
let after = first_line_name(&doc.text);
|
||||
let before = text_layer_name(&original.text);
|
||||
let after = text_layer_name(&doc.text);
|
||||
pr.set_value_at(lt, KV::Text(Box::new(doc)));
|
||||
// Text layers are named after their text until renamed.
|
||||
if before != after && !after.is_empty() && (l.name == before || l.name.starts_with("Text") || l.name.is_empty()) {
|
||||
// Text layers are named after their text until renamed (#3233).
|
||||
if before != after && !after.is_empty() && l.name_follows_text(&before) {
|
||||
l.name = after;
|
||||
l.name_follows_text = Some(true);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user