Merge remote-tracking branch 'upstream/main' into rq-readback-3335

# Conflicts:
#	crates/render/src/lib.rs
This commit is contained in:
vangel76
2026-11-09 16:06:16 +01:00
105 changed files with 3208 additions and 987 deletions
+2 -2
View File
@@ -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
@@ -99,7 +99,7 @@ Screenshots of EffectCraft itself, rendered headlessly, showing only procedurall
(sidecars `.attribution`).
- Bundled extension (ScriptUI panel) with its twelve built-in ease presets:
`extensions/scriptui-panels/Ease Presets.jsx` (sidecar `.attribution`).
- Example effect plug-in: `examples/plugins/posterize-bands` (plug-in API v1, WebAssembly).
- Example effect plug-in: `examples/plugins/posterize-bands` (WebAssembly ABI 1).
## Trained models (optional downloads, not bundled)
Generated
+1 -14
View File
@@ -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",
@@ -7959,7 +7959,6 @@ dependencies = [
"nix",
"typed-path 0.13.0",
"windows-sys 0.61.2",
"zopfli",
]
[[package]]
@@ -7974,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
View File
@@ -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" }
+3 -7
View File
@@ -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,
+4 -4
View File
@@ -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;
+7 -1
View File
@@ -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
+9 -4
View File
@@ -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.
@@ -1172,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],
@@ -1319,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 -2
View File
@@ -285,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,
@@ -295,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()
};
@@ -327,6 +330,14 @@ 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> {
@@ -453,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)
}
@@ -582,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);
}
@@ -906,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);
+13 -1
View File
@@ -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;
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
View File
@@ -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)
+94
View 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
View File
@@ -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))
+39
View File
@@ -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();
+1
View File
@@ -4,6 +4,7 @@
mod helpers;
mod aepx;
mod ai_mask;
mod blend_modes;
mod cameras;
mod cases;
+113
View File
@@ -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);
}
}
}
}
+1 -1
View File
@@ -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);
+8 -8
View File
@@ -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)),
+4 -4
View File
@@ -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)),
+4 -4
View File
@@ -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)),
+4 -4
View File
@@ -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),
]
}
+7 -7
View File
@@ -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 {
@@ -834,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),
@@ -860,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),
]
}
+123
View File
@@ -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}");
}
}
}
+11 -11
View File
@@ -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)),
+19 -19
View File
@@ -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),
+4 -4
View File
@@ -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)),
+6 -6
View File
@@ -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()),
+2 -2
View File
@@ -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)),
+2 -2
View File
@@ -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),
+5 -5
View File
@@ -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()));
+85 -8
View File
@@ -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;
@@ -560,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
@@ -776,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] {
@@ -861,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"),
@@ -990,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"),
@@ -1075,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",
@@ -1437,7 +1514,7 @@ pub const TIME_DEPENDENT: &[&str] = &[
pub fn is_time_dependent(id: &str) -> bool {
// Simulations replay from layer time 0, so every frame differs even when the defaults
// happen to look static at the test's two sample times.
id.starts_with("ec.sim.") || TIME_DEPENDENT.contains(&id)
id.starts_with("ec.sim.") || TIME_DEPENDENT.contains(&id) || (!id.starts_with("ec.") && plugin::reads_other_times(id))
}
/// Does effect `id` read its own parameters at earlier times (the stepped particle systems sample
@@ -1495,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() {
+2 -2
View File
@@ -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"])),
+6 -6
View File
@@ -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),
+3 -3
View File
@@ -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"])),
+40 -7
View File
@@ -24,10 +24,12 @@
//! * rendering must be deterministic (same inputs, same output) and may run on several threads
//! at once.
//!
//! API 2 (native plug-ins; the WebAssembly ABI stays at 1) adds [`EffectPlugin::padding`] and
//! [`EffectPlugin::render_v2`], which gets a [`PluginHost`] to read the effect's input or other
//! layers at other times, and the extra parameter kinds. External effect helpers
//! (`effectcraft-helper-host`) are built on it.
//! API 2 (native plug-ins) adds [`EffectPlugin::padding`] and [`EffectPlugin::render_v2`], which
//! gets a [`PluginHost`] to read the effect's input or other layers at other times, and the extra
//! parameter kinds. External effect helpers (`effectcraft-helper-host`) are built on it.
//!
//! The WebAssembly ABI is versioned separately ([`WASM_API_VERSION`]): ABI 1 is `ec_render`;
//! ABI 2 adds `ec_render2`, which receives the layer's previous and next frames.
use std::sync::{Arc, OnceLock, RwLock};
@@ -39,9 +41,12 @@ use crate::{Buf, EffectCtx, EffectSpec, ParamSpec, Params, RenderFn};
/// The plug-in API version this build implements (manifests with API 1 or 2 are accepted).
pub const PLUGIN_API_VERSION: u32 = 2;
/// The plug-in API a WebAssembly module implements (its `ec_api_version`). API 2 is for native
/// and helper plug-ins; WebAssembly modules stay at 1.
pub const WASM_API_VERSION: u32 = 1;
/// The newest WebAssembly ABI this build implements (a module's `ec_api_version`). ABI 1 is
/// `ec_render`; ABI 2 adds `ec_render2` and the layer's neighbouring frames. This is separate
/// from the plug-in API version: a module's manifest `api` stays 1 or 2.
pub const WASM_API_VERSION: u32 = 2;
/// The oldest WebAssembly ABI this build still loads.
pub const MIN_WASM_API_VERSION: u32 = 1;
/// The most plug-in effects one process can register (commercial effect suites register a few hundred each).
pub const MAX_PLUGINS: usize = 1024;
@@ -269,6 +274,22 @@ impl PluginImage {
fn from_buf(b: Buf) -> PluginImage {
PluginImage { width: b.img.width, height: b.img.height, pixels: b.img.data, origin: b.offset, scale: b.scale }
}
/// This image resampled onto a `width × height` buffer grid at `origin`/`scale` (the current
/// frame's); the pixels unchanged when the grids already match.
pub fn on_grid(&self, width: u32, height: u32, origin: [f64; 2], scale: f64) -> Vec<[f32; 4]> {
if self.width == width && self.height == height && self.origin == origin && self.scale == scale {
return self.pixels.clone();
}
let k = self.scale / scale.max(1e-9);
let src = crate::Image { width: self.width, height: self.height, data: self.pixels.clone() };
crate::util::gen_image(width, height, |x, y| {
let lx = (x as f64 + 0.5 - origin[0]) * k + self.origin[0];
let ly = (y as f64 + 0.5 - origin[1]) * k + self.origin[1];
src.sample_bilinear(lx, ly)
})
.data
}
}
/// Services available during an API 2 render ([`EffectPlugin::render_v2`]).
@@ -388,6 +409,12 @@ pub trait EffectPlugin: Send + Sync {
fn render_v2(&self, frame: &mut PluginFrame, params: &PluginParams, time: f64, _host: &dyn PluginHost) -> Result<(), String> {
self.render(frame, params, time)
}
/// Whether rendering reads the layer at other times (its neighbouring frames). Default
/// false; the render cache keys a layer with such an effect on its layer time. WebAssembly
/// ABI 2 plug-ins return true.
fn reads_other_times(&self) -> bool {
false
}
}
static SLOTS: [OnceLock<Arc<dyn EffectPlugin>>; MAX_PLUGINS] = [const { OnceLock::new() }; MAX_PLUGINS];
@@ -432,6 +459,12 @@ pub fn plugin(id: &str) -> Option<Arc<dyn EffectPlugin>> {
SLOTS.iter().filter_map(|s| s.get()).find(|p| p.manifest().id == id).cloned()
}
/// Whether the registered plug-in `id` reads the layer at other times (its neighbouring frames),
/// so the render cache must key its layer on the layer time.
pub fn reads_other_times(id: &str) -> bool {
SLOTS.iter().filter_map(|s| s.get()).any(|p| p.manifest().id == id && p.reads_other_times())
}
fn leak(s: &str) -> &'static str {
Box::leak(s.to_string().into_boxed_str())
}
+8 -8
View File
@@ -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)),
+3 -3
View File
@@ -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),
+5 -5
View File
@@ -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)),
+9 -9
View File
@@ -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()),
+14 -14
View File
@@ -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),
]
}
+5 -5
View File
@@ -1,9 +1,9 @@
//! 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;
//! 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).
@@ -123,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),
@@ -134,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)),
@@ -994,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;
+5 -5
View File
@@ -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),
+14 -14
View File
@@ -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),
+1 -1
View File
@@ -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),
+1 -1
View File
@@ -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() }
}
+25 -4
View File
@@ -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)?;
+1 -1
View File
@@ -1352,7 +1352,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Load Effect Plug-in...",
[],
None,
"{path (.wasm / .wat plug-in, API v1) | folder (loads every .wasm in it)}",
"{path (.wasm / .wat plug-in, WebAssembly ABI 1 or 2) | folder (loads every .wasm in it)}",
super::always,
plugins_load
),
+4 -4
View File
@@ -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" },
+3 -3
View File
@@ -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
),
+7 -1
View File
@@ -176,9 +176,11 @@ fn psd_layer(s: &Session, path: &str, l: &SourceLayer) -> Option<(u32, Option<(u
Some((u32::try_from(found.index).ok()?, size))
}
/// The pixel size of vector footage `f` as its file is now (its page, framing and layer), or
/// `None` when the file cannot be read or shows nothing.
fn vector_size(s: &Session, f: &Footage) -> Option<(u32, u32)> {
let bytes = s.services.read_file(&f.path).ok()?;
let doc = crate::vector::vector_doc(&f.path, &bytes, f.layer.as_ref(), f.page)?.ok()?;
let doc = crate::vector::footage_doc(f, &bytes)?.ok()?;
(!doc.root.children.is_empty()).then(|| doc.pixel_size())
}
@@ -229,6 +231,10 @@ fn refreshed(s: &Session, f: &Footage, probed: Footage) -> Footage {
// The layer is gone from the file: keep the item as it was.
None => (w, h) = (f.width, f.height),
},
// A placed page of an After Effects project keeps the size After Effects stored: the
// layers' anchors and masks are measured against it, and the page is drawn to fill it
// (the probe would measure the page as a viewer frames it, #3099).
None if f.placed_page => (w, h) = (f.width, f.height),
// A later page of a PDF: the probe measures the first page.
None if f.page != 0 => (w, h) = (f.width, f.height),
None => {}
+1 -1
View File
@@ -238,7 +238,7 @@ fn showcase_3d(p: &mut Project, solids: ItemId) -> Comp {
anim(&mut cam, "cameraOptions/focusDistance", keys(&[(0.0, Value::Scalar(1700.0)), (5.0, Value::Scalar(1900.0)), (10.0, Value::Scalar(1650.0))]));
// Key light: warm spot casting soft shadows. Fill: cool ambient.
let mut key = build::layer(p, &c, "Key Light", LayerSource::Light { kind: effectcraft_project::LightKind::Spot }, (1920, 1080), None);
let mut key = build::layer(p, &c, "Screen Color Key", LayerSource::Light { kind: effectcraft_project::LightKind::Spot }, (1920, 1080), None);
set(&mut key, "transform/position", Value::Vec3([500.0, -500.0, -700.0]));
set(&mut key, "transform/poi", Value::Vec3([960.0, 600.0, 300.0]));
set(&mut key, "lightOptions/intensity", Value::Scalar(150.0));
+1 -1
View File
@@ -259,7 +259,7 @@ pub fn tutorials() -> Vec<Tutorial> {
target: Some("menu.Layer"),
accepts: &["layer.newLight"],
when: Value::Null,
show: vec![("layer.newLight", json!({"kind": "Spot", "name": "Key Light"}))],
show: vec![("layer.newLight", json!({"kind": "Spot", "name": "Screen Color Key"}))],
}),
step(S {
title: "Add a camera",
+61 -3
View File
@@ -261,15 +261,73 @@ pub fn is_vector(f: &effectcraft_project::Footage) -> bool {
effectcraft_render::is_vector_footage(f) || f.path.to_ascii_lowercase().ends_with(".svg")
}
/// `doc` (the document of footage `f`) in the footage's layer pixels, as the renderer draws it
/// ([`effectcraft_render::vector_fit`]): a placed page of an After Effects project is scaled to
/// fill the size stored for it; other footage is unchanged.
pub fn fitted_to_footage(mut doc: Doc, f: &effectcraft_project::Footage) -> Doc {
let (_, [sx, sy]) = effectcraft_render::vector_fit(f, [doc.width, doc.height], doc.pixel_size(), 1.0);
if f.placed_page {
doc.root.transform = Affine::scale_non_uniform(sx, sy) * doc.root.transform;
(doc.width, doc.height) = (doc.width * sx, doc.height * sy);
}
doc
}
/// Create Shapes from Vector Layer on a placed page of an After Effects project: a hidden shape
/// layer filling the frame the footage is drawn in (its `width` × `height` raster, in the layer
/// pixels of [`fitted_to_footage`]'s document), to be the Alpha track matte of every layer made
/// from the page, so bleed, art past the artboard and the page's background stay hidden as on
/// the footage (#3099). `None` for other footage, whose shapes are converted whole as before.
///
/// A track matte is drawn at the composition's resolution, so its edge is the footage's raster
/// edge on shape and image layers alike; a mask on an image layer would be drawn at the image's
/// own resolution and blur its edge when the image is scaled up.
pub fn frame_matte(proj: &mut Project, comp: &Comp, src: &Layer, f: &effectcraft_project::Footage) -> Option<Layer> {
if !f.placed_page || f.width == 0 || f.height == 0 {
return None;
}
let svg = format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="{0}" height="{1}"><rect width="{0}" height="{1}" fill="#ffffff"/></svg>"##,
f.width, f.height
);
let doc = effectcraft_svg::parse(svg.as_bytes()).ok()?;
let mut l = build::layer(proj, comp, &format!("{} Frame", src.name), LayerSource::Shape, (0, 0), None);
add_doc_shapes(proj, &mut l, &doc);
copy_transform(proj, src, &mut l);
like_source(&mut l, src);
// The layers it mattes carry the source's opacity already.
if let Some(o) = l.props.prop_mut("transform/opacity") {
o.value = Value::Scalar(100.0);
o.keys.clear();
o.expr = None;
}
l.name = comp.unique_layer_name(&format!("{} Frame", src.name));
l.switches.video = false;
Some(l)
}
/// The vector document of footage `f` (its file read as `bytes`; `None` for other formats): its
/// page, framed as [`effectcraft_project::Footage::placed_page`] says, restricted to its layer.
pub fn footage_doc(f: &effectcraft_project::Footage, bytes: &[u8]) -> Option<Result<Doc, String>> {
let framing = if f.placed_page { effectcraft_pdf::Framing::Placed } else { effectcraft_pdf::Framing::Viewer };
vector_doc(&f.path, bytes, f.layer.as_ref(), f.page, framing)
}
/// The vector document of an SVG / PDF / AI / EPS file (`None` for other formats): page
/// `page` of a PDF, restricted to one layer when `layer` names it.
pub fn vector_doc(path: &str, bytes: &[u8], layer: Option<&effectcraft_project::SourceLayer>, page: u32) -> Option<Result<Doc, String>> {
/// `page` of a PDF framed as `framing` says, restricted to one layer when `layer` names it.
pub fn vector_doc(
path: &str,
bytes: &[u8],
layer: Option<&effectcraft_project::SourceLayer>,
page: u32,
framing: effectcraft_pdf::Framing,
) -> Option<Result<Doc, String>> {
if path.to_ascii_lowercase().ends_with(".svg") || effectcraft_svg::looks_like_svg(bytes) {
return Some(effectcraft_svg::parse(bytes).map_err(|e| format!("{path}: {e}")));
}
effectcraft_pdf::sniff(bytes)?;
Some(
effectcraft_pdf::parse_page(bytes, page as usize)
effectcraft_pdf::parse_page_framed(bytes, page as usize, framing)
.map(|d| match layer {
Some(l) => effectcraft_pdf::layer_doc_with_sizes(&d, l.index as usize, l.layer_size).0,
None => d,
+1 -1
View File
@@ -1263,7 +1263,7 @@ Window
Essential Graphics | window.panel {"panel":"essentialGraphics"}
Info | window.panel {"panel":"info"} | Cmd+2
Learn | help.inAppTutorials
Lumetri Scopes | window.panel {"panel":"lumetriScopes"}
Video Scopes | window.panel {"panel":"lumetriScopes"}
Mask Interpolation | window.panel {"panel":"maskInterpolation"}
Media Browser | window.panel {"panel":"mediaBrowser"}
Metadata | window.panel {"panel":"metadata"}
+19
View File
@@ -23,6 +23,25 @@ fn platform_entries(mac: bool) -> Vec<(Vec<String>, MenuEntry)> {
out
}
/// Guard the generated display tree, not command IDs, format tokens or dynamic user names.
#[test]
fn builtin_menu_labels_do_not_reintroduce_vendor_feature_names() {
for mac in [true, false] {
let entries = platform_entries(mac);
for (path, entry) in &entries {
for label in path.iter().chain(std::iter::once(&entry.label)) {
let folded = label.to_lowercase();
assert!(!folded.starts_with("cc "), "{label}");
for mark in ["lumetri", "mercury", "mocha", "keylight"] {
assert!(!folded.contains(mark), "{label}");
}
}
}
let scopes = entries.iter().find(|(_, e)| e.command == "window.panel" && e.params["panel"] == "lumetriScopes").unwrap();
assert_eq!(scopes.1.label, "Video Scopes");
}
}
#[test]
fn top_level_order_matches_after_effects() {
let mac: Vec<String> =
+7 -4
View File
@@ -304,7 +304,8 @@ pub fn parse_catalog(bytes: &[u8]) -> std::result::Result<(Catalog, Vec<String>)
fn runs_here(r: &Release) -> bool {
// Without a pre-release suffix (`0.18.0-dev` is 0.18.0).
let app = env!("CARGO_PKG_VERSION").split(['-', '+']).next().unwrap_or_default();
r.api == crate::effects::plugin::WASM_API_VERSION && r.min_app.as_deref().is_none_or(|m| !newer(m, app))
(crate::effects::plugin::MIN_WASM_API_VERSION..=crate::effects::plugin::WASM_API_VERSION).contains(&r.api)
&& r.min_app.as_deref().is_none_or(|m| !newer(m, app))
}
/// The newest release this build can run (plug-in API and minimum app version).
@@ -789,12 +790,13 @@ fn pick(s: &Session, c: &str, p: &Value) -> Result<(String, Package, Release)> {
return Err(bad(
c,
format!(
"{} {} needs plug-in API {} and EffectCraft {} or later; this is EffectCraft {} with WebAssembly plug-in API {}",
"{} {} needs WebAssembly ABI {} and EffectCraft {} or later; this is EffectCraft {} with WebAssembly ABI {} to {}",
pkg.name,
rel.version,
rel.api,
rel.min_app.as_deref().unwrap_or("(any)"),
env!("CARGO_PKG_VERSION"),
crate::effects::plugin::MIN_WASM_API_VERSION,
crate::effects::plugin::WASM_API_VERSION
),
));
@@ -1125,8 +1127,9 @@ mod tests {
..Default::default()
};
assert_eq!(latest_compatible(&p).map(|r| r.version.as_str()), Some("1.2.0"));
assert!(runs_here(&rel("1.0.0", 1, None)), "API 1 modules still run on a build with a newer plug-in API");
assert!(!runs_here(&rel("1.0.0", 2, None)), "WebAssembly modules are API 1: an API 2 release can't load");
assert!(runs_here(&rel("1.0.0", 1, None)), "ABI 1 modules still run on a build with a newer ABI");
assert!(runs_here(&rel("1.0.0", 2, None)), "ABI 2 modules run on this build");
assert!(!runs_here(&rel("1.0.0", 3, None)), "a WebAssembly ABI this build doesn't implement can't load");
let j = package_json(&p, None, &Status::default());
assert_eq!(j["needsNewerApp"], true);
assert!(latest_compatible(&Package::default()).is_none());
+2 -2
View File
@@ -439,7 +439,7 @@ const RIGGED: &str = r#"{
],
"cameras": [{"type": "perspective", "name": "Shot", "perspective": {"yfov": 0.6, "znear": 0.1}}],
"extensions": {"KHR_lights_punctual": {"lights": [
{"type": "spot", "name": "Key Light", "color": [1, 0.8, 0.6], "intensity": 1.5, "spot": {"innerConeAngle": 0.25, "outerConeAngle": 0.5}},
{"type": "spot", "name": "Screen Color Key", "color": [1, 0.8, 0.6], "intensity": 1.5, "spot": {"innerConeAngle": 0.25, "outerConeAngle": 0.5}},
{"type": "point", "name": "Fill Light", "intensity": 0.5, "range": 4}
]}}
}"#;
@@ -478,7 +478,7 @@ fn cameras_and_lights_from_model() {
let r = s.execute("light.fromModel", json!({})).unwrap();
let ls: Vec<Layer> = r["layers"].as_array().unwrap().iter().map(|v| layer_in(&s, cid(&s), v.as_u64().unwrap())).collect();
assert_eq!(ls.len(), 2);
let key = ls.iter().find(|l| l.name == "Key Light").unwrap();
let key = ls.iter().find(|l| l.name == "Screen Color Key").unwrap();
assert_eq!(key.source, LayerSource::Light { kind: LightKind::Spot });
assert!((key.props.prop("lightOptions/coneAngle").unwrap().value.as_f64() - 1.0f64.to_degrees()).abs() < 1e-6);
assert!((key.props.prop("lightOptions/coneFeather").unwrap().value.as_f64() - 50.0).abs() < 1e-6);
+29 -1
View File
@@ -1221,7 +1221,35 @@ fn render_backend_says_why_it_renders_on_the_cpu() {
assert_eq!(why(&mut s), json!("no GPU compositor is attached"));
s.accel_note = Some("headless: renders on the CPU unless started with --gpu".into());
assert_eq!(why(&mut s), json!("headless: renders on the CPU unless started with --gpu"));
assert_eq!(s.execute("render.backend", json!({"backend": "cpu"})).unwrap()["why"], json!("the project's renderer is Mercury Software Only"));
assert_eq!(s.execute("render.backend", json!({"backend": "cpu"})).unwrap()["why"], json!("the project's renderer is Software Rendering"));
}
/// Display labels are descriptive; machine-readable settings remain the same.
#[test]
fn renderer_labels_keep_their_stable_machine_values() {
let mut s = Session::default();
for (backend, label, gpu) in [("cpu", "Software Rendering", false), ("gpu", "GPU Acceleration", true)] {
let status = s.execute("render.backend", json!({"backend": backend})).unwrap();
assert_eq!(status["renderer"], json!(label));
assert_eq!(status["gpuAcceleration"], json!(gpu));
assert_eq!(status["active"], json!("cpu"), "the headless test has no GPU adapter");
}
}
/// The label `render.backend` reports is one it accepts back, so a caller can round-trip the
/// status instead of guessing the command's own values.
#[test]
fn renderer_status_labels_round_trip_through_the_command() {
let mut s = Session::default();
for (backend, label, gpu) in [("cpu", "Software Rendering", false), ("gpu", "GPU Acceleration", true)] {
let reported = s.execute("render.backend", json!({"backend": backend})).unwrap()["renderer"].clone();
assert_eq!(reported, json!(label));
let parsed = effectcraft_render::Backend::parse(reported.as_str().unwrap()).expect("the reported renderer parses back");
assert_eq!(parsed == effectcraft_render::Backend::Cpu, !gpu, "{reported}");
let again = s.execute("render.backend", json!({"backend": reported})).unwrap();
assert_eq!(again["renderer"], reported);
assert_eq!(again["gpuAcceleration"], json!(gpu));
}
}
/// Settings ▸ GPU Information names what the GPU compositor runs on, or why it is off, and the
+2 -2
View File
@@ -133,8 +133,8 @@ pub struct JobCaches<'a> {
pub cpu: &'a effectcraft_render::LayerCache,
}
/// The compositor a job's frames render on: the GPU's (Mercury GPU Acceleration, with an
/// accelerator) or the CPU's frame-parallel one (Mercury Software Only). The whole job renders
/// The compositor a job's frames render on: the GPU's (GPU Acceleration, with an
/// accelerator) or the CPU's frame-parallel one (Software Rendering). The whole job renders
/// on one of them, never a mix: the two differ slightly. A GPU project with an accelerator
/// measures both on its first frames and renders the job on the faster (#3335); the choice is
/// in the render log. `EFFECTCRAFT_RQ_RENDERER=gpu|cpu|auto` overrides the measurement.
+5 -5
View File
@@ -76,7 +76,7 @@ pub(crate) fn resolve_gpu_frames(env: Option<&str>, pref: Option<usize>, measure
}
/// Overrides the Render Queue's measured choice of compositor for GPU projects with an
/// accelerator (#3335): `gpu` (no measurement, the GPU), `cpu` (Mercury Software Only) or
/// accelerator (#3335): `gpu` (no measurement, the GPU), `cpu` (Software Rendering) or
/// `auto` (measure, the default).
pub const RENDERER_ENV: &str = "EFFECTCRAFT_RQ_RENDERER";
@@ -347,7 +347,7 @@ impl<'a> Cx<'a> {
cx
}
/// The project's compositor: the GPU's for Mercury GPU Acceleration with an accelerator.
/// The project's compositor: the GPU's for GPU Acceleration with an accelerator.
fn project_renderer(&self) -> crate::JobRenderer {
if self.project.settings.gpu_acceleration && self.job.accel.is_some() { crate::JobRenderer::Gpu } else { crate::JobRenderer::Cpu }
}
@@ -902,8 +902,8 @@ impl<'a> Cx<'a> {
/// The render log's account of a job's compositor: which, why, and what was measured.
pub(crate) fn describe_choice(c: &Choice) -> String {
let name = match c.renderer {
crate::JobRenderer::Gpu => "Mercury GPU Acceleration",
crate::JobRenderer::Cpu => "Mercury Software Only",
crate::JobRenderer::Gpu => "GPU Acceleration",
crate::JobRenderer::Cpu => "Software Rendering",
};
let ms = |v: Option<f64>| v.map_or_else(|| "n/a".to_string(), |v| format!("{v:.1} ms/frame"));
if c.gpu_ms.is_none() && c.cpu_ms.is_none() {
@@ -1229,7 +1229,7 @@ mod tests {
assert_eq!(cx.backend(), crate::JobRenderer::Cpu);
assert_eq!(cx.gpu_cap(), None);
assert_eq!(cx.renderer().active_accel().map(|a| a.name()), None, "CPU frames don't use the accelerator");
assert_eq!(describe_choice(&cx.choice().unwrap()), "Mercury Software Only (measured on the first frames: GPU 117.0 ms/frame, CPU 68.0 ms/frame)");
assert_eq!(describe_choice(&cx.choice().unwrap()), "Software Rendering (measured on the first frames: GPU 117.0 ms/frame, CPU 68.0 ms/frame)");
}
#[test]
+4 -4
View File
@@ -570,7 +570,7 @@ fn a_transfer_bound_gpu_job_renders_on_the_cpu() {
for (i, f) in frames.iter().enumerate() {
assert_eq!(f.get_pixel(10, 44).0, [255, 0, 0, 255], "frame {i} is not the CPU's");
}
assert!(log.contains("Renderer: Mercury Software Only (measured on the first frames: GPU "), "{log}");
assert!(log.contains("Renderer: Software Rendering (measured on the first frames: GPU "), "{log}");
// The queue item's next output module follows the first one's choice without measuring.
let shared = Mutex::new(None);
let opts = || JobOptions { renderer: Some(&shared), ..Default::default() };
@@ -581,7 +581,7 @@ fn a_transfer_bound_gpu_job_renders_on_the_cpu() {
let (frames, log) = run_pooled(&p, cid, &s, "/r/c_[#####].png", opts(), &again);
assert_eq!(again.frames.load(Ordering::SeqCst), 0);
assert!(frames.iter().all(|f| f.get_pixel(10, 44).0 == [255, 0, 0, 255]));
assert!(log.contains("Renderer: Mercury Software Only (as the queue item's first output module)"), "{log}");
assert!(log.contains("Renderer: Software Rendering (as the queue item's first output module)"), "{log}");
}
#[test]
@@ -610,7 +610,7 @@ fn a_gpu_job_that_measures_no_faster_on_the_cpu_stays_on_the_gpu() {
assert_eq!(frames.len(), 15);
assert_eq!(fast.frames.load(Ordering::SeqCst), 15, "every kept frame, warm-up and measured ones included, is the GPU's");
assert!(frames.iter().all(|f| f.pixels().all(|px| px.0 == [0, 255, 0, 255])));
assert!(log.contains("Renderer: Mercury GPU Acceleration (measured on the first frames: GPU "), "{log}");
assert!(log.contains("Renderer: GPU Acceleration (measured on the first frames: GPU "), "{log}");
}
#[test]
@@ -625,7 +625,7 @@ fn gpu_cpu_and_measured_jobs_write_the_same_frames() {
let echo = TimedAccel::new(5, false);
p.settings.gpu_acceleration = false;
let (cpu, log) = run_pooled(&p, cid, &s, "/i/a_[#####].png", JobOptions::default(), &echo);
assert!(log.contains("Renderer: Mercury Software Only (the project's renderer)"), "{log}");
assert!(log.contains("Renderer: Software Rendering (the project's renderer)"), "{log}");
p.settings.gpu_acceleration = true;
let gpu_slot = Mutex::new(Some(effectcraft_render::Backend::Gpu));
let (gpu, _) = run_pooled(&p, cid, &s, "/i/b_[#####].png", JobOptions { renderer: Some(&gpu_slot), ..Default::default() }, &echo);
+129 -2
View File
@@ -281,7 +281,7 @@ fn plugin_loading_reports_bad_modules_and_survives_failing_renders() {
let files = [
("a-good.wasm", wat_plugin("org.test.host.good", 1, "i32.const 0").into_bytes()),
("b-garbage.wasm", b"\0asm\x01\0\0\0garbage".to_vec()),
("c-v2.wasm", wat_plugin("org.test.host.v2", 2, "i32.const 0").into_bytes()),
("c-v3.wasm", wat_plugin("org.test.host.v3", 3, "i32.const 0").into_bytes()),
("d-empty.wasm", vec![]),
("e-trap.wasm", wat_plugin("org.test.host.trap", 1, "unreachable").into_bytes()),
("notes.txt", b"not a plug-in".to_vec()),
@@ -296,7 +296,7 @@ fn plugin_loading_reports_bad_modules_and_survives_failing_renders() {
let errs: Vec<String> = r["errors"].as_array().unwrap().iter().map(|e| e["path"].as_str().unwrap().to_string()).collect();
assert_eq!(errs.len(), 3, "{r}");
assert!(errs.iter().all(|p| !p.ends_with(".txt")));
assert!(r["errors"].to_string().contains("API 2"), "{r}");
assert!(r["errors"].to_string().contains("ABI 3"), "{r}");
// A single file: errors come back as command errors; loading twice is refused.
let good = dir.join("a-good.wasm").to_string_lossy().to_string();
assert!(s.execute("effect.plugins.load", json!({"path": good})).unwrap_err().to_string().contains("already registered"));
@@ -311,3 +311,130 @@ fn plugin_loading_reports_bad_modules_and_survives_failing_renders() {
assert_eq!(px(&s), before);
let _ = std::fs::remove_dir_all(&dir);
}
/// An ABI 2 plug-in (WebAssembly text) that shows the neighbouring frames it got: per pixel it
/// writes |next.red − prev.red| into red, `flags / 8` into green, `prev_time` into blue and 1
/// into alpha, so a render test can read the frames and their times back from the image.
fn wat_neighbour_plugin(id: &str) -> String {
let manifest = format!(r#"{{"api":2,"id":"{id}","name":"Host {id}","category":"Test Plug-ins","version":"1","params":[]}}"#);
let len = manifest.len();
let escaped = manifest.replace('"', "\\\"");
format!(
r#"(module
(memory (export "memory") 2)
(data (i32.const 16) "{escaped}")
(func (export "ec_api_version") (result i32) i32.const 2)
(func (export "ec_manifest_ptr") (result i32) i32.const 16)
(func (export "ec_manifest_len") (result i32) i32.const {len})
(func (export "ec_alloc") (param i32) (result i32) i32.const 1024)
(func (export "ec_render2")
(param $px i32) (param $w i32) (param $h i32) (param $params i32) (param $np i32)
(param $t f64) (param $scale f64)
(param $prev i32) (param $prev_t f64) (param $next i32) (param $next_t f64) (param $flags i32)
(result i32)
(local $i i32)
(local $n i32)
local.get $w
local.get $h
i32.mul
local.set $n
block $done
loop $per_pixel
local.get $i
local.get $n
i32.ge_u
br_if $done
;; red = |next.red - prev.red|
local.get $px
local.get $i
i32.const 16
i32.mul
i32.add
local.get $next
local.get $i
i32.const 16
i32.mul
i32.add
f32.load
local.get $prev
local.get $i
i32.const 16
i32.mul
i32.add
f32.load
f32.sub
f32.abs
f32.store
;; green = flags / 8
local.get $px
local.get $i
i32.const 16
i32.mul
i32.add
local.get $flags
f32.convert_i32_s
f32.const 8
f32.div
f32.store offset=4
;; blue = prev_time
local.get $px
local.get $i
i32.const 16
i32.mul
i32.add
local.get $prev_t
f32.demote_f64
f32.store offset=8
;; alpha = 1
local.get $px
local.get $i
i32.const 16
i32.mul
i32.add
f32.const 1
f32.store offset=12
local.get $i
i32.const 1
i32.add
local.set $i
br $per_pixel
end
end
i32.const 0))"#
)
}
/// The neighbouring frames are the layer's own pixels at other times: here a precomp whose
/// nested solid fades from 0 to 100 % over one second, at 4 fps. At 0.5 s the neighbours are
/// 0.25 s and 0.75 s, so red (the current frame) was 0.5 and |next − prev| is 0.5.
#[test]
fn abi_v2_plugins_render_with_real_neighbour_frames() {
let mut s = effectcraft_host::session();
let inner = s.execute("comp.new", json!({"name": "Inner", "width": 40, "height": 20, "frameRate": 4, "duration": 1})).unwrap()["comp"].as_u64().unwrap();
let solid = s.execute("layer.newSolid", json!({"color": [1.0, 0.0, 0.0], "width": 40, "height": 20})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.addKey", json!({"layer": solid, "path": "transform/opacity", "time": 0.0, "value": 0})).unwrap();
s.execute("prop.addKey", json!({"layer": solid, "path": "transform/opacity", "time": 1.0, "value": 100})).unwrap();
let outer = s.execute("comp.new", json!({"name": "Outer", "width": 40, "height": 20, "frameRate": 4, "duration": 1})).unwrap()["comp"].as_u64().unwrap();
let layer = s.execute("layer.addItem", json!({"comp": outer, "item": inner, "time": 0})).unwrap()["layer"].as_u64().unwrap();
let outer = effectcraft_engine::project::ItemId(outer);
let dir = tempfile::TempDir::new().unwrap();
let wasm = dir.path().join("neighbours.wasm");
std::fs::write(&wasm, wat_neighbour_plugin("org.test.host.neighbours")).unwrap();
let r = s.execute("effect.plugins.load", json!({"path": wasm.to_string_lossy()})).unwrap();
assert_eq!(r["loaded"][0]["id"], "org.test.host.neighbours", "{r}");
s.execute("effect.apply", json!({"layer": layer, "effect": "org.test.host.neighbours"})).unwrap();
let p = s.render(outer, Tick::from_seconds_f64(0.5), Default::default()).get(20, 10);
assert!((p[0] - 0.5).abs() < 0.02, "precomp neighbours: {p:?}");
// Both neighbours exist, the previous one is the 0.25 s frame, and the frame is opaque.
assert!((p[1] - 3.0 / 8.0).abs() < 0.01, "flags: {p:?}");
assert!((p[2] - 0.25).abs() < 0.01, "prev time: {p:?}");
assert_eq!(p[3], 1.0, "{p:?}");
// At the very start of the layer only the next frame exists (the previous one is zeroed);
// solids are filled with a quantised colour, hence the 8-bit step.
let p = s.render(outer, Tick::ZERO, Default::default()).get(20, 10);
assert!((p[0] - 0.25).abs() < 0.01, "the next frame's red, with no previous frame: {p:?}");
assert!((p[1] - 2.0 / 8.0).abs() < 0.01, "only the next frame is valid: {p:?}");
}
+355 -1
View File
@@ -466,6 +466,11 @@ fn psd_smart_objects_with_perspective_and_warp_bake_as_placed() {
/// A PDF with two placed images (one turned a quarter) between a background and a shape.
fn image_pdf() -> Vec<u8> {
image_pdf_with("")
}
/// [`image_pdf`] with `boxes` added to the page dictionary.
fn image_pdf_with(boxes: &str) -> Vec<u8> {
let content = "0.9 0.9 0.9 rg 0 0 120 80 re f\nq 60 0 0 30 10 40 cm /Im Do Q\nq 0 40 -20 0 110 10 cm /Im Do Q\n0 0 1 rg 20 5 30 20 re f\n";
// 8×8 pixels in quadrants: red, green / white, black.
let quad = |x: usize, y: usize| -> [u8; 3] { [[[255, 0, 0], [0, 255, 0]], [[255, 255, 255], [0, 0, 0]]][y / 4][x / 4] };
@@ -474,7 +479,7 @@ fn image_pdf() -> Vec<u8> {
&[
(1, "<< /Type /Catalog /Pages 2 0 R >>".into(), None),
(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), None),
(3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 120 80] /Resources << /XObject << /Im 5 0 R >> >> /Contents 4 0 R >>".into(), None),
(3, format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 120 80] {boxes} /Resources << /XObject << /Im 5 0 R >> >> /Contents 4 0 R >>"), None),
(4, "<< >>".into(), Some(content.as_bytes().to_vec())),
(5, "<< /Type /XObject /Subtype /Image /Width 8 /Height 8 /ColorSpace /DeviceRGB /BitsPerComponent 8 >>".into(), Some(px)),
],
@@ -546,6 +551,355 @@ fn retain_pdf(width: u32) -> Vec<u8> {
)
}
/// A 200 × 100 pt page: red left half, blue right half.
fn halves_pdf() -> Vec<u8> {
effectcraft_pdf::write::pdf(
&[
(1, "<< /Type /Catalog /Pages 2 0 R >>".into(), None),
(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), None),
(3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Contents 4 0 R >>".into(), None),
(4, "<< >>".into(), Some(b"1 0 0 rg 0 0 100 100 re f 0 0 1 rg 100 0 100 100 re f".to_vec())),
],
1,
)
}
/// #3099: footage from an After Effects project ([`effectcraft_project::Footage::placed_page`])
/// keeps the size After Effects stored for a placed .ai, which can differ from the page as read
/// here. The page fills that size at full and at reduced resolution alike: it was drawn at its own
/// aspect at full resolution and stretched to the stored size below 3/4 (the art jumped when the
/// viewer changed resolution, and the masks, stored against that size, came out too large at
/// full resolution). Two placed items of one file at different stored sizes each get their own
/// pixels, and neither reuses the file's frame at its own size, decoded first.
#[test]
fn placed_vector_footage_fills_its_stored_size_at_every_resolution() {
use effectcraft_project::ItemKind;
use effectcraft_time::Tick;
let path = tmp("stored-size.ai");
std::fs::write(&path, halves_pdf()).unwrap();
let mut s = effectcraft_host::session();
let item = ItemId(s.execute_checked("file.import", json!({"paths": [path]})).unwrap()["items"][0].as_u64().unwrap());
let red = |p: [f32; 4]| p[3] > 0.99 && p[0] > 0.9 && p[2] < 0.1;
let blue = |p: [f32; 4]| p[3] > 0.99 && p[2] > 0.9 && p[0] < 0.1;
// At the file's own size first (its frame goes to the pool's cache).
s.execute("comp.new", json!({"name": "Own", "width": 200, "height": 100, "frameRate": 25, "duration": 1})).unwrap();
s.execute_checked("layer.addItem", json!({"item": item.0})).unwrap();
let own_comp = s.active_comp_id().unwrap();
let own = s.render(own_comp, Tick::ZERO, RenderOpts::default());
assert!(red(own.get(50, 90)) && blue(own.get(150, 90)));
// Two placed items of the file with the sizes an After Effects project stores (other aspects).
let proj = std::sync::Arc::make_mut(&mut s.project);
let ItemKind::Footage(f) = &mut proj.item_mut(item).unwrap().kind else { panic!("footage") };
(f.width, f.height, f.placed_page) = (100, 80, true);
let tall = effectcraft_project::Footage { width: 60, height: 120, ..f.clone() };
let tall = proj.add_item("tall", Default::default(), None, ItemKind::Footage(tall));
for (item, (w, h)) in [(item, (100u32, 80u32)), (tall, (60, 120))] {
s.execute("comp.new", json!({"name": format!("Stored {w}"), "width": w, "height": h, "frameRate": 25, "duration": 1})).unwrap();
let lid = s.execute_checked("layer.addItem", json!({"item": item.0})).unwrap()["layer"].as_u64().unwrap();
let cid = s.active_comp_id().unwrap();
let (l, r, top, bottom) = (i64::from(w / 4), i64::from(w * 3 / 4), 3, i64::from(h) - 4);
for collapse in [false, true] {
s.execute("layer.setSwitch", json!({"layers": [lid], "switch": "collapse", "value": collapse})).unwrap();
let full = s.render(cid, Tick::ZERO, RenderOpts::default());
assert_eq!((full.width, full.height), (w, h));
for y in [top, bottom] {
let (a, b) = (full.get(l, y), full.get(r, y));
assert!(red(a) && blue(b), "{w}×{h}, collapse {collapse}, full resolution, row {y}: {a:?} {b:?}");
}
let half = s.render(cid, Tick::ZERO, RenderOpts { scale: 0.5, ..RenderOpts::default() });
assert_eq!((half.width, half.height), (w / 2, h / 2));
for y in [top / 2, bottom / 2] {
let (a, b) = (half.get(l / 2, y), half.get(r / 2, y));
assert!(red(a) && blue(b), "{w}×{h}, collapse {collapse}, half resolution, row {y}: {a:?} {b:?}");
}
}
// Create Shapes from Vector Layer draws the shapes where the footage is drawn.
s.execute_checked("layer.create", json!({"op": "shapesFromVector", "layers": [lid]})).unwrap();
s.execute("layer.setSwitch", json!({"layers": [lid], "switch": "video", "value": false})).unwrap();
let shapes = s.render(cid, Tick::ZERO, RenderOpts::default());
for y in [top, bottom] {
let (a, b) = (shapes.get(l, y), shapes.get(r, y));
assert!(red(a) && blue(b), "{w}×{h}, shapes, row {y}: {a:?} {b:?}");
}
}
}
/// #3099 review: Create Shapes from Vector Layer on a placed page keeps the page's frame. The
/// footage's raster shows only the ArtBox (241 × 121) of `art_box_ai()`, whose grey media box
/// reaches past it on every side; in a composition larger than the footage, the outlines show
/// the same colours inside the frame and nothing (the background) outside it. Without the clip
/// they showed the whole page: the grey background around the art.
#[test]
fn shapes_from_a_placed_page_stay_inside_its_frame() {
use effectcraft_project::ItemKind;
use effectcraft_time::Tick;
let path = tmp("placed-frame.ai");
std::fs::write(&path, art_box_ai()).unwrap();
let mut s = effectcraft_host::session();
let item = ItemId(s.execute_checked("file.import", json!({"paths": [path]})).unwrap()["items"][0].as_u64().unwrap());
// As an After Effects project stores it: the ArtBox's size, placed.
let ItemKind::Footage(f) = &mut std::sync::Arc::make_mut(&mut s.project).item_mut(item).unwrap().kind else { panic!("footage") };
(f.width, f.height, f.placed_page) = (241, 121, true);
s.execute("comp.new", json!({"name": "Big", "width": 400, "height": 300, "frameRate": 25, "duration": 1})).unwrap();
let lid = s.execute_checked("layer.addItem", json!({"item": item.0})).unwrap()["layer"].as_u64().unwrap();
let cid = s.active_comp_id().unwrap();
// The layer is centred: layer pixel (x, y) is comp (x + 79.5, y + 89.5).
let at = |img: &Image, x: f64, y: f64| img.get((x + 79.5).floor() as i64, (y + 89.5).floor() as i64);
let red = |p: [f32; 4]| p[3] > 0.99 && p[0] > 0.9 && p[1] < 0.1 && p[2] < 0.1;
let blue = |p: [f32; 4]| p[3] > 0.99 && p[2] > 0.9 && p[0] < 0.1 && p[1] < 0.1;
let empty = |p: [f32; 4]| p[0] < 0.02 && p[1] < 0.02 && p[2] < 0.02;
let inside = [([20.0, 20.0], true), ([110.0, 100.0], true), ([130.0, 20.0], false), ([230.0, 110.0], false)];
// Outside the frame, where the page's grey media box would show: left, right, above, below.
let outside = [[-20.0, 60.0], [-55.0, 100.0], [260.0, 60.0], [300.0, 10.0], [120.0, -20.0], [120.0, 140.0]];
let check = |img: &Image, what: &str| {
for (p, want_red) in inside {
let px = at(img, p[0], p[1]);
assert!(if want_red { red(px) } else { blue(px) }, "{what}: inside the frame at {p:?}: {px:?}");
}
for p in outside {
let px = at(img, p[0], p[1]);
assert!(empty(px), "{what}: outside the frame at {p:?}: {px:?}");
}
};
check(&s.render(cid, Tick::ZERO, RenderOpts::default()), "footage");
s.execute_checked("layer.create", json!({"op": "shapesFromVector", "layers": [lid]})).unwrap();
let comp = s.project.comp(cid).unwrap();
assert!(!comp.layers.iter().find(|l| l.id.0 == lid).unwrap().switches.video, "the source is switched off");
check(&s.render(cid, Tick::ZERO, RenderOpts::default()), "shapes");
}
/// #3099 review: the images of a placed page become footage layers clipped to the frame too.
/// `image_pdf_with` an ArtBox [20 15 100 70]: the upright image reaches past the frame's left
/// edge, the turned one past its right and bottom edges, the grey page around it. After the
/// conversion the layers inside the frame match the footage, and outside it nothing shows.
#[test]
fn images_from_a_placed_page_stay_inside_its_frame() {
use effectcraft_project::{ItemKind, MatteKind, TrackMatte};
use effectcraft_time::Tick;
let path = tmp("placed-images.pdf");
std::fs::write(&path, image_pdf_with("/ArtBox [20 15 100 70]")).unwrap();
let mut s = effectcraft_host::session();
let item = ItemId(s.execute_checked("file.import", json!({"paths": [path]})).unwrap()["items"][0].as_u64().unwrap());
let ItemKind::Footage(f) = &mut std::sync::Arc::make_mut(&mut s.project).item_mut(item).unwrap().kind else { panic!("footage") };
(f.width, f.height, f.placed_page) = (80, 55, true);
s.execute("comp.new", json!({"name": "Big", "width": 200, "height": 150, "frameRate": 25, "duration": 1})).unwrap();
let lid = s.execute_checked("layer.addItem", json!({"item": item.0})).unwrap()["layer"].as_u64().unwrap();
let cid = s.active_comp_id().unwrap();
// Centred: layer pixel (x, y) is comp (x + 60, y + 47.5).
let at = |img: &Image, x: f64, y: f64| img.get((x + 60.0).floor() as i64, (y + 47.5).floor() as i64);
let empty = |p: [f32; 4]| p[0] < 0.02 && p[1] < 0.02 && p[2] < 0.02;
// Inside: both images and the grey page; outside: the images past the frame, the grey page.
let inside = [[10.0, 8.0], [40.0, 8.0], [10.0, 22.0], [40.0, 22.0], [75.0, 25.0], [75.0, 50.0], [5.0, 40.0], [60.0, 10.0]];
let outside = [[-5.0, 15.0], [85.0, 40.0], [75.0, 58.0], [-15.0, 50.0], [40.0, -5.0]];
let footage = s.render(cid, Tick::ZERO, RenderOpts::default());
for p in outside {
assert!(empty(at(&footage, p[0], p[1])), "footage outside the frame at {p:?}: {:?}", at(&footage, p[0], p[1]));
}
let r = s.execute_checked("layer.create", json!({"op": "shapesFromVector", "layers": [lid]})).unwrap();
assert_eq!(r["skipped"], json!([]), "{r}");
// One hidden frame layer is the Alpha track matte of every layer made from the page.
let comp = s.project.comp(cid).unwrap();
let frame = comp.layers.iter().find(|l| l.name.ends_with(" Frame")).expect("the frame matte layer");
assert!(!frame.switches.video);
let made: Vec<_> = comp.layers.iter().filter(|l| l.id != frame.id && l.id.0 != lid).collect();
assert_eq!(made.iter().filter(|l| l.name == "Image").count(), 2);
for l in &made {
assert_eq!(l.track_matte, Some(TrackMatte { layer: frame.id, kind: MatteKind::Alpha }), "{}", l.name);
}
let shapes = s.render(cid, Tick::ZERO, RenderOpts::default());
for p in inside {
let (a, b) = (at(&shapes, p[0], p[1]), at(&footage, p[0], p[1]));
assert!((0..4).all(|k| (a[k] - b[k]).abs() < 0.05), "inside the frame at {p:?}: {a:?} vs footage {b:?}");
}
for p in outside {
assert!(empty(at(&shapes, p[0], p[1])), "shapes outside the frame at {p:?}: {:?}", at(&shapes, p[0], p[1]));
}
}
/// A PDF with an ArtBox, imported here (not from an After Effects project), and a project saved
/// with it before #3099: shown as a PDF viewer frames it (the crop box, 410 × 210) at its own
/// size, exactly as before; the project file has no new field.
#[test]
fn a_pdf_with_an_art_box_imported_here_keeps_the_viewer_framing() {
use effectcraft_project::{ItemKind, Project};
use effectcraft_time::Tick;
let bytes = art_box_ai();
let path = tmp("viewer-art-box.pdf");
std::fs::write(&path, &bytes).unwrap();
let mut s = effectcraft_host::session();
let item = ItemId(s.execute_checked("file.import", json!({"paths": [path]})).unwrap()["items"][0].as_u64().unwrap());
let ItemKind::Footage(f) = &s.project.item(item).unwrap().kind else { panic!("footage") };
assert_eq!((f.width, f.height, f.placed_page), (410, 210, false));
s.execute("comp.new", json!({"name": "V", "width": 410, "height": 210, "frameRate": 25, "duration": 1})).unwrap();
s.execute_checked("layer.addItem", json!({"item": item.0})).unwrap();
let cid = s.active_comp_id().unwrap();
let direct = effectcraft_svg::rasterize(&effectcraft_pdf::parse(&bytes).unwrap(), 410, 210, 1.0);
assert!(mean_diff(&s.render(cid, Tick::ZERO, RenderOpts::default()), &direct) < 1.0 / 255.0);
let saved = s.project.to_json();
assert!(!saved.contains("placed_page"), "the saved project is in the earlier format");
s.project = std::sync::Arc::new(Project::from_json(&saved).unwrap());
assert!(mean_diff(&s.render(cid, Tick::ZERO, RenderOpts::default()), &direct) < 1.0 / 255.0, "reopened");
}
/// `i3099-a-boxes.ai` of `effectcraft-aep`'s `ai-mask-3099.aep` (its boxes as in
/// `tests/fixtures/tools/ai-mask-3099.py`): MediaBox 420 × 220, CropBox, BleedBox and TrimBox
/// inside it, ArtBox [60 50 300.6 170.4]. Here grey over the media box, the art box red on its
/// left half and blue on its right half.
fn art_box_ai() -> Vec<u8> {
let page = "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 420 220] /CropBox [5 5 415 215] /BleedBox [10 10 410 210] /TrimBox [20 20 400 200] /ArtBox [60 50 300.6 170.4] /Contents 4 0 R >>";
let content = b"0.5 g 0 0 420 220 re f 1 0 0 rg 60 50 120.3 120.4 re f 0 0 1 rg 180.3 50 120.3 120.4 re f";
effectcraft_pdf::write::pdf(
&[
(1, "<< /Type /Catalog /Pages 2 0 R >>".into(), None),
(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), None),
(3, page.into(), None),
(4, "<< >>".into(), Some(content.to_vec())),
],
1,
)
}
/// `i3099-d-trim.ai` of `ai-mask-3099.aep`: MediaBox 418 × 218, TrimBox, ArtBox [40 30 250 150]
/// (no CropBox). Grey over the media box, the art box red on its left half, blue on its right.
fn trim_ai() -> Vec<u8> {
let page = "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 418 218] /TrimBox [9 9 409 209] /BleedBox [0 0 418 218] /ArtBox [40 30 250 150] /Contents 4 0 R >>";
let content = b"0.5 g 0 0 418 218 re f 1 0 0 rg 40 30 105 120 re f 0 0 1 rg 145 30 105 120 re f";
effectcraft_pdf::write::pdf(
&[
(1, "<< /Type /Catalog /Pages 2 0 R >>".into(), None),
(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), None),
(3, page.into(), None),
(4, "<< >>".into(), Some(content.to_vec())),
],
1,
)
}
/// `i3099-c-rot.ai` of `ai-mask-3099.aep`: MediaBox [100 50 400 150] with `/Rotate 90`, grey,
/// with a blue 20 × 10 marker 4 pt in from the media box's lower-left corner.
fn rotated_ai() -> Vec<u8> {
effectcraft_pdf::write::pdf(
&[
(1, "<< /Type /Catalog /Pages 2 0 R >>".into(), None),
(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), None),
(3, "<< /Type /Page /Parent 2 0 R /MediaBox [100 50 400 150] /Rotate 90 /Contents 4 0 R >>".into(), None),
(4, "<< >>".into(), Some(b"0.5 g 100 50 300 100 re f 0 0 1 rg 104 54 20 10 re f".to_vec())),
],
1,
)
}
/// #3099 end to end, against After Effects 2026's own read-back of the committed project
/// `ai-mask-3099.aep`: a placed .ai (ArtBox inside a larger crop box) parented to a Null parented
/// to a Null, with two rectangular masks. After Effects sizes the footage 241 × 121 (the ArtBox)
/// and puts the layer's corners at the comp points below (`toComp`). The art fills the layer
/// between the masks at full and at half resolution; it was drawn from the crop box (410 × 210)
/// scaled to the layer's width, so the masks cut the wrong part of it and half resolution
/// squeezed it, and the Null left at its default anchor moved it. `artC` (`/Rotate 90`) is drawn
/// unturned as After Effects draws it, `artD` (Continuously Rasterize) as `art`.
#[test]
fn aep_ai_layer_with_masks_under_two_nulls_renders_like_after_effects() {
use effectcraft_engine::render::EvalCtx;
use effectcraft_project::ItemKind;
use effectcraft_time::Tick;
let dir = std::path::PathBuf::from(tmp("ai-mask-3099"));
std::fs::create_dir_all(&dir).unwrap();
let aep = dir.join("ai-mask-3099.aep");
// The fixture belongs to `effectcraft-aep` (its sidecar and generator are there); this test
// renders it through the wired session, which that crate cannot.
std::fs::write(&aep, include_bytes!("../../../aep/tests/fixtures/ai-mask-3099.aep")).unwrap();
let mut s = effectcraft_host::session();
s.execute_checked("file.importAep", json!({"path": aep.to_string_lossy(), "allowLosses": true})).unwrap();
// The project's footage paths are neutral (`C:\fixture\x\...`): the files are linked here.
for (name, bytes, size) in
[("i3099-a-boxes.ai", art_box_ai(), (241, 121)), ("i3099-c-rot.ai", rotated_ai(), (300, 100)), ("i3099-d-trim.ai", trim_ai(), (210, 120))]
{
let file = dir.join(name);
std::fs::write(&file, bytes).unwrap();
let item = s
.project
.items
.values()
.find(|i| matches!(&i.kind, ItemKind::Footage(f) if f.path.ends_with(name)))
.map(|i| i.id)
.unwrap_or_else(|| panic!("footage {name}"));
let ItemKind::Footage(f) = &mut std::sync::Arc::make_mut(&mut s.project).item_mut(item).unwrap().kind else { panic!("footage") };
assert_eq!((f.width, f.height, f.placed_page), (size.0, size.1, true), "{name}: After Effects' size of the placed .ai");
(f.path, f.missing) = (file.to_string_lossy().into_owned(), false);
}
let cid = s.project.items.values().find(|i| i.name == "ai-mask").map(|i| i.id).expect("comp ai-mask");
// After Effects' toComp of the art layer's points (layer pixels → comp pixels).
let ae = [
([0.0, 0.0], [634.193413913741, 195.085717365354]),
([241.0, 0.0], [913.539162876539, 269.936185209003]),
([0.0, 121.0], [577.822625890412, 405.464362331113]),
([40.0, 30.0], [666.58162514008, 259.669026149884]),
([350.0, 160.0], [965.342375965623, 581.976354279664]),
];
let comp = s.project.comp(cid).unwrap();
let art = comp.layer_by_name("art").unwrap();
let (m, _) = EvalCtx::new(&s.project, cid, comp, Tick::ZERO).layer_to_comp(art);
let to_comp = |[u, v]: [f64; 2]| [m.0[0][0] * u + m.0[0][1] * v + m.0[0][2], m.0[1][0] * u + m.0[1][1] * v + m.0[1][2]];
for (p, want) in ae {
let got = to_comp(p);
assert!((got[0] - want[0]).abs() < 1e-3 && (got[1] - want[1]).abs() < 1e-3, "{p:?}: {got:?} vs After Effects {want:?}");
}
// Inside the masks (M1: x 40..200, y 30..90; M2: x 220..350, y 100..160, cut by the layer).
let red = |p: [f32; 4]| p[0] > 0.9 && p[1] < 0.1 && p[2] < 0.1;
let blue = |p: [f32; 4]| p[2] > 0.9 && p[0] < 0.1 && p[1] < 0.1;
let full = s.render(cid, Tick::ZERO, RenderOpts::default());
let half = s.render(cid, Tick::ZERO, RenderOpts { scale: 0.5, ..RenderOpts::default() });
for (p, want_red) in [([60.0, 45.0], true), ([100.0, 75.0], true), ([150.0, 40.0], false), ([190.0, 80.0], false), ([232.0, 112.0], false)] {
let [x, y] = to_comp(p);
let at = |img: &Image, k: f64| img.get((x * k).round() as i64, (y * k).round() as i64);
for (img, k, what) in [(&full, 1.0, "full"), (&half, 0.5, "half")] {
let px = at(img, k);
let ok = if want_red { red(px) } else { blue(px) };
assert!(ok, "{what} resolution, layer point {p:?}: {px:?} (want {})", if want_red { "red" } else { "blue" });
}
}
// Outside the masks the art is cut away: the black background.
for p in [[100.0, 110.0], [20.0, 60.0]] {
let [x, y] = to_comp(p);
let px = full.get(x.round() as i64, y.round() as i64);
assert!(px[0] < 0.05 && px[1] < 0.05 && px[2] < 0.05, "layer point {p:?}: {px:?}");
}
// artC (/Rotate 90, position (1500, 800), anchor (150, 50)): After Effects draws the page
// unturned, 300 × 100, the marker at its lower left (comp 1354..1374, 836..846).
for (img, k, what) in [(&full, 1.0, "full"), (&half, 0.5, "half")] {
let at = |x: f64, y: f64| img.get((x * k).round() as i64, (y * k).round() as i64);
let marker = at(1364.0, 841.0);
assert!(blue(marker), "{what} resolution, artC marker: {marker:?}");
for (x, y) in [(1450.0, 780.0), (1630.0, 790.0), (1364.0, 770.0)] {
let grey = at(x, y);
assert!(grey[3] > 0.99 && (grey[0] - grey[2]).abs() < 0.02 && grey[0] > 0.1 && grey[0] < 0.9, "{what} resolution, artC at ({x}, {y}): {grey:?}");
}
}
for (x, y) in [(1340.0, 800.0), (1500.0, 870.0)] {
let px = full.get(x as i64, y as i64);
assert!(px[0] < 0.05 && px[2] < 0.05, "outside artC ({x}, {y}): {px:?}");
}
// artD (ArtBox 210 × 120, Continuously Rasterize, the same masks, also under N2): placed by
// After Effects' toComp of its corners.
let (tl, tr, bl) = ([883.191424310055, 728.610562914909], [1126.6047325349, 793.832962280744], [827.286510567911, 937.250541393348]);
let d_to_comp = |[u, v]: [f64; 2]| [0, 1].map(|i| tl[i] + u / 210.0 * (tr[i] - tl[i]) + v / 120.0 * (bl[i] - tl[i]));
for (p, want_red) in [([60.0, 45.0], true), ([90.0, 75.0], true), ([150.0, 40.0], false), ([190.0, 80.0], false)] {
let [x, y] = d_to_comp(p);
for (img, k, what) in [(&full, 1.0, "full"), (&half, 0.5, "half")] {
let px = img.get((x * k).round() as i64, (y * k).round() as i64);
let ok = if want_red { red(px) } else { blue(px) };
assert!(ok, "artD, {what} resolution, layer point {p:?}: {px:?} (want {})", if want_red { "red" } else { "blue" });
}
}
}
#[test]
fn ai_composition_retains_layer_sizes_and_positions() {
use effectcraft_project::{ItemKind, LayerSource, Project};
+90 -11
View File
@@ -14,19 +14,20 @@ fn is_svg(path: &str, bytes: &[u8]) -> bool {
}
/// The vector document of an SVG / PDF / AI / EPS file (`None` for other formats): page
/// `page` of a PDF, restricted to the footage's layer when it names one.
/// `page` of a PDF framed as `framing` says, restricted to the footage's layer when it names one.
pub fn vector_doc(
path: &str,
bytes: &[u8],
layer: Option<&effectcraft_project::SourceLayer>,
page: u32,
framing: effectcraft_pdf::Framing,
) -> Option<std::result::Result<effectcraft_svg::Doc, String>> {
if is_svg(path, bytes) {
return Some(effectcraft_svg::parse(bytes).map_err(|e| format!("{path}: {e}")));
}
effectcraft_pdf::sniff(bytes)?;
Some(
effectcraft_pdf::parse_page(bytes, page as usize)
effectcraft_pdf::parse_page_framed(bytes, page as usize, framing)
.map(|d| match layer {
Some(l) => effectcraft_pdf::layer_doc_with_sizes(&d, l.index as usize, l.layer_size).0,
None => d,
@@ -111,14 +112,14 @@ pub(crate) fn decode(path: &str, bytes: &[u8], footage: &Footage, op: AlphaOp) -
return Ok(Some(straight_to_image(px.width, px.height, &px.data, op)));
}
if is_svg(path, bytes) || is_pdf_like(path, bytes) {
let doc = vector_doc(path, bytes, footage.layer.as_ref(), footage.page).unwrap_or(Err(String::new())).map_err(MediaError::Decode)?;
let (w, h) = doc.pixel_size();
let doc = footage_doc(path, bytes, footage).unwrap_or(Err(String::new())).map_err(MediaError::Decode)?;
let ((w, h), to_image) = fit(&doc, footage, 1.0);
// A declared size up to 30000² would be a 14 GB allocation that aborts the app (#2136):
// past what Continuously Rasterize can draw, the footage reports an error instead.
if u64::from(w) * u64::from(h) > u64::from(MAX_VECTOR_SIDE) * u64::from(MAX_VECTOR_SIDE) {
return Err(MediaError::Decode(format!("{path}: {w}×{h} pixels is larger than vector footage can be drawn ({MAX_VECTOR_SIDE}×{MAX_VECTOR_SIDE})")));
}
let img = effectcraft_svg::rasterize(&doc, w, h, 1.0);
let img = effectcraft_svg::rasterize_with(&doc, w, h, to_image);
// The rasteriser produces premultiplied pixels already.
let _ = op;
return Ok(Some(img));
@@ -129,12 +130,29 @@ pub(crate) fn decode(path: &str, bytes: &[u8], footage: &Footage, op: AlphaOp) -
/// The largest side vector footage is drawn at, decoded or continuously rasterised.
const MAX_VECTOR_SIDE: u32 = 16384;
/// A vector file rasterised at `scale` × its pixel size (Continuously Rasterize).
pub(crate) fn rasterize_vector(path: &str, bytes: &[u8], layer: Option<&effectcraft_project::SourceLayer>, page: u32, scale: f64) -> Option<Image> {
let doc = vector_doc(path, bytes, layer, page)?.ok()?;
let (w, h) = doc.pixel_size();
let (sw, sh) = (((w as f64 * scale).ceil() as u32).clamp(1, MAX_VECTOR_SIDE), ((h as f64 * scale).ceil() as u32).clamp(1, MAX_VECTOR_SIDE));
Some(effectcraft_svg::rasterize(&doc, sw, sh, scale))
/// The vector footage `footage` (of the file `path` with these `bytes`) rasterised at `scale` ×
/// its size as [`effectcraft_render::vector_fit`] draws it (Continuously Rasterize).
pub(crate) fn rasterize_vector(path: &str, bytes: &[u8], footage: &Footage, scale: f64) -> Option<Image> {
let doc = footage_doc(path, bytes, footage)?.ok()?;
let ((sw, sh), to_image) = fit(&doc, footage, scale);
Some(effectcraft_svg::rasterize_with(&doc, sw.clamp(1, MAX_VECTOR_SIDE), sh.clamp(1, MAX_VECTOR_SIDE), to_image))
}
/// The page framing of vector footage: as After Effects places it for footage from an After
/// Effects project ([`Footage::placed_page`]), as a PDF viewer shows it otherwise.
fn framing(footage: &Footage) -> effectcraft_pdf::Framing {
if footage.placed_page { effectcraft_pdf::Framing::Placed } else { effectcraft_pdf::Framing::Viewer }
}
/// [`vector_doc`] of `footage`'s page, layer and framing.
fn footage_doc(path: &str, bytes: &[u8], footage: &Footage) -> Option<std::result::Result<effectcraft_svg::Doc, String>> {
vector_doc(path, bytes, footage.layer.as_ref(), footage.page, framing(footage))
}
/// The image size and document → image mapping of `doc` drawn for `footage` at `scale`.
fn fit(doc: &effectcraft_svg::Doc, footage: &Footage, scale: f64) -> ((u32, u32), effectcraft_svg::Affine) {
let (size, [sx, sy]) = effectcraft_render::vector_fit(footage, [doc.width, doc.height], doc.pixel_size(), scale);
(size, effectcraft_svg::Affine::scale_non_uniform(sx, sy))
}
#[cfg(test)]
@@ -154,4 +172,65 @@ mod tests {
let img = decode("small.svg", small, &footage, AlphaOp::new(footage.alpha, footage.premul_color)).unwrap().unwrap();
assert_eq!((img.width, img.height), (40, 20));
}
/// A 200 × 100 pt page: red left half, blue right half.
fn halves_pdf() -> Vec<u8> {
effectcraft_pdf::write::pdf(
&[
(1, "<< /Type /Catalog /Pages 2 0 R >>".into(), None),
(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), None),
(3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Contents 4 0 R >>".into(), None),
(4, "<< >>".into(), Some(b"1 0 0 rg 0 0 100 100 re f 0 0 1 rg 100 0 100 100 re f".to_vec())),
],
1,
)
}
/// An opaque red pixel.
fn is_red(p: [f32; 4]) -> bool {
p[3] > 0.99 && p[0] > 0.9 && p[2] < 0.1
}
/// An opaque blue pixel.
fn is_blue(p: [f32; 4]) -> bool {
p[3] > 0.99 && p[2] > 0.9 && p[0] < 0.1
}
/// #3099: an After Effects project keeps the size After Effects measured for a placed .ai or
/// PDF, which can differ from this reader's page size. A placed page fills that size (the
/// layer's anchor and masks are measured against it), decoded and continuously rasterised;
/// other footage is drawn at the page's own size as before, whatever size it says.
#[test]
fn placed_vector_footage_fills_the_footage_size() {
let pdf = halves_pdf();
let mut footage = probe("art.ai", &pdf).unwrap().unwrap();
assert_eq!((footage.width, footage.height), (200, 100));
assert!(!footage.placed_page);
let op = AlphaOp::new(footage.alpha, footage.premul_color);
let own = decode("art.ai", &pdf, &footage, op).unwrap().unwrap();
assert_eq!((own.width, own.height), (200, 100));
assert!(is_red(own.get(50, 50)) && is_blue(own.get(150, 50)));
(footage.width, footage.height) = (100, 80);
let unplaced = decode("art.ai", &pdf, &footage, op).unwrap().unwrap();
assert!(unplaced == own, "not placed: drawn as before");
assert_eq!(rasterize_vector("art.ai", &pdf, &footage, 0.5).map(|i| (i.width, i.height)), Some((100, 50)));
footage.placed_page = true;
let img = decode("art.ai", &pdf, &footage, op).unwrap().unwrap();
assert_eq!((img.width, img.height), (100, 80));
for (x, y) in [(25, 3), (25, 76)] {
assert!(is_red(img.get(x, y)), "red at ({x}, {y}): {:?}", img.get(x, y));
}
for (x, y) in [(75, 3), (75, 76)] {
assert!(is_blue(img.get(x, y)), "blue at ({x}, {y}): {:?}", img.get(x, y));
}
let sharp = rasterize_vector("art.ai", &pdf, &footage, 2.0).unwrap();
assert_eq!((sharp.width, sharp.height), (200, 160));
assert!(is_red(sharp.get(50, 155)) && is_blue(sharp.get(150, 155)));
// Placed at the page's own size: 1:1.
(footage.width, footage.height) = (200, 100);
assert!(decode("art.ai", &pdf, &footage, op).unwrap().unwrap() == own, "placed at its own size: 1:1");
}
}
+15 -5
View File
@@ -42,14 +42,16 @@ fn colour_bars(w: u32, h: u32) -> Image {
struct Key {
path: Arc<str>,
frame: i64,
/// The alpha interpretation (bits 0–1) and how float values are encoded (bits 2–3: sRGB,
/// as stored, a 2.4 power curve): they change the decoded pixels.
/// The alpha interpretation (bits 0–1), how float values are encoded (bits 2–3: sRGB, as
/// stored, a 2.4 power curve) and a placed PDF / Illustrator page's framing (bit 4,
/// [`Footage::placed_page`]): they change the decoded pixels.
interp: u8,
matte: [u32; 3],
/// A movie frame made at this size for a reduced-resolution render ((0, 0): as decoded).
size: (u32, u32),
/// A still at a reduced `size`: the footage's nominal size, which decides whether it is
/// resampled (see `Inner::still_at_size`); (0, 0) otherwise.
/// resampled (see `Inner::still_at_size`); also at its own size for a placed PDF /
/// Illustrator page, which is drawn to fill it ([`Footage::placed_page`]); (0, 0) otherwise.
nominal: (u32, u32),
}
@@ -756,6 +758,8 @@ impl Inner {
((d.0 as i128 * rate.num as i128 + den / 2) / den) as i64
}
/// Where the frame of `footage` at `t` comes from (file, frame or media time) and its cache
/// key, at the footage's own size.
fn locate(footage: &Footage, t: Tick, float: FloatCurve) -> Loc {
let alpha = match footage.alpha {
AlphaMode::Straight => 0,
@@ -768,7 +772,8 @@ impl Inner {
FloatCurve::Srgb => 0,
FloatCurve::Linear => 1,
FloatCurve::Gamma24 => 2,
} << 2);
} << 2)
| (u8::from(footage.placed_page) << 4);
let matte = if footage.alpha == AlphaMode::Premultiplied { footage.premul_color.map(f32::to_bits) } else { [0; 3] };
let key = |path: &str, frame| Key { path: path.into(), frame, interp, matte, size: (0, 0), nominal: (0, 0) };
match footage.kind {
@@ -818,6 +823,11 @@ impl Inner {
loc.key.nominal = (footage.width, footage.height);
}
}
// A placed PDF / Illustrator page is drawn to fill the footage's size (`layered::decode`,
// #3099): two items of one file stored at different sizes have different pixels.
if footage.placed_page && !movie {
loc.key.nominal = (footage.width, footage.height);
}
let mut c = lock(&self.cache);
// sequential playback of a movie: read the next frame ahead
if playback && movie {
@@ -1041,7 +1051,7 @@ impl FootageSource for MediaPool {
return None;
}
let bytes = self.inner.read(&footage.path).ok()?;
crate::layered::rasterize_vector(&footage.path, &bytes, footage.layer.as_ref(), footage.page, scale).map(Arc::new)
crate::layered::rasterize_vector(&footage.path, &bytes, footage, scale).map(Arc::new)
}
fn set_aux_footage(&self, paths: &[String]) {
+7 -1
View File
@@ -29,7 +29,13 @@ ffmpeg elsewhere) to check the code tables against an independent implementation
the app's existing object model. References retain object and generation numbers. A bounded
compatibility repair recovers referenced objects from object streams when damaged xrefs omit
them; every recovery or unresolved reference is reported in `Doc::skipped` (up to 64 messages).
The existing page tree preserves inherited resources, MediaBox / CropBox and `/Rotate`.
The existing page tree preserves inherited resources, MediaBox / CropBox and `/Rotate`. A page
is its CropBox (else MediaBox), turned by `/Rotate` (`Framing::Viewer`, the default). Footage
from an After Effects project is framed as After Effects places a PDF or `.ai`
(`Framing::Placed`, `parse_page_framed`): the page's own ArtBox clipped to the CropBox (else
MediaBox) when it has one, else the CropBox, else the MediaBox, with `/Rotate` ignored
(measured in After Effects 2026 with own files, #3099). Not measured: a CropBox without an
ArtBox, files saved by Illustrator itself, `.pdf` names.
Content/image filters remain app-owned: Flate (with PNG predictors), LZW, ASCIIHex, ASCII85,
RunLength and CCITTFax. Encrypted files remain refused; `page_count` returns zero when the
reader refuses a document.
+26 -4
View File
@@ -92,8 +92,25 @@ pub fn parse(bytes: &[u8]) -> Result<Doc, Error> {
/// Parse page `index` of a PDF (EPS files have one page).
pub fn parse_page(bytes: &[u8], index: usize) -> Result<Doc, Error> {
parse_page_framed(bytes, index, Framing::Viewer)
}
/// Which region of a PDF page becomes the document, and whether `/Rotate` turns it.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Framing {
/// As a PDF viewer shows the page: its CropBox (else MediaBox), turned by `/Rotate`.
#[default]
Viewer,
/// As After Effects places a PDF or `.ai` as footage: the page's ArtBox clipped to the
/// CropBox (else MediaBox) when it has one, else the CropBox, else the MediaBox, with
/// `/Rotate` ignored (measured in After Effects 2026 with own files, #3099).
Placed,
}
/// [`parse_page`] with the page framed as `framing` says (EPS files are their bounding box).
pub fn parse_page_framed(bytes: &[u8], index: usize, framing: Framing) -> Result<Doc, Error> {
match sniff(bytes) {
Some(Format::Pdf) => parse_pdf(bytes, index),
Some(Format::Pdf) => parse_pdf(bytes, index, framing),
Some(Format::Eps) => parse_eps(bytes),
None => Err(Error::NotVector),
}
@@ -142,17 +159,22 @@ fn finish(nodes: Vec<Node>, flags: Vec<bool>, skipped: Vec<String>, width: f64,
Doc { width, height, root, skipped }
}
fn parse_pdf(bytes: &[u8], index: usize) -> Result<Doc, Error> {
/// Page `index` of a PDF framed as `framing` says, as a document in points (y down).
fn parse_pdf(bytes: &[u8], index: usize, framing: Framing) -> Result<Doc, Error> {
let file = object::File::parse(bytes)?;
let pages = page::pages(&file);
if pages.is_empty() {
return Err(Error::NoPages);
}
let pg = pages.get(index).ok_or(Error::PageOutOfRange(index))?;
let [x0, y0, x1, y1] = pg.bbox;
let (frame, rotate) = match framing {
Framing::Viewer => (pg.bbox, pg.rotate),
Framing::Placed => (pg.art.unwrap_or(pg.bbox), 0),
};
let [x0, y0, x1, y1] = frame;
let (w, h) = (x1 - x0, y1 - y0);
// Default user space (y up) → document pixels (y down), with the page's /Rotate.
let (to_doc, dw, dh) = match pg.rotate {
let (to_doc, dw, dh) = match rotate {
90 => (Affine::new([0.0, 1.0, 1.0, 0.0, -y0, -x0]), h, w),
180 => (Affine::new([-1.0, 0.0, 0.0, 1.0, x1, -y0]), w, h),
270 => (Affine::new([0.0, -1.0, -1.0, 0.0, y1, x1]), h, w),
+37 -4
View File
@@ -23,6 +23,9 @@ pub struct Page {
pub resources: Dict,
/// CropBox (else MediaBox) `[x0, y0, x1, y1]`.
pub bbox: [f64; 4],
/// The page's own ArtBox clipped to [`Page::bbox`] (ISO 32000-1 §14.11.2), when it has one
/// that keeps an area there; not inherited (table 30).
pub art: Option<[f64; 4]>,
pub rotate: i32,
}
@@ -31,13 +34,15 @@ pub fn pages(file: &File) -> Vec<Page> {
let mut out = vec![];
let Some(cat) = file.catalog() else { return out };
let Some(root) = file.get_dict(cat, "Pages") else { return out };
/// Collects the pages under `node`, passing on the attributes pages inherit (resources,
/// MediaBox, CropBox, /Rotate); bounded in depth and page count.
fn walk(file: &File, node: &Dict, inherited: (Option<Dict>, Option<Vec<f64>>, Option<Vec<f64>>, i32), out: &mut Vec<Page>, depth: usize) {
if depth > 32 || out.len() > 10_000 {
return;
}
let res = file.get_dict(node, "Resources").cloned().or(inherited.0);
let media = file.get(node, "MediaBox").map(|m| file.nums(m)).filter(|v| v.len() == 4).or(inherited.1);
let crop = file.get(node, "CropBox").map(|m| file.nums(m)).filter(|v| v.len() == 4).or(inherited.2);
let media = file.get(node, "MediaBox").map(|m| file.nums(m)).filter(|v| is_area(v)).or(inherited.1);
let crop = file.get(node, "CropBox").map(|m| file.nums(m)).filter(|v| is_area(v)).or(inherited.2);
let rot = file.get_num(node, "Rotate").map(|r| r as i32).unwrap_or(inherited.3);
match file.get(node, "Kids").and_then(Obj::array) {
Some(kids) if file.get(node, "Type").and_then(Obj::name) != Some("Page") => {
@@ -49,8 +54,16 @@ pub fn pages(file: &File) -> Vec<Page> {
}
_ => {
let b = crop.or(media).unwrap_or_else(|| vec![0.0, 0.0, 612.0, 792.0]);
let bbox = [b[0].min(b[2]), b[1].min(b[3]), b[0].max(b[2]), b[1].max(b[3])];
out.push(Page { dict: node.clone(), resources: res.unwrap_or_default(), bbox, rotate: rot.rem_euclid(360) });
let bbox = normalized(&b).unwrap_or([0.0, 0.0, 612.0, 792.0]);
// The ArtBox is the page's own (not inherited), clipped to the crop box.
let art = file
.get(node, "ArtBox")
.map(|m| file.nums(m))
.filter(|v| is_area(v))
.and_then(|v| normalized(&v))
.map(|a| [a[0].max(bbox[0]), a[1].max(bbox[1]), a[2].min(bbox[2]), a[3].min(bbox[3])])
.filter(|a| a[2] > a[0] && a[3] > a[1]);
out.push(Page { dict: node.clone(), resources: res.unwrap_or_default(), bbox, art, rotate: rot.rem_euclid(360) });
}
}
}
@@ -58,6 +71,26 @@ pub fn pages(file: &File) -> Vec<Page> {
out
}
/// A box `[x0, y0, x1, y1]` (corners in any order) with finite corners and a finite span that
/// encloses an area.
fn is_area(v: &[f64]) -> bool {
match v {
[x0, y0, x1, y1] => {
let (w, h) = (x1 - x0, y1 - y0);
v.iter().all(|c| c.is_finite()) && w.is_finite() && h.is_finite() && w.abs() > 0.0 && h.abs() > 0.0
}
_ => false,
}
}
/// A box with its corners ordered (`x0 < x1`, `y0 < y1`), `None` unless [`is_area`].
fn normalized(v: &[f64]) -> Option<[f64; 4]> {
match v {
[x0, y0, x1, y1] if is_area(v) => Some([x0.min(*x1), y0.min(*y1), x0.max(*x1), y0.max(*y1)]),
_ => None,
}
}
/// The page's content streams, decoded and joined.
pub fn page_content(file: &File, page: &Page) -> Vec<u8> {
let mut out = vec![];
+80
View File
@@ -112,6 +112,86 @@ fn pdf_forms_rotation_cmyk_and_alpha() {
assert_eq!(img.get(30, 30)[3], 0.0, "form clipped to its bounding box");
}
/// A one-page PDF with a MediaBox [0 0 420 220], `pages` added to the /Pages node and `page` to
/// the page: grey over the media box, a blue 20 × 10 marker at PDF (64, 54).
fn boxes_pdf(pages: &str, page: &str) -> Vec<u8> {
let content = "0.5 g 0 0 420 220 re f 0 0 1 rg 64 54 20 10 re f";
pdf(
&[
(1, "<< /Type /Catalog /Pages 2 0 R >>".to_string(), None),
(2, format!("<< /Type /Pages /Kids [3 0 R] /Count 1 {pages} >>"), None),
(3, format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 420 220] {page} /Contents 4 0 R >>"), None),
(4, "<< >>".to_string(), Some(content.as_bytes().to_vec())),
],
1,
)
}
/// The first page of `bytes` framed as After Effects places it.
fn placed(bytes: &[u8]) -> Doc {
parse_page_framed(bytes, 0, Framing::Placed).unwrap()
}
/// #3099: After Effects 2026 places a .ai or PDF as footage by the page's ArtBox and shows only
/// that region (a page with MediaBox 420 × 220, CropBox 410 × 210, TrimBox 380 × 180 and ArtBox
/// [60 50 300.6 170.4] imports as 241 × 121). `Framing::Placed` frames it so; the viewer framing
/// (the default, used by File ▸ Import) stays the CropBox.
#[test]
fn placed_framing_is_the_art_box() {
let bytes = boxes_pdf("/CropBox [5 5 415 215]", "/TrimBox [20 20 400 200] /ArtBox [60 50 300.6 170.4]");
let doc = placed(&bytes);
assert!((doc.width - 240.6).abs() < 1e-9 && (doc.height - 120.4).abs() < 1e-9, "{} × {}", doc.width, doc.height);
assert_eq!(doc.pixel_size(), (241, 121));
// The art box's top-left corner is the document's origin: the marker (PDF x 64..84, y 54..64)
// is at x 4..24, y 106.4..116.4 from the top.
let (w, h) = doc.pixel_size();
let img = effectcraft_svg::rasterize(&doc, w, h, 1.0);
let marker = img.get(10, 110);
assert!(marker[2] > 0.99 && marker[0] < 0.01, "{marker:?}");
assert!(img.get(30, 110)[2] < 0.6, "{:?}", img.get(30, 110));
let viewer = parse(&bytes).unwrap();
assert_eq!((viewer.width, viewer.height), (410.0, 210.0), "the viewer framing is unchanged");
}
/// The ArtBox is clipped to the crop box (ISO 32000-1 §14.11.2); one that keeps no area there,
/// a degenerate or unreadable one, or one on the /Pages node (not inherited, table 30) leaves the
/// crop box (else the media box).
#[test]
fn placed_framing_clips_the_art_box_and_falls_back() {
let size = |pages: &str, page: &str| {
let d = placed(&boxes_pdf(pages, page));
(d.width, d.height)
};
let crop = "/CropBox [5 5 415 215]";
assert_eq!(size(crop, "/ArtBox [-100 -100 1000 1000]"), (410.0, 210.0), "larger than the page");
assert_eq!(size(crop, "/ArtBox [300 100 900 900]"), (115.0, 115.0), "partly outside");
let (w, h) = size(crop, "/ArtBox [300.6 170.4 60 50]");
assert!((w - 240.6).abs() < 1e-9 && (h - 120.4).abs() < 1e-9, "corners in any order: {w} × {h}");
// Finite corners whose span is not.
let big = format!("1{}", "0".repeat(308));
let unreadable = [format!("/ArtBox [-{big} 50 {big} 170]")];
for page in ["/ArtBox [500 300 600 400]", "/ArtBox [60 50 60 170]", "/ArtBox [60 50 300]"].map(String::from).iter().chain(&unreadable) {
assert_eq!(size(crop, page), (410.0, 210.0), "{page}");
}
assert_eq!(size("/ArtBox [60 50 300 170]", ""), (420.0, 220.0), "an ArtBox on /Pages is not inherited");
// A crop box that is not a box leaves the media box.
assert_eq!(size("", &format!("/CropBox [-{big} 5 {big} 215]")), (420.0, 220.0));
}
/// After Effects ignores /Rotate on placed footage (a page [100 50 400 150] with /Rotate 90
/// imports as 300 × 100, drawn unturned); the viewer framing turns it.
#[test]
fn placed_framing_ignores_rotate() {
let bytes = boxes_pdf("", "/Rotate 90");
let doc = placed(&bytes);
assert_eq!((doc.width, doc.height), (420.0, 220.0));
// Unturned: the marker at PDF (64..84, 54..64) is at x 64..84, y 156..166 from the top.
let img = effectcraft_svg::rasterize(&doc, 420, 220, 1.0);
assert!(img.get(70, 160)[2] > 0.99, "{:?}", img.get(70, 160));
let viewer = parse(&bytes).unwrap();
assert_eq!((viewer.width, viewer.height), (220.0, 420.0));
}
const EPS: &str = "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n\
/m {moveto} bind def /l {lineto} bind def\n\
/cm { 6 array astore concat } bind def\n\
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "effectcraft-plugin"
description = "EffectCraft effect plug-ins: loads sandboxed WebAssembly effect modules (plug-in API v1) into the effect registry"
description = "EffectCraft effect plug-ins: loads sandboxed WebAssembly effect modules (WebAssembly ABI v1 and v2) into the effect registry"
version.workspace = true
edition.workspace = true
license.workspace = true
+14 -8
View File
@@ -1,21 +1,25 @@
//! # effectcraft-plugin (L3)
//!
//! Loads **WebAssembly effect plug-ins** (plug-in ABI v1, see `docs/plugins.md`; the native
//! plug-in API 2 additions such as layer parameters and host services are not part of it) into the effect
//! registry ([`effectcraft_effects::plugin`]). A plug-in is a `.wasm` (or `.wat`) module with
//! no imports that exports:
//! Loads **WebAssembly effect plug-ins** (WebAssembly ABI v1 and v2, see `docs/plugins.md`; the
//! native plug-in API 2 additions such as layer parameters and host services are not part of it)
//! into the effect registry ([`effectcraft_effects::plugin`]). A plug-in is a `.wasm` (or
//! `.wat`) module with no imports that exports:
//!
//! | export | signature | |
//! |---|---|---|
//! | `memory` | memory | linear memory shared with the host |
//! | `ec_api_version` | `() -> i32` | must return 1 |
//! | `ec_api_version` | `() -> i32` | 1 for ABI 1, 2 for ABI 2 (neighbouring frames) |
//! | `ec_manifest_ptr`, `ec_manifest_len` | `() -> i32` | a UTF-8 JSON [`PluginManifest`] in memory |
//! | `ec_alloc` | `(bytes: i32) -> i32` | a buffer of at least `bytes` bytes, 8-byte aligned; it may reuse the previous one |
//! | `ec_render` | `(pixels: i32, width: i32, height: i32, params: i32, nparams: i32, time: f64, scale: f64) -> i32` | process the pixels in place; 0 = ok |
//! | `ec_render` (ABI 1) | `(pixels: i32, width: i32, height: i32, params: i32, nparams: i32, time: f64, scale: f64) -> i32` | process the pixels in place; 0 = ok |
//! | `ec_render2` (ABI 2) | `ec_render`'s arguments, then `prev: i32, prev_time: f64, next: i32, next_time: f64, flags: i32` | the same, with the neighbouring frames; 0 = ok |
//!
//! For each render the host asks `ec_alloc` for one buffer holding the parameters (`nparams`
//! little-endian `f64`s) followed by the pixels (`width × height × 4` little-endian `f32`s,
//! premultiplied RGBA, row-major), calls `ec_render`, and reads the pixels back.
//! premultiplied RGBA, row-major), calls the render export, and reads the pixels back. An ABI 2
//! module gets two more pixel buffers after the current frame: the layer's previous and next
//! frames, resampled onto the current grid, and their layer times. The buffers are always
//! allocated (zeroed when a frame does not exist); `flags` bit 0/1 says which are real.
//!
//! Modules run in the [wasmi](https://github.com/wasmi-labs/wasmi) interpreter (pure Rust,
//! MIT/Apache-2.0) with deterministic floats, no imports (no files, network or clock) and a
@@ -26,7 +30,9 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use effectcraft_effects::EffectSpec;
pub use effectcraft_effects::plugin::{EffectPlugin, PLUGIN_API_VERSION, PluginFrame, PluginManifest, PluginParams, WASM_API_VERSION, register_plugin};
pub use effectcraft_effects::plugin::{
EffectPlugin, MIN_WASM_API_VERSION, PLUGIN_API_VERSION, PluginFrame, PluginImage, PluginManifest, PluginParams, WASM_API_VERSION, register_plugin,
};
#[cfg(feature = "wasm")]
mod wasm;
+174 -5
View File
@@ -163,11 +163,15 @@ fn wasm_plugins_are_sandboxed() {
// No imports (files, clock, network…).
let imports = r#"(module (import "env" "now" (func $now (result f64))) (memory (export "memory") 1))"#;
assert!(load_err(load_wasm(imports.as_bytes(), "imports.wat")).contains("import"));
// Garbage, wrong API version, bad manifests.
// Garbage, an ABI this build doesn't implement, bad manifests. A module that claims ABI 2
// must export `ec_render2` (this one still has only `ec_render`).
assert!(load_wasm(b"\0asm nope", "bad.wasm").is_err());
let v2 = gain_module("org.test.v2", "")
.replace("(func (export \"ec_api_version\") (result i32) i32.const 1)", "(func (export \"ec_api_version\") (result i32) i32.const 2)");
assert!(load_err(load_wasm(v2.as_bytes(), "v2.wat")).contains("API 2"));
assert!(load_err(load_wasm(v2.as_bytes(), "v2.wat")).contains("ec_render2"));
let v3 = gain_module("org.test.v3", "")
.replace("(func (export \"ec_api_version\") (result i32) i32.const 1)", "(func (export \"ec_api_version\") (result i32) i32.const 3)");
assert!(load_err(load_wasm(v3.as_bytes(), "v3.wat")).contains("ABI 3"));
let reserved = gain_module("ec.blur.mine", "");
assert!(load_err(load_wasm(reserved.as_bytes(), "reserved.wat")).contains("reserved"));
}
@@ -194,14 +198,14 @@ fn abi_violations_fail_to_load_with_reasons() {
assert!(load_err(load_wasm(m.as_bytes(), "x.wat")).contains("ec_api_version"));
let m = edit(&base, "(param $scale f64) (result i32)", "(param $scale f32) (result i32)");
assert!(load_err(load_wasm(m.as_bytes(), "x.wat")).contains("ec_render"));
// API versions other than 1.
for v in ["0", "-1", "2147483647"] {
// WebAssembly ABIs other than 1 and 2.
for v in ["0", "-1", "3", "2147483647"] {
let m = edit(
&base,
"(func (export \"ec_api_version\") (result i32) i32.const 1)",
&format!("(func (export \"ec_api_version\") (result i32) i32.const {v})"),
);
assert!(load_err(load_wasm(m.as_bytes(), "x.wat")).contains(&format!("API {v}")), "{v}");
assert!(load_err(load_wasm(m.as_bytes(), "x.wat")).contains(&format!("ABI {v}")), "{v}");
}
// Manifest out of bounds, negative or absurd lengths, not JSON.
let ptr = "(func (export \"ec_manifest_ptr\") (result i32) i32.const 16)";
@@ -317,3 +321,168 @@ fn wasm_rendering_is_deterministic() {
let twin = load_wasm(gain_module("org.test.det2", body).as_bytes(), "det2.wat").unwrap();
assert_eq!(bits(&render(twin, 0.37, true)), first);
}
/// An ABI 2 module whose `ec_render2` writes what it got from the host into each pixel: red is
/// |next.red − prev.red|, green `flags / 8`, blue `prev_time`, alpha 1.
fn neighbour_module(id: &str) -> String {
let manifest = format!(r#"{{"api":2,"id":"{id}","name":"Neighbours {id}","category":"Test Plug-ins","version":"1","params":[]}}"#);
let len = manifest.len();
let escaped = manifest.replace('\\', "\\\\").replace('"', "\\\"");
format!(
r#"(module
(memory (export "memory") 2)
(data (i32.const 16) "{escaped}")
(func (export "ec_api_version") (result i32) i32.const 2)
(func (export "ec_manifest_ptr") (result i32) i32.const 16)
(func (export "ec_manifest_len") (result i32) i32.const {len})
(func (export "ec_alloc") (param i32) (result i32) i32.const 1024)
(func (export "ec_render2")
(param $px i32) (param $w i32) (param $h i32) (param $params i32) (param $np i32)
(param $t f64) (param $scale f64)
(param $prev i32) (param $prev_t f64) (param $next i32) (param $next_t f64) (param $flags i32)
(result i32)
(local $i i32) (local $n i32)
local.get $w
local.get $h
i32.mul
local.set $n
block $done
loop $per_pixel
local.get $i
local.get $n
i32.ge_u
br_if $done
local.get $px
local.get $i
i32.const 16
i32.mul
i32.add
local.get $next
local.get $i
i32.const 16
i32.mul
i32.add
f32.load
local.get $prev
local.get $i
i32.const 16
i32.mul
i32.add
f32.load
f32.sub
f32.abs
f32.store
local.get $px
local.get $i
i32.const 16
i32.mul
i32.add
local.get $flags
f32.convert_i32_s
f32.const 8
f32.div
f32.store offset=4
local.get $px
local.get $i
i32.const 16
i32.mul
i32.add
local.get $prev_t
f32.demote_f64
f32.store offset=8
local.get $px
local.get $i
i32.const 16
i32.mul
i32.add
f32.const 1
f32.store offset=12
local.get $i
i32.const 1
i32.add
local.set $i
br $per_pixel
end
end
i32.const 0))"#
)
}
fn neighbour_image(red: f32) -> effectcraft_effects::plugin::PluginImage {
effectcraft_effects::plugin::PluginImage { width: 2, height: 1, pixels: vec![[red, 0.0, 0.0, 1.0]; 2], origin: [0.0; 2], scale: 1.0 }
}
/// A host with frames at two layer times.
struct Host {
time: f64,
fps: f64,
prev: Option<(f64, effectcraft_effects::plugin::PluginImage)>,
next: Option<(f64, effectcraft_effects::plugin::PluginImage)>,
}
impl effectcraft_effects::plugin::PluginHost for Host {
fn input_at(&self, layer_time: f64) -> Option<effectcraft_effects::plugin::PluginImage> {
if let Some((t, img)) = &self.prev
&& (t - layer_time).abs() < 1e-9
{
return Some(img.clone());
}
if let Some((t, img)) = &self.next
&& (t - layer_time).abs() < 1e-9
{
return Some(img.clone());
}
None
}
fn layer_input(&self, _param_id: &str, _layer_time: f64) -> Option<effectcraft_effects::plugin::PluginImage> {
None
}
fn params_at(&self, _layer_time: f64) -> Option<(Vec<f64>, Vec<String>)> {
None
}
fn frame_rate(&self) -> f64 {
self.fps
}
fn time(&self) -> f64 {
self.time
}
}
#[test]
fn abi_v2_receives_neighbour_frames_and_times() {
load_wasm(neighbour_module("org.test.neighbours").as_bytes(), "neighbours.wat").unwrap();
let p = effectcraft_effects::plugin::plugin("org.test.neighbours").unwrap();
assert!(effectcraft_effects::plugin::reads_other_times("org.test.neighbours"));
assert!(effectcraft_effects::is_time_dependent("org.test.neighbours"));
// The module has no parameters, so the flattened vector is empty.
let params = effectcraft_effects::plugin::PluginParams { manifest: p.manifest(), flat: &[], texts: &[] };
let mut px = vec![[0.0f32; 4]; 2];
let host = Host { time: 0.5, fps: 4.0, prev: Some((0.25, neighbour_image(0.1))), next: Some((0.75, neighbour_image(0.6))) };
let mut frame = effectcraft_effects::plugin::PluginFrame { width: 2, height: 1, pixels: &mut px, scale: 1.0, origin: [0.0; 2], layer_size: [2.0, 1.0] };
p.render_v2(&mut frame, &params, 0.5, &host).unwrap();
assert!((px[0][0] - 0.5).abs() < 1e-6, "|next − prev|: {px:?}");
assert!((px[0][1] - 3.0 / 8.0).abs() < 1e-6, "both flags: {px:?}");
assert!((px[0][2] - 0.25).abs() < 1e-6, "previous frame's time: {px:?}");
assert_eq!(px[0][3], 1.0);
}
#[test]
fn abi_v2_without_a_host_gets_zeroed_neighbours() {
let spec = load_wasm(neighbour_module("org.test.neighbours.empty").as_bytes(), "empty.wat").unwrap();
let mut next_id = 1;
let g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next_id), spec.name, [2.0, 1.0]);
let params = effectcraft_effects::flatten_params(&g, &mut |p| p.value.clone());
let ctx = EffectCtx { params: &params, time: 0.5, layer_size: [2.0, 1.0], seed: 1, adjustment: false, env: EffectEnv::default() };
let buf = Buf { img: Image::filled(2, 1, [0.5, 0.5, 0.5, 1.0]), offset: [0.0; 2], scale: 1.0 };
// No host: both neighbour buffers are zeroed and no flag is set.
assert_eq!((spec.render)(&ctx, buf).img.get(0, 0), [0.0, 0.0, 0.0, 1.0]);
// ABI 1 plug-ins keep the time-independent behaviour.
let gain = load_wasm(gain_module("org.test.neighbours.v1", "").as_bytes(), "v1.wat").unwrap();
assert!(!effectcraft_effects::plugin::reads_other_times("org.test.neighbours.v1"));
assert!(!effectcraft_effects::is_time_dependent("org.test.neighbours.v1"));
assert!(gain.id.starts_with("org.test."));
}
+244 -43
View File
@@ -1,10 +1,11 @@
//! The wasmi-backed [`EffectPlugin`] (plug-in API v1 ABI, see the crate docs).
//! The wasmi-backed [`EffectPlugin`] (WebAssembly ABI v1 and v2, see the crate docs).
use std::sync::Mutex;
use wasmi::{Config, Engine, Instance, Linker, Memory, Module, Store, StoreLimits, StoreLimitsBuilder, TypedFunc, WasmParams, WasmResults};
use crate::{EffectPlugin, PluginFrame, PluginManifest, PluginParams, WASM_API_VERSION};
use crate::{EffectPlugin, MIN_WASM_API_VERSION, PluginFrame, PluginManifest, PluginParams, WASM_API_VERSION};
use effectcraft_effects::plugin::{PluginHost, PluginImage};
/// Instruction budget per frame: a fixed allowance plus some per pixel. Generous for per-pixel
/// work, but a runaway loop stops instead of hanging the render.
@@ -15,32 +16,74 @@ const MAX_MEMORY: usize = 1 << 30;
/// The longest manifest read.
const MAX_MANIFEST: i32 = 1 << 20;
/// ABI 1: one buffer.
type RenderFn = TypedFunc<(i32, i32, i32, i32, i32, f64, f64), i32>;
/// ABI 2: the same call plus the previous and next frame buffers and their layer times.
type Render2Fn = TypedFunc<(i32, i32, i32, i32, i32, f64, f64, i32, f64, i32, f64, i32), i32>;
enum Renderer {
V1(RenderFn),
V2(Render2Fn),
}
/// One instantiated module (renders running on several threads each take their own).
struct Inst {
store: Store<StoreLimits>,
memory: Memory,
alloc: TypedFunc<i32, i32>,
render: Renderer,
}
/// An instance whose ABI version (and so its render export) is not selected yet.
struct Pending {
store: Store<StoreLimits>,
instance: Instance,
memory: Memory,
alloc: TypedFunc<i32, i32>,
render: RenderFn,
}
impl Inst {
impl Pending {
/// A typed export by name, reporting a missing or mistyped export like [`missing`].
fn func<P: WasmParams, R: WasmResults>(&self, name: &str) -> Result<TypedFunc<P, R>, String> {
self.instance.get_typed_func::<P, R>(&self.store, name).map_err(|_| missing(name))
}
/// Select the render export for `abi`.
fn finish(self, abi: u32) -> Result<Inst, String> {
let render = match abi {
1 => Renderer::V1(self.instance.get_typed_func::<_, i32>(&self.store, "ec_render").map_err(|_| missing("ec_render"))?),
2 => Renderer::V2(self.instance.get_typed_func::<_, i32>(&self.store, "ec_render2").map_err(|_| missing("ec_render2"))?),
v => return Err(unsupported(v as i32)),
};
Ok(Inst { store: self.store, memory: self.memory, alloc: self.alloc, render })
}
}
/// The error for a missing or mistyped export.
fn missing(name: &str) -> String {
format!("the module exports no `{name}` (or with the wrong signature)")
}
/// The error for an ABI this build does not implement.
fn unsupported(v: i32) -> String {
format!("the module implements WebAssembly ABI {v}, this build implements ABI {MIN_WASM_API_VERSION} to {WASM_API_VERSION}")
}
/// The module's `ec_api_version`, when this build implements it.
fn abi_of(v: i32) -> Result<u32, String> {
match u32::try_from(v) {
Ok(v) if (MIN_WASM_API_VERSION..=WASM_API_VERSION).contains(&v) => Ok(v),
_ => Err(unsupported(v)),
}
}
/// The error for a wasmi or trap failure.
fn err(e: impl std::fmt::Display) -> String {
e.to_string()
}
fn instantiate(engine: &Engine, module: &Module) -> Result<Inst, String> {
/// Instantiate `module` in a fresh, limited store and read its memory and allocator.
fn begin(engine: &Engine, module: &Module) -> Result<Pending, String> {
let mut store = Store::new(engine, StoreLimitsBuilder::new().memory_size(MAX_MEMORY).instances(1).build());
store.limiter(|l| l);
store.set_fuel(FUEL_BASE).map_err(err)?;
@@ -48,14 +91,20 @@ fn instantiate(engine: &Engine, module: &Module) -> Result<Inst, String> {
let instance = linker.instantiate_and_start(&mut store, module).map_err(|e| format!("instantiation failed: {e}"))?;
let memory = instance.get_memory(&store, "memory").ok_or("the module exports no `memory`")?;
let alloc = instance.get_typed_func::<i32, i32>(&store, "ec_alloc").map_err(|_| missing("ec_alloc"))?;
let render = instance.get_typed_func::<(i32, i32, i32, i32, i32, f64, f64), i32>(&store, "ec_render").map_err(|_| missing("ec_render"))?;
Ok(Inst { store, instance, memory, alloc, render })
Ok(Pending { store, instance, memory, alloc })
}
/// Instantiate `module` and select the render export for `abi`.
fn instantiate(engine: &Engine, module: &Module, abi: u32) -> Result<Inst, String> {
begin(engine, module)?.finish(abi)
}
/// A loaded WebAssembly effect plug-in.
pub struct WasmPlugin {
manifest: PluginManifest,
source: String,
/// The WebAssembly ABI the module implements (`ec_api_version`).
abi: u32,
engine: Engine,
module: Module,
/// Idle instances.
@@ -63,6 +112,7 @@ pub struct WasmPlugin {
}
impl WasmPlugin {
/// Load `bytes` and its manifest; errors on imports, a bad manifest or a missing export.
pub fn new(bytes: &[u8], source: &str) -> Result<WasmPlugin, String> {
let mut config = Config::default();
config.consume_fuel(true);
@@ -71,51 +121,42 @@ impl WasmPlugin {
if let Some(i) = module.imports().next() {
return Err(format!("plug-ins can't import anything (the module imports `{}.{}`)", i.module(), i.name()));
}
let mut inst = instantiate(&engine, &module)?;
let version = inst.func::<(), i32>("ec_api_version")?;
let v = version.call(&mut inst.store, ()).map_err(err)?;
if u32::try_from(v).ok() != Some(WASM_API_VERSION) {
return Err(format!("the module implements plug-in API {v}, this build implements API {WASM_API_VERSION} for WebAssembly"));
}
let ptr = inst.func::<(), i32>("ec_manifest_ptr")?.call(&mut inst.store, ()).map_err(err)?;
let len = inst.func::<(), i32>("ec_manifest_len")?.call(&mut inst.store, ()).map_err(err)?;
let mut pending = begin(&engine, &module)?;
let version = pending.func::<(), i32>("ec_api_version")?;
let abi = abi_of(version.call(&mut pending.store, ()).map_err(err)?)?;
let ptr = pending.func::<(), i32>("ec_manifest_ptr")?.call(&mut pending.store, ()).map_err(err)?;
let len = pending.func::<(), i32>("ec_manifest_len")?.call(&mut pending.store, ()).map_err(err)?;
if !(0..=MAX_MANIFEST).contains(&len) {
return Err(format!("bad manifest length {len}"));
}
let mut text = vec![0u8; len as usize];
inst.memory.read(&inst.store, ptr as u32 as usize, &mut text).map_err(|e| format!("manifest out of bounds: {e}"))?;
pending.memory.read(&pending.store, ptr as u32 as usize, &mut text).map_err(|e| format!("manifest out of bounds: {e}"))?;
let manifest: PluginManifest = serde_json::from_slice(&text).map_err(|e| format!("bad manifest JSON: {e}"))?;
manifest.validate()?;
if let Some(p) = manifest.params.iter().find(|p| p.kind.needs_v2()) {
return Err(format!("parameter `{}`: WebAssembly plug-ins use plug-in API 1 parameter types (no point3, layer, text or hidden)", p.id));
}
Ok(WasmPlugin { manifest, source: source.to_string(), engine, module, pool: Mutex::new(vec![inst]) })
let inst = pending.finish(abi)?;
Ok(WasmPlugin { manifest, source: source.to_string(), abi, engine, module, pool: Mutex::new(vec![inst]) })
}
/// An idle instance, or a fresh one when the pool is empty.
fn take(&self) -> Result<Inst, String> {
if let Some(i) = self.pool.lock().ok().and_then(|mut p| p.pop()) {
return Ok(i);
}
instantiate(&self.engine, &self.module)
instantiate(&self.engine, &self.module, self.abi)
}
/// Return a working instance to the pool.
fn give_back(&self, inst: Inst) {
if let Ok(mut p) = self.pool.lock() {
p.push(inst);
}
}
}
impl EffectPlugin for WasmPlugin {
fn manifest(&self) -> &PluginManifest {
&self.manifest
}
fn source(&self) -> String {
self.source.clone()
}
fn render(&self, frame: &mut PluginFrame, params: &PluginParams, time: f64) -> Result<(), String> {
/// ABI 1: parameters, then the pixels; `ec_render`.
fn render_v1(&self, frame: &mut PluginFrame, params: &PluginParams, time: f64) -> Result<(), String> {
let n = frame.pixels.len();
let params_bytes = params.flat.len() * 8;
let total = params_bytes + n * 16;
@@ -130,25 +171,19 @@ impl EffectPlugin for WasmPlugin {
for v in params.flat {
buf.extend_from_slice(&v.to_le_bytes());
}
for px in frame.pixels.iter() {
for c in px {
buf.extend_from_slice(&c.to_le_bytes());
}
}
push_pixels(&mut buf, frame.pixels);
inst.memory.write(&mut inst.store, base, &buf).map_err(|e| format!("ec_alloc returned a buffer out of bounds: {e}"))?;
let px_ptr = (base + params_bytes) as i32;
let r = inst
.render
.call(&mut inst.store, (px_ptr, frame.width as i32, frame.height as i32, base as i32, params.flat.len() as i32, time, frame.scale))
.map_err(|e| format!("ec_render trapped: {e}"));
let r = match &inst.render {
Renderer::V1(f) => f
.call(&mut inst.store, (px_ptr, frame.width as i32, frame.height as i32, base as i32, params.flat.len() as i32, time, frame.scale))
.map_err(|e| format!("ec_render trapped: {e}")),
Renderer::V2(_) => return Err("the module implements WebAssembly ABI 1 but exports ec_render2".into()),
};
let r = match r {
Ok(0) => {
inst.memory.read(&inst.store, base + params_bytes, &mut buf[params_bytes..]).map_err(err)?;
for (px, b) in frame.pixels.iter_mut().zip(buf[params_bytes..].as_chunks::<16>().0.iter()) {
for (c, v) in px.iter_mut().zip(b.as_chunks::<4>().0.iter()) {
*c = f32::from_le_bytes([v[0], v[1], v[2], v[3]]);
}
}
read_pixels(&buf[params_bytes..], frame.pixels);
Ok(())
}
Ok(code) => Err(format!("ec_render returned error {code}")),
@@ -159,4 +194,170 @@ impl EffectPlugin for WasmPlugin {
}
r
}
/// ABI 2: parameters, the current frame, then the previous and next frames; `ec_render2`.
/// The neighbour buffers are always allocated (zeroed when absent), so a module can read
/// both without a null check; `flags` says which hold real frames.
fn render_v2(
&self,
frame: &mut PluginFrame,
params: &PluginParams,
time: f64,
prev: Option<(f64, &PluginImage)>,
next: Option<(f64, &PluginImage)>,
) -> Result<(), String> {
let n = frame.pixels.len();
let params_bytes = params.flat.len() * 8;
let frame_bytes = n * 16;
let total = params_bytes + frame_bytes * 3;
if total > i32::MAX as usize {
return Err("frame too large for a 32-bit plug-in".into());
}
let prev_px = prev.map(|(t, img)| (t, img.on_grid(frame.width, frame.height, frame.origin, frame.scale)));
let next_px = next.map(|(t, img)| (t, img.on_grid(frame.width, frame.height, frame.origin, frame.scale)));
let prev = prev_px.as_ref().filter(|(_, px)| px.len() == n);
let next = next_px.as_ref().filter(|(_, px)| px.len() == n);
let mut inst = self.take()?;
inst.store.set_fuel(FUEL_BASE + FUEL_PER_PIXEL * (n as u64 * 3)).map_err(err)?;
let base = inst.alloc.call(&mut inst.store, total as i32).map_err(err)? as u32 as usize;
let mut buf: Vec<u8> = Vec::with_capacity(total);
for v in params.flat {
buf.extend_from_slice(&v.to_le_bytes());
}
push_pixels(&mut buf, frame.pixels);
push_neighbour(&mut buf, prev, frame_bytes);
push_neighbour(&mut buf, next, frame_bytes);
inst.memory.write(&mut inst.store, base, &buf).map_err(|e| format!("ec_alloc returned a buffer out of bounds: {e}"))?;
let px_ptr = (base + params_bytes) as i32;
let prev_ptr = (base + params_bytes + frame_bytes) as i32;
let next_ptr = (base + params_bytes + frame_bytes * 2) as i32;
let flags = i32::from(prev.is_some()) | (i32::from(next.is_some()) << 1);
let prev_time = prev.map_or(0.0, |(t, _)| *t);
let next_time = next.map_or(0.0, |(t, _)| *t);
let r = match &inst.render {
Renderer::V2(f) => f
.call(
&mut inst.store,
(
px_ptr,
frame.width as i32,
frame.height as i32,
base as i32,
params.flat.len() as i32,
time,
frame.scale,
prev_ptr,
prev_time,
next_ptr,
next_time,
flags,
),
)
.map_err(|e| format!("ec_render2 trapped: {e}")),
Renderer::V1(_) => return Err("the module implements WebAssembly ABI 2 but exports ec_render".into()),
};
let r = match r {
Ok(0) => {
let mut out = vec![0u8; frame_bytes];
inst.memory.read(&inst.store, base + params_bytes, &mut out).map_err(err)?;
read_pixels(&out, frame.pixels);
Ok(())
}
Ok(code) => Err(format!("ec_render2 returned error {code}")),
Err(e) => Err(e),
};
if r.is_ok() {
self.give_back(inst);
}
r
}
}
/// Append pixels to the little-endian buffer the module reads.
fn push_pixels(buf: &mut Vec<u8>, px: &[[f32; 4]]) {
for p in px {
for c in p {
buf.extend_from_slice(&c.to_le_bytes());
}
}
}
/// The neighbour's pixels, or `frame_bytes` zeroed bytes when there is no frame.
fn push_neighbour(buf: &mut Vec<u8>, neighbour: Option<&(f64, Vec<[f32; 4]>)>, frame_bytes: usize) {
match neighbour {
Some((_, px)) => push_pixels(buf, px),
None => buf.resize(buf.len() + frame_bytes, 0),
}
}
/// Read the little-endian pixels the module wrote back.
fn read_pixels(bytes: &[u8], pixels: &mut [[f32; 4]]) {
for (px, b) in pixels.iter_mut().zip(bytes.as_chunks::<16>().0.iter()) {
for (c, v) in px.iter_mut().zip(b.as_chunks::<4>().0.iter()) {
*c = f32::from_le_bytes([v[0], v[1], v[2], v[3]]);
}
}
}
/// The layer times of the frames around `time` on a `fps` grid: the frame before and after it
/// at a frame boundary, the two frames that bracket it between frames.
fn neighbour_times(time: f64, fps: f64) -> (f64, f64) {
if !fps.is_finite() || fps <= 0.0 {
return (time - 1.0 / 30.0, time + 1.0 / 30.0);
}
let step = 1.0 / fps;
let at = time * fps;
// Frame times are not exact in floating point: at NTSC rates `time * fps` often lands a few
// ulps above or below an integer. Treat a near-integer as a frame boundary, so the current
// frame is never handed back as its own neighbour.
if (at - at.round()).abs() < 1e-6 { (time - step, time + step) } else { (at.floor() / fps, at.ceil() / fps) }
}
impl EffectPlugin for WasmPlugin {
fn manifest(&self) -> &PluginManifest {
&self.manifest
}
fn source(&self) -> String {
self.source.clone()
}
fn reads_other_times(&self) -> bool {
self.abi >= 2
}
fn render(&self, frame: &mut PluginFrame, params: &PluginParams, time: f64) -> Result<(), String> {
if self.abi == 1 { self.render_v1(frame, params, time) } else { self.render_v2(frame, params, time, None, None) }
}
fn render_v2(&self, frame: &mut PluginFrame, params: &PluginParams, time: f64, host: &dyn PluginHost) -> Result<(), String> {
if self.abi == 1 {
return self.render_v1(frame, params, time);
}
let (prev_time, next_time) = neighbour_times(time, host.frame_rate());
let prev = host.input_at(prev_time);
let next = host.input_at(next_time);
let prev = prev.as_ref().map(|img| (prev_time, img));
let next = next.as_ref().map(|img| (next_time, img));
self.render_v2(frame, params, time, prev, next)
}
}
#[cfg(test)]
mod tests {
use super::neighbour_times;
/// NTSC frame times do not multiply back exactly; every frame boundary must still get the
/// frames either side of it, never the frame itself.
#[test]
fn frame_boundaries_get_their_neighbours_at_ntsc_rates() {
let fps = 24000.0 / 1001.0;
for n in 0..2400 {
// 24000/1001 fps at 254 016 000 000 ticks/s, as `Tick` stores it.
let time = (n * 10_594_584_000_i64) as f64 / 254_016_000_000.0;
let (prev, next) = neighbour_times(time, fps);
assert!((prev - (time - 1.0 / fps)).abs() < 1e-9, "frame {n}: prev {prev}, current {time}");
assert!((next - (time + 1.0 / fps)).abs() < 1e-9, "frame {n}: next {next}, current {time}");
}
}
}
+8
View File
@@ -269,6 +269,13 @@ pub struct Footage {
/// made from the footage starts with them, and Update Markers From Source copies new ones.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub markers: Vec<Marker>,
/// PDF / Illustrator footage (`.pdf`, `.ai`; not EPS) from an After Effects project: the page
/// is framed as After Effects places it (the ArtBox, `/Rotate` ignored) and drawn to fill
/// `width` × `height`, the size After Effects stored, which the layers' anchors and masks are
/// measured against (#3099). Other footage (and every project saved before this field) shows
/// the page as a PDF viewer does, at its own size.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub placed_page: bool,
}
fn is_default<T: Default + PartialEq>(v: &T) -> bool {
@@ -307,6 +314,7 @@ impl Default for Footage {
start_timecode: None,
file_timecode: None,
markers: vec![],
placed_page: false,
}
}
}
+5 -2
View File
@@ -21,10 +21,13 @@ pub enum Backend {
}
impl Backend {
/// The backend `s` names, whatever case it is written in: the project's own values (`cpu`,
/// `gpu`, `auto`), the labels Project Settings ▸ Video Rendering and Effects draws
/// (`GPU Acceleration`, `Software Rendering`) and the older After Effects wording.
pub fn parse(s: &str) -> Option<Backend> {
match s.to_ascii_lowercase().as_str() {
"cpu" | "software" | "mercury software only" => Some(Backend::Cpu),
"gpu" | "mercury gpu acceleration" => Some(Backend::Gpu),
"cpu" | "software" | "mercury software only" | "software rendering" => Some(Backend::Cpu),
"gpu" | "mercury gpu acceleration" | "gpu acceleration" => Some(Backend::Gpu),
"auto" => Some(Backend::Auto),
_ => None,
}
+3 -1
View File
@@ -694,7 +694,9 @@ fn reads_other_times(layer: &Layer, effects: Option<usize>) -> bool {
fx.groups().take(effects.unwrap_or(usize::MAX)).any(|g| {
g.enabled
&& matches!(&g.kind, effectcraft_project::GroupKind::Effect { effect }
if effect.starts_with("ec.time.") || effectcraft_effects::reads_param_history(effect))
if effect.starts_with("ec.time.")
|| effectcraft_effects::reads_param_history(effect)
|| (!effect.starts_with("ec.") && effectcraft_effects::plugin::reads_other_times(effect)))
})
})
}
+27 -4
View File
@@ -76,8 +76,9 @@ pub trait FootageSource: Send + Sync {
fn cache_budget(&self) -> Option<usize> {
None
}
/// Vector footage (SVG) rasterised at `scale` × its pixel size, for Continuously Rasterize.
/// `None` when the footage is not vector or cannot be read.
/// Vector footage (SVG, PDF, Illustrator, EPS: [`is_vector_footage`]) rasterised at `scale` ×
/// its size as [`vector_fit`] draws it, for Continuously Rasterize. `None` when the footage
/// is not vector or cannot be read.
fn vector_frame(&self, _item: ItemId, _footage: &Footage, _scale: f64) -> Option<Arc<Image>> {
None
}
@@ -216,9 +217,31 @@ impl FootageSource for CancellableFootage<'_> {
}
}
/// Footage that can be rasterised at any scale (SVG).
/// Footage that can be rasterised at any scale: SVG, PDF, Illustrator and EPS files, and a
/// placed page of an After Effects project (its codec is not stored there).
pub fn is_vector_footage(f: &Footage) -> bool {
matches!(f.codec.as_str(), "SVG" | "PDF" | "AI" | "EPS")
matches!(f.codec.as_str(), "SVG" | "PDF" | "AI" | "EPS") || f.placed_page
}
/// How vector footage `f` is drawn at `scale` × its size: the image size, and the x and y scale
/// from document pixels (`doc` = the document's width and height, `own` = its pixel size, the
/// size rounded up) to image pixels.
///
/// Footage from an After Effects project ([`Footage::placed_page`]) fills its stored size, which
/// can round or measure the page differently: the layer's anchor and masks are measured against
/// that size, and the renderer resamples a reduced-resolution frame to it, so drawn at its own
/// size the art was scaled by the width ratio alone at full resolution (off against the masks)
/// and stretched to the stored aspect below 3/4 resolution (#3099). Other footage is drawn 1:1 at
/// the document's own size, as it always was.
pub fn vector_fit(f: &Footage, doc: [f64; 2], own: (u32, u32), scale: f64) -> ((u32, u32), [f64; 2]) {
let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
let side = |px: u32| (f64::from(px) * scale).ceil().clamp(1.0, f64::from(u32::MAX)) as u32;
let size = (f.width, f.height);
let fills = f.placed_page && size != own && size.0 > 0 && size.1 > 0 && doc.iter().all(|d| d.is_finite() && *d > 0.0);
if !fills {
return ((side(own.0), side(own.1)), [scale, scale]);
}
((side(size.0), side(size.1)), [scale * f64::from(size.0) / doc[0], scale * f64::from(size.1) / doc[1]])
}
/// No footage available (renders footage layers as transparent).
+1 -1
View File
@@ -47,7 +47,7 @@ mod opts;
mod pipeline;
pub use accel::{Accelerator, Backend, FrameMemory, FxStep, FxTarget};
pub use footage::{CancellableFootage, FloatCurve, FootageSource, NoFootage, is_vector_footage, missing_item_problem};
pub use footage::{CancellableFootage, FloatCurve, FootageSource, NoFootage, is_vector_footage, missing_item_problem, vector_fit};
pub use frames::{
adaptive_samples, blend_frames, cineon_frame, content_bounds, extended_region, field_frame, frame_position, frame_size, interpret_pixels, kurbo_path,
render_frame, roi_inside,
+32 -11
View File
@@ -329,7 +329,7 @@ fn card_dance_comp_camera_matches_the_default_view() {
assert!(img.get(10, 50)[3] < 0.01);
}
/// A black 2D solid with CC Particle World, under a camera layer panned right by `pan` px.
/// A black 2D solid with Particle Emitter 3D, under a camera layer panned right by `pan` px.
fn particle_world_scene(pan: f64) -> (Project, ItemId) {
let (mut p, cid, comp) = setup();
let mut l = solid(&mut p, &comp, [0.0, 0.0, 0.0], 200, 100);
@@ -359,7 +359,7 @@ fn particles_x(img: &crate::Image) -> f32 {
sx / m
}
/// #397: CC Particle World on a 2D layer is seen through the comp's camera layer, and the layer
/// #397: Particle Emitter 3D on a 2D layer is seen through the comp's camera layer, and the layer
/// cache notices the camera moving.
#[test]
fn particle_world_follows_the_comp_camera() {
@@ -374,7 +374,7 @@ fn particle_world_follows_the_comp_camera() {
assert!(b < a - 20.0, "panning the camera right moves the particles left: {a} -> {b}");
}
/// A black solid with CC Particle World whose Birth Rate is 3 until layer time `cut`, then 0 a
/// A black solid with Particle Emitter 3D whose Birth Rate is 3 until layer time `cut`, then 0 a
/// frame later (`None`: 3 throughout); the layer starts at comp time `start`, stretched `stretch` %.
fn burst_scene(cut: Option<f64>, start: f64, stretch: f64) -> (Project, ItemId) {
burst_scene_keys(cut, start, stretch, false)
@@ -624,7 +624,7 @@ fn a_text_expression_selector_is_not_served_from_the_disk_cache() {
/// A Birth Rate expression reading another layer: changing that layer changes the history but
/// not the current rate (0) nor this layer's properties, which is all the layer cache key sees.
/// CC Particle World layers with expressions therefore aren't layer cached.
/// Particle Emitter 3D layers with expressions therefore aren't layer cached.
#[test]
fn particle_world_with_a_birth_rate_expression_is_not_served_from_the_layer_cache() {
let scene = |opacity: f64| {
@@ -657,8 +657,8 @@ fn particle_world_with_a_birth_rate_expression_is_not_served_from_the_layer_cach
assert!(light(&b) > 1.0 && light(&b) < light(&a), "fewer births: {} vs {}", light(&b), light(&a));
}
/// A grey solid (CC Mr. Mercury shades its blobs from it) with effect `id` (CC Particle Systems
/// II, CC Mr. Mercury): Birth Rate `rate`,
/// A grey solid (Liquid Particles shades its blobs from it) with effect `id` (CC Particle Systems
/// II, Liquid Particles): Birth Rate `rate`,
/// or keyed 3 until 0.5 s and 0 a frame later (`None`); Longevity 1 s.
fn system_scene(id: &str, rate: Option<f64>) -> (Project, ItemId) {
let (mut p, cid, comp) = setup();
@@ -677,7 +677,7 @@ fn system_scene(id: &str, rate: Option<f64>) -> (Project, ItemId) {
(p, cid)
}
/// #1373 for CC Particle Systems II and CC Mr. Mercury through the renderer: a keyed burst
/// #1373 for Particle Emitter 2D and Liquid Particles through the renderer: a keyed burst
/// draws as the constant rate during it, still draws after the rate drops to 0 (unlike a rate
/// of 0 throughout), and draws as nothing born once Longevity has run out.
#[test]
@@ -1057,6 +1057,27 @@ fn comp_frame_ends_the_frame_when_the_render_panics() {
assert_eq!(a.ends.load(Ordering::SeqCst), 1);
}
/// The renderer labels Project Settings ▸ Video Rendering and Effects draws parse back as the
/// backend they name (in any case), and the older spellings still work.
#[test]
fn renderer_labels_parse_back_and_keep_the_older_spellings() {
use crate::Backend;
for (label, backend) in [
("GPU Acceleration", Backend::Gpu),
("gpu acceleration", Backend::Gpu),
("Mercury GPU Acceleration", Backend::Gpu),
("gpu", Backend::Gpu),
("Software Rendering", Backend::Cpu),
("software rendering", Backend::Cpu),
("Mercury Software Only", Backend::Cpu),
("cpu", Backend::Cpu),
("Auto", Backend::Auto),
] {
assert_eq!(Backend::parse(label), Some(backend), "{label}");
}
assert_eq!(Backend::parse("Vulkan"), None);
}
#[test]
fn auto_backend_sends_2d_and_3d_comps_to_the_gpu() {
let (mut p, cid, comp) = setup();
@@ -1223,11 +1244,11 @@ fn effects_on_text_reach_past_the_text_bounds() {
}
assert!(
painted > 50 && hi.saturating_sub(lo) > 100,
"CC Tiler tiles the glyphs across the layer region, not just its text bounds: {painted} painted, x-span {lo}..{hi}"
"Scaled Tiles tiles the glyphs across the layer region, not just its text bounds: {painted} painted, x-span {lo}..{hi}"
);
}
/// #658: CC Smear drags text glyphs beyond the text layer's own bounds. Pixels smeared past
/// #658: Directional Smear drags text glyphs beyond the text layer's own bounds. Pixels smeared past
/// the painted rectangle used to fall off its edge, so the liquid trail never appeared.
#[test]
fn cc_smear_trails_text_past_its_bounds() {
@@ -1246,7 +1267,7 @@ fn cc_smear_trails_text_past_its_bounds() {
);
p.comp_mut(cid).unwrap().layers = vec![l].into();
let smeared = right(&render_frame(&p, cid, Tick::ZERO, 1.0));
assert!(plain > 40 && smeared > plain + 25, "CC Smear trails the glyphs past the text bounds: right edge {plain} → {smeared}");
assert!(plain > 40 && smeared > plain + 25, "Directional Smear trails the glyphs past the text bounds: right edge {plain} → {smeared}");
}
/// A layer parameter pointing at a shape layer sees all of it (in its effect space), and the
@@ -1502,7 +1523,7 @@ fn solo_excludes_other_layers_outside_the_soloed_layers_range() {
assert_eq!(second.get(100, 50), [0.0, 0.0, 1.0, 1.0], "only B inside its range");
}
/// #1252: a layer blended with a CC Particle World layer, under a camera panned by `pan` px.
/// #1252: a layer blended with a Particle Emitter 3D layer, under a camera panned by `pan` px.
fn scene_reader_dependency(pan: f64) -> (Project, ItemId) {
let (mut p, cid, comp) = setup();
let mut source = solid(&mut p, &comp, [0.0, 0.0, 0.0], 200, 100);
+89
View File
@@ -1,7 +1,10 @@
//! Time effects through the renderer: neighbouring frames via `EffectHost::self_at`, and the
//! layer cache staying correct while scrubbing.
use std::sync::Arc;
use effectcraft_color::Label;
use effectcraft_effects::plugin::{EffectPlugin, PluginFrame, PluginManifest, PluginParams, register_plugin};
use effectcraft_keyframe::{Keyframe, ShapePath, Value};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, MaskMode, Project, Solid};
@@ -157,3 +160,89 @@ fn input_keys_ignore_later_effects() {
set(&mut p, "effects/#1/blurriness", 8.0);
assert_ne!(key(&p).0, one);
}
/// A plug-in that reads the layer's neighbouring frames (WebAssembly ABI 2) makes its layer's
/// cache key depend on the layer time, even when the source and every property are static. A
/// plug-in that does not keeps the old key (#1977: a static layer stays cached across a scrub).
#[test]
fn abi2_plugin_layers_key_on_layer_time() {
struct NeighbourReader {
m: PluginManifest,
}
impl EffectPlugin for NeighbourReader {
fn manifest(&self) -> &PluginManifest {
&self.m
}
fn render(&self, _frame: &mut PluginFrame, _params: &PluginParams, _time: f64) -> Result<(), String> {
Ok(())
}
fn reads_other_times(&self) -> bool {
true
}
}
struct Plain {
m: PluginManifest,
}
impl EffectPlugin for Plain {
fn manifest(&self) -> &PluginManifest {
&self.m
}
fn render(&self, _frame: &mut PluginFrame, _params: &PluginParams, _time: f64) -> Result<(), String> {
Ok(())
}
}
/// A static white solid with one plug-in effect, registered by `plugin`.
fn plugin_scene(plugin: Arc<dyn EffectPlugin>) -> (Project, ItemId) {
let mut p = Project::default();
let comp = Comp::new(200, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 1.0, 1.0], width: 200, height: 100, pixel_aspect: 1.0 }));
let mut l = build::layer(&mut p, &comp, "Solid", LayerSource::Solid { item: sid }, (200, 100), None);
let spec = register_plugin(plugin).unwrap();
let mut next = p.next_id;
let g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [200.0, 100.0]);
p.next_id = next;
l.props.sub_mut("effects").unwrap().children.push(g.into());
p.comp_mut(cid).unwrap().layers.push(l);
(p, cid)
}
fn key(p: &Project, cid: ItemId, t: f64) -> Option<u64> {
let comp = p.comp(cid).unwrap();
let ctx = crate::EvalCtx {
project: p,
comp_id: cid,
comp,
time: Tick::from_seconds_f64(t),
expr: None,
footage: None,
property_overrides: &[],
media_overrides: &[],
reads: None,
};
crate::cache::layer_key(&ctx, &comp.layers[0], 1.0, false, false)
}
let manifest = |id: &str| PluginManifest {
api: 2,
id: id.into(),
name: "Key".into(),
category: "Test Plug-ins".into(),
version: "1".into(),
author: String::new(),
description: String::new(),
params: vec![],
};
let (plain, c1) = plugin_scene(Arc::new(Plain { m: manifest("org.test.render.time-key.plain") }));
assert_eq!(key(&plain, c1, 0.25), key(&plain, c1, 0.5), "a plain plug-in keeps the old key");
let (reader, c2) = plugin_scene(Arc::new(NeighbourReader { m: manifest("org.test.render.time-key.reader") }));
assert_ne!(key(&reader, c2, 0.25), key(&reader, c2, 0.5), "an ABI 2 plug-in keys on the layer time");
}
+11 -1
View File
@@ -124,7 +124,7 @@ impl PanelKind {
PanelKind::EssentialGraphics => "Essential Graphics",
PanelKind::ScriptPanel(_) => "ScriptUI Panel",
PanelKind::Viewer(_) => "Composition",
PanelKind::LumetriScopes => "Lumetri Scopes",
PanelKind::LumetriScopes => "Video Scopes",
PanelKind::Footage => "Footage",
PanelKind::MediaBrowser => "Media Browser",
PanelKind::Metadata => "Metadata",
@@ -709,6 +709,16 @@ mod tests {
w.apply(action, permissions).unwrap();
}
/// Only the title changes: existing saved workspace layouts still deserialize.
#[test]
fn scopes_label_is_descriptive_and_its_saved_identity_stays_compatible() {
let panel: PanelKind = serde_json::from_str("\"LumetriScopes\"").unwrap();
assert_eq!(panel, LumetriScopes);
assert_eq!(panel.title(), "Video Scopes");
assert_eq!(panel.id(), "LumetriScopes");
assert_eq!(serde_json::to_string(&panel).unwrap(), "\"LumetriScopes\"");
}
#[test]
fn after_effects_workspaces_exist_with_their_core_panels() {
for name in WORKSPACES {
+28 -13
View File
@@ -1054,7 +1054,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str, &str)] = &[
("window.panel", "Essential Graphics", "基本图形"),
("window.panel", "Info", "信息"),
("help.inAppTutorials", "Learn", "学习"),
("window.panel", "Lumetri Scopes", "Lumetri 范围"),
("window.panel", "Video Scopes", "视频示波器"),
("window.panel", "Mask Interpolation", "蒙版插值"),
("window.panel", "Media Browser", "媒体浏览器"),
("window.panel", "Metadata", "元数据"),
@@ -1690,7 +1690,7 @@ const SWEDISH: &[(&str, &str, &str)] = &[
("window.panel", "Essential Graphics", "Grundläggande grafik"),
("window.panel", "Info", "Info"),
("help.inAppTutorials", "Learn", "Lär dig"),
("window.panel", "Lumetri Scopes", "Lumetri-scope"),
("window.panel", "Video Scopes", "Videoskop"),
("window.panel", "Mask Interpolation", "Maskinterpolering"),
("window.panel", "Media Browser", "Mediebläddrare"),
("window.panel", "Metadata", "Metadata"),
@@ -2336,7 +2336,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str, &str)] = &[
("window.panel", "Essential Graphics", "基本圖形"),
("window.panel", "Info", "資訊"),
("help.inAppTutorials", "Learn", "學習"),
("window.panel", "Lumetri Scopes", "Lumetri 示波器"),
("window.panel", "Video Scopes", "視訊示波器"),
("window.panel", "Mask Interpolation", "遮色片插補"),
("window.panel", "Media Browser", "媒體瀏覽器"),
("window.panel", "Metadata", "中繼資料"),
@@ -2975,7 +2975,7 @@ const JAPANESE: &[(&str, &str, &str)] = &[
("window.panel", "Essential Graphics", "エッセンシャルグラフィックス"),
("window.panel", "Info", "情報"),
("help.inAppTutorials", "Learn", "学習"),
("window.panel", "Lumetri Scopes", "Lumetriスコープ"),
("window.panel", "Video Scopes", "ビデオスコープ"),
("window.panel", "Mask Interpolation", "マスク補間"),
("window.panel", "Media Browser", "メディアブラウザー"),
("window.panel", "Metadata", "メタデータ"),
@@ -3617,7 +3617,7 @@ const UKRAINIAN: &[(&str, &str, &str)] = &[
("window.panel", "Essential Graphics", "Основна графіка"),
("window.panel", "Info", "Інформація"),
("help.inAppTutorials", "Learn", "Навчання"),
("window.panel", "Lumetri Scopes", "Вимірювачі Lumetri"),
("window.panel", "Video Scopes", "Відеоскопи"),
("window.panel", "Mask Interpolation", "Інтерполяція маски"),
("window.panel", "Media Browser", "Браузер медіа"),
("window.panel", "Metadata", "Метадані"),
@@ -4260,7 +4260,7 @@ const SPANISH: &[(&str, &str, &str)] = &[
("window.panel", "Essential Graphics", "Gráficos esenciales"),
("window.panel", "Info", "Información"),
("help.inAppTutorials", "Learn", "Aprender"),
("window.panel", "Lumetri Scopes", "Ámbitos de Lumetri"),
("window.panel", "Video Scopes", "Monitores de vídeo"),
("window.panel", "Mask Interpolation", "Interpolación de máscara"),
("window.panel", "Media Browser", "Navegador de medios"),
("window.panel", "Metadata", "Metadatos"),
@@ -4901,7 +4901,7 @@ const PORTUGUESE: &[(&str, &str, &str)] = &[
("window.panel", "Essential Graphics", "Gráficos essenciais"),
("window.panel", "Info", "Informações"),
("help.inAppTutorials", "Learn", "Aprender"),
("window.panel", "Lumetri Scopes", "Escopos Lumetri"),
("window.panel", "Video Scopes", "Escopos de vídeo"),
("window.panel", "Mask Interpolation", "Interpolação de máscara"),
("window.panel", "Media Browser", "Navegador de mídia"),
("window.panel", "Metadata", "Metadados"),
@@ -5544,7 +5544,7 @@ const RUSSIAN: &[(&str, &str, &str)] = &[
("window.panel", "Essential Graphics", "Основная графика"),
("window.panel", "Info", "Информация"),
("help.inAppTutorials", "Learn", "Обучение"),
("window.panel", "Lumetri Scopes", "Измерители Lumetri"),
("window.panel", "Video Scopes", "Видеоскопы"),
("window.panel", "Mask Interpolation", "Интерполяция маски"),
("window.panel", "Media Browser", "Обозреватель медиа"),
("window.panel", "Metadata", "Метаданные"),
@@ -6185,7 +6185,7 @@ const FRENCH: &[(&str, &str, &str)] = &[
("window.panel", "Essential Graphics", "Graphiques essentiels"),
("window.panel", "Info", "Informations"),
("help.inAppTutorials", "Learn", "Apprentissage"),
("window.panel", "Lumetri Scopes", "Oscilloscopes Lumetri"),
("window.panel", "Video Scopes", "Oscilloscopes vidéo"),
("window.panel", "Mask Interpolation", "Interpolation de masque"),
("window.panel", "Media Browser", "Navigateur multimédia"),
("window.panel", "Metadata", "Métadonnées"),
@@ -6826,7 +6826,7 @@ const GERMAN: &[(&str, &str, &str)] = &[
("window.panel", "Essential Graphics", "Essential-Grafik"),
("window.panel", "Info", "Info"),
("help.inAppTutorials", "Learn", "Lernen"),
("window.panel", "Lumetri Scopes", "Lumetri-Scopes"),
("window.panel", "Video Scopes", "Videoskope"),
("window.panel", "Mask Interpolation", "Maskeninterpolation"),
("window.panel", "Media Browser", "Medienbrowser"),
("window.panel", "Metadata", "Metadaten"),
@@ -7464,7 +7464,7 @@ const POLISH: &[(&str, &str, &str)] = &[
("window.panel", "Essential Graphics", "Essential Graphics"),
("window.panel", "Info", "Informacje"),
("help.inAppTutorials", "Learn", "Nauka"),
("window.panel", "Lumetri Scopes", "Zakresy Lumetri"),
("window.panel", "Video Scopes", "Wskaźniki wideo"),
("window.panel", "Mask Interpolation", "Interpolacja maski"),
("window.panel", "Media Browser", "Przeglądarka multimediów"),
("window.panel", "Metadata", "Metadane"),
@@ -8106,7 +8106,7 @@ const CZECH: &[(&str, &str, &str)] = &[
("window.panel", "Essential Graphics", "Základní grafika"),
("window.panel", "Info", "Informace"),
("help.inAppTutorials", "Learn", "Výuka"),
("window.panel", "Lumetri Scopes", "Měřicí přístroje Lumetri"),
("window.panel", "Video Scopes", "Videoskopy"),
("window.panel", "Mask Interpolation", "Interpolace masek"),
("window.panel", "Media Browser", "Prohlížeč médií"),
("window.panel", "Metadata", "Metadata"),
@@ -8748,7 +8748,7 @@ const GEORGIAN: &[(&str, &str, &str)] = &[
("window.panel", "Essential Graphics", "ძირითადი გრაფიკა"),
("window.panel", "Info", "ინფორმაცია"),
("help.inAppTutorials", "Learn", "სწავლა"),
("window.panel", "Lumetri Scopes", "Lumetri Scopes"),
("window.panel", "Video Scopes", "ვიდეოსკოპები"),
("window.panel", "Mask Interpolation", "ნიღბის ინტერპოლაცია"),
("window.panel", "Media Browser", "მედიის ბრაუზერი"),
("window.panel", "Metadata", "მეტამონაცემები"),
@@ -8799,6 +8799,21 @@ mod tests {
use serde_json::json;
use std::collections::BTreeSet;
#[test]
fn renamed_menu_translations_do_not_reintroduce_vendor_names() {
for code in CATALOG_CODES {
set_current(code);
for (id, source) in [("window.panel", "Video Scopes")] {
let rendered = command_label(id, source);
let label = rendered.to_lowercase();
for mark in ["lumetri", "mercury"] {
assert!(!label.contains(mark), "{code}: {id} -> {rendered}");
}
}
}
set_current("en");
}
/// The four options at the foot of the header's workspace menu say what After Effects says,
/// read from its workspace button on 2026-10-31: reset, save over this one, save a copy, then
/// edit the list.
File diff suppressed because it is too large Load Diff
+64 -28
View File
@@ -708,8 +708,8 @@ const JAPANESE: &[(&str, &str)] = &[
("Import Photoshop File", "Photoshopファイルを読み込み"),
("Interpret Unlabeled Alpha As", "ラベルのないアルファの解釈"),
("Join the ArtCraft Discord", "ArtCraftのDiscordに参加"),
("Mercury GPU Acceleration ({})", "Mercury GPUアクセラレーション({})"),
("Mercury Software Only", "Mercuryソフトウェアのみ"),
("GPU Acceleration ({})", "GPUアクセラレーション({})"),
("Software Rendering", "ソフトウェアレンダリング"),
("OCIO (built-in ACES config)", "OCIO(組み込みACES設定)"),
("Off (Final Quality)", "オフ(最終品質)"),
("OpenGL (compatibility)", "OpenGL(互換)"),
@@ -1119,7 +1119,7 @@ const JAPANESE: &[(&str, &str)] = &[
("Script Console", "スクリプトコンソール"),
("Essential Graphics", "エッセンシャルグラフィックス"),
("ScriptUI Panel", "ScriptUIパネル"),
("Lumetri Scopes", "Lumetriスコープ"),
("Video Scopes", "ビデオスコープ"),
("Media Browser", "メディアブラウザー"),
("Content-Aware Fill", "コンテンツに応じた塗りつぶし"),
("Create Nulls From Paths", "パスからヌルを作成"),
@@ -1319,6 +1319,7 @@ const JAPANESE: &[(&str, &str)] = &[
("per second", "毎秒"),
("Waveform", "波形"),
("steps", "ステップ"),
("GPU Acceleration (no GPU: software)", "GPUアクセラレーション(GPUなし:ソフトウェア)"),
];
const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
@@ -1838,8 +1839,8 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
("Import Photoshop File", "导入 Photoshop 文件"),
("Interpret Unlabeled Alpha As", "将未标记的 Alpha 解释为"),
("Join the ArtCraft Discord", "加入 ArtCraft Discord"),
("Mercury GPU Acceleration ({})", "Mercury GPU 加速({})"),
("Mercury Software Only", "仅 Mercury 软件"),
("GPU Acceleration ({})", "GPU 加速({})"),
("Software Rendering", "软件渲染"),
("OCIO (built-in ACES config)", "OCIO(内置 ACES 配置)"),
("Off (Final Quality)", "关闭(最终质量)"),
("OpenGL (compatibility)", "OpenGL(兼容)"),
@@ -2282,7 +2283,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
("Script Console", "脚本控制台"),
("Essential Graphics", "基本图形"),
("ScriptUI Panel", "ScriptUI 面板"),
("Lumetri Scopes", "Lumetri 示波器"),
("Video Scopes", "视频示波器"),
("Media Browser", "媒体浏览器"),
("Content-Aware Fill", "内容识别填充"),
("Create Nulls From Paths", "从路径创建空对象"),
@@ -2435,6 +2436,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
("Blend Mode", "混合模式"),
("Show mesh", "显示网格"),
("Record Options...", "记录选项..."),
("GPU Acceleration (no GPU: software)", "GPU 加速(无 GPU:软件)"),
];
/// Swedish (`sv`), written from the English source.
@@ -2952,8 +2954,8 @@ const SWEDISH: &[(&str, &str)] = &[
("Import Photoshop File", "Importera Photoshop-fil"),
("Interpret Unlabeled Alpha As", "Tolka omärkt alfa som"),
("Join the ArtCraft Discord", "Gå med i ArtCraft på Discord"),
("Mercury GPU Acceleration ({})", "Mercury GPU-acceleration ({})"),
("Mercury Software Only", "Endast Mercury-programvara"),
("GPU Acceleration ({})", "GPU-acceleration ({})"),
("Software Rendering", "Programvarurendering"),
("OCIO (built-in ACES config)", "OCIO (inbyggd ACES-konfiguration)"),
("Off (Final Quality)", "Av (slutlig kvalitet)"),
("OpenGL (compatibility)", "OpenGL (kompatibilitet)"),
@@ -3194,7 +3196,7 @@ const SWEDISH: &[(&str, &str)] = &[
("Drop {} files to import", "Släpp {} filer för att importera"),
("Effects", "Effekter"),
("Label of {}: {}", "Etikett för {}: {}"),
("Mercury GPU Acceleration (no GPU: software)", "Mercury GPU-acceleration (ingen GPU: programvara)"),
("GPU Acceleration (no GPU: software)", "GPU-acceleration (ingen GPU: programvara)"),
("Only one project can be opened per drop; skipped {} more", "Bara ett projekt kan öppnas per släpp; hoppade över {} till"),
("Open in Essential Graphics", "Öppna i Grundläggande grafik"),
("Original color: click to go back to it", "Originalfärg: klicka för att återgå till den"),
@@ -3443,7 +3445,7 @@ const SWEDISH: &[(&str, &str)] = &[
("Script Console", "Skriptkonsol"),
("Essential Graphics", "Grundläggande grafik"),
("ScriptUI Panel", "ScriptUI-panel"),
("Lumetri Scopes", "Lumetri-scope"),
("Video Scopes", "Videoskop"),
("Media Browser", "Mediebläddrare"),
("Content-Aware Fill", "Innehållsmedveten fyllning"),
("Create Nulls From Paths", "Skapa nullobjekt från banor"),
@@ -4612,7 +4614,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
("Script Console", "指令碼主控台"),
("Essential Graphics", "基本圖形"),
("ScriptUI Panel", "ScriptUI 面板"),
("Lumetri Scopes", "Lumetri 示波器"),
("Video Scopes", "視訊示波器"),
("Media Browser", "媒體瀏覽器"),
("Content-Aware Fill", "內容感知填色"),
("Create Nulls From Paths", "從路徑建立空物件"),
@@ -4765,6 +4767,9 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
("Blend Mode", "混合模式"),
("Show mesh", "顯示網格"),
("Record Options...", "記錄選項..."),
("Software Rendering", "軟體算繪"),
("GPU Acceleration ({})", "GPU 加速({})"),
("GPU Acceleration (no GPU: software)", "GPU 加速(無 GPU:軟體)"),
];
/// Ukrainian (uk): original translations of the same interface rows as the Chinese catalogs.
@@ -5305,8 +5310,8 @@ const UKRAINIAN: &[(&str, &str)] = &[
("Import Photoshop File", "Імпорт файлу Photoshop"),
("Interpret Unlabeled Alpha As", "Інтерпретувати непозначену альфу як"),
("Join the ArtCraft Discord", "Приєднатися до Discord ArtCraft"),
("Mercury GPU Acceleration ({})", "Прискорення Mercury GPU ({})"),
("Mercury Software Only", "Лише програмний Mercury"),
("GPU Acceleration ({})", "Прискорення GPU ({})"),
("Software Rendering", "Програмне відтворення"),
("OCIO (built-in ACES config)", "OCIO (вбудована конфігурація ACES)"),
("Off (Final Quality)", "Вимкнено (остаточна якість)"),
("OpenGL (compatibility)", "OpenGL (сумісність)"),
@@ -5352,6 +5357,8 @@ const UKRAINIAN: &[(&str, &str)] = &[
("Project", "Проєкт"),
("Total Offset", "Загальний зсув"),
("Per Layer", "На шар"),
("Video Scopes", "Відеоскопи"),
("GPU Acceleration (no GPU: software)", "Прискорення GPU (без GPU: програмне)"),
];
// Brazilian Portuguese.
@@ -5984,7 +5991,7 @@ const PORTUGUESE: &[(&str, &str)] = &[
("Guess", "Adivinhar"),
("Import Image Sequence", "Importar sequência de imagens"),
("Import PDF / Illustrator File", "Importar arquivo PDF / Illustrator"),
("Mercury Software Only", "Apenas software Mercury"),
("Software Rendering", "Renderização por software"),
("OCIO (built-in ACES config)", "OCIO (config do ACES integrado)"),
("Still Duration", "Duração da imagem fixa"),
("Straight", "Direto"),
@@ -6091,7 +6098,7 @@ const PORTUGUESE: &[(&str, &str)] = &[
("Light", "Claro"),
("Lines", "Linhas"),
("Memory & CPU", "Memória e CPU"),
("Mercury GPU Acceleration ({})", "Aceleração de GPU Mercury ({})"),
("GPU Acceleration ({})", "Aceleração de GPU ({})"),
("Off", "Desativado"),
("Options", "Opções"),
("Overlay", "Sobrepor"),
@@ -6116,6 +6123,8 @@ const PORTUGUESE: &[(&str, &str)] = &[
("Video", "Vídeo"),
("channel", "canal"),
("files", "arquivos"),
("Video Scopes", "Escopos de vídeo"),
("GPU Acceleration (no GPU: software)", "Aceleração de GPU (sem GPU: software)"),
];
/// Polish (pl): original translations covering every row in the Chinese panel catalogs.
@@ -6634,8 +6643,8 @@ const POLISH: &[(&str, &str)] = &[
("Import Photoshop File", "Importuj plik Photoshopa"),
("Interpret Unlabeled Alpha As", "Interpretuj nieoznaczoną alfę jako"),
("Join the ArtCraft Discord", "Dołącz do Discorda ArtCraft"),
("Mercury GPU Acceleration ({})", "Akceleracja Mercury GPU ({})"),
("Mercury Software Only", "Tylko programowy Mercury"),
("GPU Acceleration ({})", "Akceleracja GPU ({})"),
("Software Rendering", "Renderowanie programowe"),
("OCIO (built-in ACES config)", "OCIO (wbudowana konfiguracja ACES)"),
("Off (Final Quality)", "Wył. (jakość końcowa)"),
("OpenGL (compatibility)", "OpenGL (zgodność)"),
@@ -6873,6 +6882,8 @@ const POLISH: &[(&str, &str)] = &[
("Resolution:", "Rozdzielczość:"),
("Guide Layer", "Warstwa pomocnicza"),
("Layer Styles", "Style warstwy"),
("Video Scopes", "Wskaźniki wideo"),
("GPU Acceleration (no GPU: software)", "Akceleracja GPU (bez GPU: programowa)"),
];
/// Czech (cs): original translations covering every row in the Chinese panel catalogs.
@@ -7385,8 +7396,8 @@ const CZECH: &[(&str, &str)] = &[
("Import Photoshop File", "Importovat soubor Photoshop"),
("Interpret Unlabeled Alpha As", "Interpretovat neoznačenou alfu jako"),
("Join the ArtCraft Discord", "Připojit se na Discord ArtCraft"),
("Mercury GPU Acceleration ({})", "GPU akcelerace Mercury ({})"),
("Mercury Software Only", "Pouze softwarové zpracování Mercury"),
("GPU Acceleration ({})", "Akcelerace GPU ({})"),
("Software Rendering", "Softwarové vykreslování"),
("OCIO (built-in ACES config)", "OCIO (vestavěná konfigurace ACES)"),
("Off (Final Quality)", "Vypnuto (finální kvalita)"),
("OpenGL (compatibility)", "OpenGL (kompatibilita)"),
@@ -7437,6 +7448,8 @@ const CZECH: &[(&str, &str)] = &[
("Variable fonts only", "Jen proměnná písma"),
("Total Offset", "Celkový posun"),
("Per Layer", "Na vrstvu"),
("Video Scopes", "Videoskopy"),
("GPU Acceleration (no GPU: software)", "Akcelerace GPU (bez GPU: software)"),
];
/// Spanish: neutral, tú, sentence case; the same source strings as the Chinese catalogs, in the same
@@ -7965,8 +7978,8 @@ const SPANISH: &[(&str, &str)] = &[
("Import Photoshop File", "Importar archivo de Photoshop"),
("Interpret Unlabeled Alpha As", "Interpretar alfa sin etiquetar como"),
("Join the ArtCraft Discord", "Únete al Discord de ArtCraft"),
("Mercury GPU Acceleration ({})", "Aceleración por GPU Mercury ({})"),
("Mercury Software Only", "Solo software Mercury"),
("GPU Acceleration ({})", "Aceleración por GPU ({})"),
("Software Rendering", "Renderizado por software"),
("OCIO (built-in ACES config)", "OCIO (configuración ACES integrada)"),
("Off (Final Quality)", "Desactivado (calidad final)"),
("OpenGL (compatibility)", "OpenGL (compatibilidad)"),
@@ -8007,6 +8020,8 @@ const SPANISH: &[(&str, &str)] = &[
("After Effects project", "Proyecto de After Effects"),
("Lottie animation", "Animación de Lottie"),
("Project", "Proyecto"),
("Video Scopes", "Monitores de vídeo"),
("GPU Acceleration (no GPU: software)", "Aceleración por GPU (sin GPU: software)"),
];
/// Georgian (ka): every row of the Chinese catalogs, in the same order (the tests keep them in step).
@@ -8533,8 +8548,8 @@ const GEORGIAN: &[(&str, &str)] = &[
("Import Photoshop File", "Photoshop ფაილის იმპორტი"),
("Interpret Unlabeled Alpha As", "უცნობი ალფას ინტერპრეტაცია"),
("Join the ArtCraft Discord", "შემოგვიერთდით Discord-ზე"),
("Mercury GPU Acceleration ({})", "GPU აჩქარება ({})"),
("Mercury Software Only", "მხოლოდ Mercury Software"),
("GPU Acceleration ({})", "GPU აჩქარება ({})"),
("Software Rendering", "პროგრამული რენდერი"),
("OCIO (built-in ACES config)", "OCIO (ჩაშენებული ACES კონფიგურაცია)"),
("Off (Final Quality)", "გამორთული (საბოლოო ხარისხი)"),
("OpenGL (compatibility)", "OpenGL (თავსებადობა)"),
@@ -8766,6 +8781,7 @@ const GEORGIAN: &[(&str, &str)] = &[
("Total Offset", "ჯამური წანაცვლება"),
("Per Layer", "შრიდან შრემდე"),
("Track", "ტრეკინგი"),
("Video Scopes", "ვიდეოსკოპები"),
];
/// Georgian rows for interface text no other catalog has yet: strings the panels already send
@@ -8956,7 +8972,7 @@ const GEORGIAN_PANELS: &[(&str, &str)] = &[
("Solid Settings", "სოლიდის პარამეტრები"),
("Save as Template", "შაბლონად შენახვა"),
("My Custom RGB", "ჩემი მორგებული RGB"),
("Mercury GPU Acceleration (no GPU: software)", "Mercury GPU აჩქარება (GPU არ არის: პროგრამული)"),
("GPU Acceleration (no GPU: software)", "GPU აჩქარება (GPU არ არის: პროგრამული)"),
("Sort by {}, {}", "დალაგება: {}, {}"),
("Sort by {}", "დალაგება: {}"),
("Label of {}: {}", "{}-ის იარლიყი: {}"),
@@ -9515,8 +9531,8 @@ const FRENCH: &[(&str, &str)] = &[
("Import Photoshop File", "Importer un fichier Photoshop"),
("Interpret Unlabeled Alpha As", "Interpréter l’alpha non étiqueté comme"),
("Join the ArtCraft Discord", "Rejoindre le Discord ArtCraft"),
("Mercury GPU Acceleration ({})", "Accélération GPU Mercury ({})"),
("Mercury Software Only", "Mercury, logiciel uniquement"),
("GPU Acceleration ({})", "Accélération GPU ({})"),
("Software Rendering", "Rendu logiciel"),
("OCIO (built-in ACES config)", "OCIO (configuration ACES intégrée)"),
("Off (Final Quality)", "Désactivé (qualité finale)"),
("OpenGL (compatibility)", "OpenGL (compatibilité)"),
@@ -9931,7 +9947,7 @@ const FRENCH: &[(&str, &str)] = &[
("Solid Settings", "Paramètres du solide"),
("Save as Template", "Enregistrer en tant que modèle"),
("My Custom RGB", "Mon RVB personnalisé"),
("Mercury GPU Acceleration (no GPU: software)", "Accélération GPU Mercury (aucun GPU : logiciel)"),
("GPU Acceleration (no GPU: software)", "Accélération GPU (aucun GPU : logiciel)"),
("Sort by {}, {}", "Trier par {}, {}"),
("Sort by {}", "Trier par {}"),
("Label of {}: {}", "Étiquette de {} : {}"),
@@ -10052,7 +10068,7 @@ const FRENCH: &[(&str, &str)] = &[
("Script Console", "Console de scripts"),
("Essential Graphics", "Graphiques essentiels"),
("ScriptUI Panel", "Panneau ScriptUI"),
("Lumetri Scopes", "Scopes Lumetri"),
("Video Scopes", "Oscilloscopes vidéo"),
("Media Browser", "Explorateur de médias"),
("Content-Aware Fill", "Remplissage adapté au contenu"),
("Create Nulls From Paths", "Créer des objets nuls à partir de tracés"),
@@ -10271,6 +10287,26 @@ const FRENCH: &[(&str, &str)] = &[
mod tests {
use super::*;
/// Check rendered feature labels only. Format names, compatibility tokens and user text
/// are not subject to this guard, and a missing translation keeps its English fallback.
#[test]
fn renamed_panel_labels_do_not_reintroduce_vendor_names() {
for language in ["en", "ja", "zh-hans", "zh-hant", "fr", "sv", "ka", "cs", "de", "es", "pl", "pt-br", "ru", "uk"] {
set_current(language);
for source in ["GPU Acceleration ({})", "GPU Acceleration (no GPU: software)", "Software Rendering", "Video Scopes"] {
let rendered = tr(source);
assert!(!rendered.is_empty(), "{language}: {source}");
let label = rendered.to_lowercase();
for mark in ["lumetri", "mercury", "mocha", "keylight"] {
assert!(!label.contains(mark), "{language}: {source} -> {rendered}");
}
}
// A real interchange label remains factual, not rewritten or rejected.
assert!(!tr("Export Adobe Premiere Pro Project").is_empty());
}
set_current("en");
}
/// The interface text that has moved into this catalog. Add a panel here when you convert it;
/// `every_row_is_used_by_a_converted_panel` then looks for its rows' English sources in it.
pub(super) const CONVERTED: &[(&str, &str)] = &[
+16 -4
View File
@@ -338,11 +338,11 @@ fn interpret_footage_form(s: &effectcraft_engine::Session, proxy: bool, base: &m
/// The Project Settings dialog's title and fields.
fn project_settings_form(s: &effectcraft_engine::Session) -> (String, Vec<Field>) {
let st = &s.project.settings;
// The catalogue row is `Mercury GPU Acceleration ({})`, so the value goes through
// The catalogue row is `GPU Acceleration ({})`, so the value goes through
// tr_args: wrapping the assembled string in tr() would look up the backend's name.
let gpu_label = match &s.accel {
Some(a) => tr_args("Mercury GPU Acceleration ({})", &[&a.name()]),
None => tr("Mercury GPU Acceleration (no GPU: software)").to_string(),
Some(a) => tr_args("GPU Acceleration ({})", &[&a.name()]),
None => tr("GPU Acceleration (no GPU: software)").to_string(),
};
let depth = match st.bit_depth.label() {
l if l.starts_with("16") => 1,
@@ -436,7 +436,7 @@ fn project_settings_form(s: &effectcraft_engine::Session) -> (String, Vec<Field>
Field::choice(
"renderer",
"Video rendering and effects",
&[(gpu_label.as_str(), json!("gpu")), ("Mercury Software Only", json!("software"))],
&[(gpu_label.as_str(), json!("gpu")), ("Software Rendering", json!("software"))],
usize::from(!st.gpu_acceleration),
),
],
@@ -1050,6 +1050,18 @@ pub fn show_view_options(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Toke
mod tests {
use super::*;
#[test]
fn renderer_form_keeps_descriptive_labels_and_stable_option_values() {
crate::i18n::set_current("en");
let (_, fields) = project_settings_form(&effectcraft_engine::Session::default());
let renderer = fields.iter().find(|field| field.key == "renderer").unwrap();
let FieldKind::Choice { options, .. } = &renderer.kind else { panic!("renderer must be a choice") };
assert_eq!(
options.as_slice(),
&[("GPU Acceleration (no GPU: software)".to_string(), json!("gpu")), ("Software Rendering".to_string(), json!("software"))]
);
}
#[test]
fn form_params_build_arrays_and_choices() {
let f = Form {
+1 -1
View File
@@ -756,7 +756,7 @@ pub struct UiState {
/// Flowchart panel options (layers, effects, solids, direction, root comp).
#[serde(default)]
pub flowchart: crate::panels::flowchart::FlowOptions,
/// Lumetri Scopes panel options.
/// Video Scopes panel options.
#[serde(default)]
pub scopes: crate::panels::scopes_panel::ScopesState,
}
+1
View File
@@ -91,6 +91,7 @@ pub(crate) fn show(app: &mut EffectcraftApp, ctx: &egui::Context) {
rt.updater.dismiss_status();
}
CheckResultResponse::CheckAgain => rt.updater.check_now(ctx),
CheckResultResponse::RestartToUpdate => rt.updater.restart_to_update(),
}
// A check that found a release leaves the status idle and the startup prompt takes over.
if matches!(rt.updater.status(), Status::Idle) && !rt.check_requested {
+1 -1
View File
@@ -38,7 +38,7 @@ fn harness(panel: Panel, angle: f64) -> (Harness<'static, EffectcraftApp>, u64,
s.execute("comp.new", json!({"name": "Angles", "width": 320, "height": 180, "duration": 4})).unwrap();
let layer = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#40b080"})).unwrap()["layer"].as_u64().unwrap();
let uid = if matches!(panel, Panel::EffectControls) {
let fx = s.execute("effect.apply", json!({"layer": layer, "effect": "CC Bend It"})).unwrap()["effects"][0].as_u64().unwrap();
let fx = s.execute("effect.apply", json!({"layer": layer, "effect": "Two-Point Bend"})).unwrap()["effects"][0].as_u64().unwrap();
s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.find_group(fx).unwrap().get("bend").unwrap().uid
} else {
s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.prop("transform/rotation").unwrap().uid
+2 -2
View File
@@ -1,4 +1,4 @@
//! The colour picker every colour input opens: its hex field sets an effect's colour (CC Toner),
//! The colour picker every colour input opens: its hex field sets an effect's colour (Multi-Tone Tint),
//! the dialogs' colour button stores sRGB (egui's own `color_edit_button_rgba` read the stored
//! sRGB values as linear, so mid-grey `#808080` was stored as `#373737`), the square, slider and
//! HSB / RGB fields set the colour, and Cancel / Escape / the original swatch restore it.
@@ -45,7 +45,7 @@ fn hex_code_sets_an_effect_colour() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Hex", "width": 320, "height": 180, "duration": 4})).unwrap();
let layer = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#40b080"})).unwrap()["layer"].as_u64().unwrap();
let fx = s.execute("effect.apply", json!({"layer": layer, "effect": "CC Toner"})).unwrap()["effects"][0].as_u64().unwrap();
let fx = s.execute("effect.apply", json!({"layer": layer, "effect": "Multi-Tone Tint"})).unwrap()["effects"][0].as_u64().unwrap();
let uid = s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.find_group(fx).unwrap().get("midtones").unwrap().uid;
s.state.selected_layers = vec![LayerId(layer)];
let mut app = EffectcraftApp::new(s);
@@ -260,14 +260,14 @@ fn the_eyedropper_stays_armed_until_the_viewer_has_a_frame() {
}
}
/// Key Light's Screen Colour eyedropper picks from the effect's input: the shown frame is
/// Screen Color Key's Screen Colour eyedropper picks from the effect's input: the shown frame is
/// already keyed (the default screen colour takes most of a green), so sampling it would miss.
#[test]
fn keyer_eyedropper_picks_the_screen_from_the_effect_input() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Key", "width": 320, "height": 180, "frameRate": 30, "duration": 1})).unwrap();
let screen = s.execute("layer.newSolid", json!({"name": "Screen", "color": "#30b050"})).unwrap()["layer"].as_u64().unwrap();
let fx = s.execute("effect.apply", json!({"layers": [screen], "effect": "Key Light"})).unwrap()["effects"][0].as_u64().unwrap();
let fx = s.execute("effect.apply", json!({"layers": [screen], "effect": "Screen Color Key"})).unwrap()["effects"][0].as_u64().unwrap();
let prop = layer(&s, screen).effects().unwrap().groups().find(|g| g.uid == fx).and_then(|g| g.get("screenColour")).map(|p| p.uid).unwrap();
s.state.selected_layers = vec![LayerId(screen)];
let mut app = EffectcraftApp::new(s);
@@ -295,7 +295,7 @@ fn keyer_eyedropper_picks_the_screen_from_the_effect_input() {
fn command_clicking_an_effect_twirl_closes_and_opens_its_groups() {
let (app, x) = app();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| app);
let fx = h.state_mut().session.execute("effect.apply", json!({"layers": [x.small], "effect": "Lumetri Color"})).unwrap()["effects"][0].as_u64().unwrap();
let fx = h.state_mut().session.execute("effect.apply", json!({"layers": [x.small], "effect": "Color Grading"})).unwrap()["effects"][0].as_u64().unwrap();
// Only this effect open, so its header is on screen.
let l = layer(&h.state().session, x.small);
let others: Vec<u64> = l.effects().unwrap().groups().map(|g| g.uid).filter(|u| *u != fx).collect();
@@ -13,7 +13,7 @@ fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect {
fn drag_to(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) {
h.input_mut().events.push(Event::PointerMoved(p));
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
egui::DragAndDrop::set_payload(&h.ctx, DragPayload::Effect("CC Bend It".into()));
egui::DragAndDrop::set_payload(&h.ctx, DragPayload::Effect("Two-Point Bend".into()));
h.run_steps(2);
}
+1 -1
View File
@@ -130,7 +130,7 @@ fn undo(h: &mut Harness<'_, EffectcraftApp>) {
#[test]
fn lumetri_rgb_and_hue_saturation_curve_editors() {
let (s0, l, fx) = with_effect("Lumetri Color");
let (s0, l, fx) = with_effect("Color Grading");
let mut h = harness(s0);
only_groups(&mut h, l, fx, &["curves", "rgbCurves", "hueSaturationCurves"]);
let pre = format!("effectControls.effect.{fx}.lumetri.rgbCurves");
+2 -2
View File
@@ -77,12 +77,12 @@ fn position_anchor_and_effect_edits_present_intermediate_pixels() {
(None, "transform/position", json!([22, 18, 0]), json!([18, 18, 0])),
(None, "transform/anchor", json!([9, 8, 0]), json!([6, 8, 0])),
(Some("Gaussian Blur"), "blurriness", json!(3), json!(6)),
(Some("CC Radial Fast Blur"), "center", json!([4, 4]), json!([18, 12])),
(Some("Fast Radial Blur"), "center", json!([4, 4]), json!([18, 12])),
] {
let mut session = Session::default();
session.execute("comp.new", json!({"width":64,"height":36,"duration":1})).unwrap();
let layer = session.execute("layer.newSolid", json!({"color":"#ffffff","width":24,"height":16})).unwrap()["layer"].as_u64().unwrap();
if effect == Some("CC Radial Fast Blur") {
if effect == Some("Fast Radial Blur") {
// A uniform solid is invariant under a radial blur; give the point edit visible detail.
session.execute("effect.apply", json!({"layers":[layer],"effect":"Gradient Ramp"})).unwrap();
}
@@ -54,7 +54,7 @@ fn nested_groups_keep_indentation_value_alignment_and_effect_gaps_at_fixed_compa
let mut s = Session::default();
s.execute("comp.new", json!({"width":64,"height":36,"duration":1})).unwrap();
s.execute("layer.newSolid", json!({"name":"Plate","color":"#406080"})).unwrap();
let fx = s.execute("effect.apply", json!({"effect":"Lumetri Color"})).unwrap()["effects"][0].as_u64().unwrap();
let fx = s.execute("effect.apply", json!({"effect":"Color Grading"})).unwrap()["effects"][0].as_u64().unwrap();
let next = s.execute("effect.apply", json!({"effect":"Checkbox Control"})).unwrap()["effects"][0].as_u64().unwrap();
let g = s.active_comp().unwrap().layers[0].props.find_group(fx).unwrap();
let basic = g.sub("basicCorrection").unwrap();
+1 -1
View File
@@ -2370,7 +2370,7 @@ fn command_clicking_a_group_twirl_opens_its_whole_tree() {
// twirls open with it, and closed again with it.
let mut h = harness();
let id = layer_id(&h, "Box");
h.state_mut().session.execute("effect.apply", json!({"effect": "Lumetri Color", "layers": [id]})).unwrap();
h.state_mut().session.execute("effect.apply", json!({"effect": "Color Grading", "layers": [id]})).unwrap();
fn walk(g: &effectcraft_engine::project::PropGroup, out: &mut Vec<u64>) {
for sg in g.groups() {
out.push(sg.uid);
+9 -1
View File
@@ -135,7 +135,15 @@ Units, the "absent = default" rule and the keyframe templates are the reader's
text does not have; the runs are cut to the text. A range style splits `Hello` into runs of 1,
2 and 2 characters (248, 340).
- **Null objects.** The hidden placeholder solid of a null object is not an item (null layers
have no source item here).
have no source item here). A null's Anchor Point that is not stored is its top-left corner
`[0, 0]`, not the centre, as After Effects 2026 reads it back (#3099).
- **Placed Illustrator and PDF files** (`.ai`, `.pdf`; `Footage::placed_page`): the page is
framed as After Effects places it (the ArtBox clipped to the crop box, `/Rotate` ignored) and
drawn to fill the stored footage size, which masks and anchors are measured against (#3099).
Create Shapes from Vector Layer on such a layer adds a hidden `<layer> Frame` shape layer, the
Alpha track matte of every layer it makes, so they show only the frame, as the footage does.
EPS files keep their bounding box at their own size: how After Effects sizes them is not
measured.
- **Track mattes.** The mode and the matte layer's id are in the target layer's header
(aep-format.md 3.1). The matte layer can be any layer of the composition (407: below its
target); it keeps its stored video switch (After Effects switches it off when the matte is set,
+14 -14
View File
@@ -43,7 +43,7 @@ cpal or muda. Everything in L0 to L4, the egui UI and the web app also build for
| L3 | `interchange` | Premiere Pro interop through timeline interchange (FCP7 XML, FCPXML, OTIO, EDL, AAF, OMF via FilmCraft's `filmcraft-interchange`): sequences ↔ compositions (§6a) |
| L3 | `aep` | After Effects projects in one crate (like `lottie`): `container` reads and writes the `.aep` container (RIFX `Egg!`) as a lossless chunk tree with a byte-identical round trip, bounded for hostile input, reads the `.aepx` XML form into the same tree, and has a typed view of items, layers, properties, effects, masks, shapes, text, markers, footage, cameras, lights and layer styles, from our own observations of generated files ([aep-format.md](aep-format.md)); `import` maps the typed view into a project (items, settings, compositions, every layer kind, Transform, keyframes, expressions, masks, text, shapes, cameras, lights, mattes, markers, time remapping, layer styles, the effects of a shared table, footage) with a report of everything not imported ([aep-import.md](aep-import.md)); `export` builds every chunk in code (prototypes in the layout After Effects writes, cloned and edited only at verified fields) for the same scope, with a report of everything not exported, verified by opening the output in After Effects ([aep-export.md](aep-export.md)) |
| L3 | `lottie` | Lottie JSON / dotLottie import and export (layers, precomps, eased and spatial keyframes, shapes, masks, mattes) with a warnings list for what Lottie cannot express |
| L3 | `plugin` | Effect plug-ins: loads sandboxed WebAssembly effect modules (plug-in API v1; the wasmi interpreter behind the `wasm` feature, on for native hosts) into the effect registry ([plugins.md](plugins.md)) |
| L3 | `plugin` | Effect plug-ins: loads sandboxed WebAssembly effect modules (WebAssembly ABI v1 and v2, the latter with neighbouring frames; the wasmi interpreter behind the `wasm` feature, on for native hosts) into the effect registry ([plugins.md](plugins.md)) |
| L3 | `helper-host` | External effect helpers: finds helper programs, runs each as a child process and registers its effects (plug-in API 2) over a versioned pipe + shared-file protocol; a crashing helper is an error on the effect, never a crash ([external-helpers.md](external-helpers.md), [ADR 0001](adr/0001-external-effect-helpers.md)). Native only |
| L4 | `engine` | `Session`: project, branching undo history, editor state, the command registry and menus, the ScriptUI window model (`scriptui`) |
| L4 | `script` | Scripting: JavaScript (boa) with an After Effects-style object model (`app.project`, comps, layers, properties, render queue) and ScriptUI (dialogs, palettes, dockable panels; script-host threads so dialogs block `show()`), whose edits run engine commands; AE match names |
@@ -121,7 +121,7 @@ are not rewritten, so an expression that computes an effect point on a shape or
other values may need `+ [thisComp.width / 2, thisComp.height / 2]`. Effects that use the layer
bounds (edge pinning, wipes, Offset, generators) now cover the whole comp-sized bounds instead of
their bottom-right quarter. Tracking data of effects that analyse footage (Warp Stabilizer, 3D
Camera Tracker, Mocha shape) is left as it is: footage has a source rectangle, whose effect space
Camera Tracker, Tracked Shape Matte) is left as it is: footage has a source rectangle, whose effect space
did not change.
## 4. Rendering
@@ -331,7 +331,7 @@ before conversion (a quarter of the conversions; the result differs from convert
in blocks where colours clip) and resampled from there, as other frames are from full size.
**GPU compositor** (`crates/gpu`; Project Settings ▸ Video Rendering and Effects ▸ Mercury GPU
Acceleration, the default, or Mercury Software Only; `render.backend`). The CPU renderer is the
Acceleration, the default, or Software Rendering; `render.backend`). The CPU renderer is the
reference and keeps rendering layer content (sources, masks, CPU effects, layer styles) into the
layer cache. The render crate defines an `Accelerator` trait; `effectcraft_gpu::Gpu` implements it
on wgpu compute shaders. `RenderOpts::backend` picks `Cpu`, `Gpu` or `Auto` (an accelerator
@@ -341,7 +341,7 @@ per comp, output scale and path (readback or viewer display) it averages CPU and
(warm-up on both sides, the fastest warm-up frame starting the average, single stalls capped),
picks the GPU unless the CPU is clearly faster, and re-measures the other side every 48 frames.
Render Queue jobs don't use Auto: every frame of a job renders on the project's renderer (the
GPU compositor with Mercury GPU Acceleration), so runs of a render don't differ by timing.
GPU compositor with GPU Acceleration), so runs of a render don't differ by timing.
Light comps (a few small layers, which the CPU composites only within their bounds) then stay on
the CPU instead of paying for full-frame passes and a readback. A GPU frame walks the comp like
`draw_comp`: cached layer buffers are uploaded once per buffer, then transformed with the CPU's
@@ -370,16 +370,16 @@ Preserve Transparency / blend kernels (a 3D track matte renders solo through the
Environment Light Background layers draw their sky in a kernel (`adv_sky`, from
`Renderer::sky_draw`) before the rest of the run, in Classic and Advanced 3D comps. Wireframe
outlines draw on the GPU from the CPU's pixel list (`Renderer::wireframe_pixels`). GPU effects
(`effects::GPU_EFFECTS`, 280 of them: blurs, colour correction, keying incl. Key Light, mattes,
channel, stylize, distortion and warps (Warp, Bezier Warp, Smear, Reshape, CC Bend It, CC Page
Turn, CC Bender, CC Blobbylize), Cartoon, bevels, shapes, the transitions (CC light family, CC
transitions, Block Dissolve), perspective (Radial Shadow, CC Cylinder / Sphere / Spotlight /
(`effects::GPU_EFFECTS`, 280 of them: blurs, colour correction, keying incl. Screen Color Key, mattes,
channel, stylize, distortion and warps (Warp, Bezier Warp, Smear, Reshape, Two-Point Bend, CC Page
Turn, Flexible Bend, Organic Displacement), Cartoon, bevels, shapes, the transitions (CC light family, CC
transitions, Block Dissolve), perspective (Radial Shadow, Cylinder Wrap / Sphere / Spotlight /
Environment, 3D Glasses), generators, noise, grain, text (Numbers, Timecode), time, 3D Channel (aux channels as an
extra texture), Immersive Video, colour management (colour programs from
`effects::color_program`, LUTs in a storage buffer) and the simulations' render passes (the
simulation stays on the CPU and hands its plan — sprites, pieces, blobs, grids — to a tiled
rasteriser; the particle effects too, `gpu::fx_particles`: CC Particle World / Systems II,
Particle Playground with its Layer Map frames in one atlas, CC Ball Action, CC Pixel Polly, CC
rasteriser; the particle effects too, `gpu::fx_particles`: Particle Emitter 3D / Systems II,
Particle Playground with its Layer Map frames in one atlas, Ball Grid, Pixel Shatter, CC
Scatterize; plans too large for one item table draw in several passes over the previous one),
and the geometry generators (Lightning, Advanced Lightning, Radio Waves, Stroke, Scribble,
Vegas, Write-on, Audio Spectrum / Waveform, Basic / Path Text: bolts, strokes, waves, audio
@@ -390,7 +390,7 @@ as kernels, in one module per family (`gpu::fx_*` with `shaders/fx_*.wgsl`); con
effects run as one chain with one upload and one readback. Statistics that need the whole frame
(Auto Levels / Contrast / Color, Equalize, Shadow/Highlight, Color Stabilizer, Remove Grain's
noise level) are measured on the CPU from one readback and applied on the GPU; effects reading
other frames (Echo, Posterize Time, Time Difference, Time Displacement, CC Force Motion Blur, CC
other frames (Echo, Posterize Time, Time Difference, Time Displacement, Temporal Motion Blur, CC
Wide Time, Pixel Motion Blur, Timewarp) upload the frames the host renders (`time_frames`) and
combine them on the GPU; Time Displacement's per-pixel times and Timewarp's motion vectors
(`time_fx::timewarp_plan`) are computed on the CPU. Numbers and Timecode rasterise their glyph
@@ -433,12 +433,12 @@ sentinel is implemented here. Browser scope futures are not synchronously awaite
forwards shared-device errors, while owned worker devices install their own handlers. On the web
(WebGPU) the GPU composites viewer frames; steps that need a readback fall back to the CPU (the
Info panel's pixel readout reads GPU frames back asynchronously). **GPU particles**
(`effects::psim`, `gpu::particles`): CC Particle World, CC Particle Systems II and Particle
(`effects::psim`, `gpu::particles`): Particle Emitter 3D, Particle Emitter 2D and Particle
Playground cannons without interacting forces evolve every particle independently, so the GPU
backend (reached through `EffectHost::particles` when the GPU compositor is active) simulates one
particle per invocation from the CPU's birth schedule and keeps per-key state checkpoints on the
GPU, like `SimCache`; the CPU simulation stays the oracle (tests compare ids and positions).
CC Particle World, CC Particle Systems II and CC Mr. Mercury sample their Birth Rate at every
Particle Emitter 3D, Particle Emitter 2D and Liquid Particles sample their Birth Rate at every
step (`EffectHost::param_at`, #1373), so a keyed rate only decides the births of its own time;
the rate is left out of the simulation key, and a checkpoint is resumed only with the same rate history (the CPU state's `hist`, the GPU
checkpoint's births). The rate's keyframes are read at the step's exact layer time
@@ -629,7 +629,7 @@ Unix milliseconds. The layer's Start Time becomes `dataStart + videoStart − fi
(`videoStart` given, or the file's creation date on the desktop; reduced to a time of day when
the data has no dates).
**Lumetri Scopes** (`raster::scopes`) are density plots of the viewer frame (rendered over the
**Video Scopes** (`raster::scopes`) are density plots of the viewer frame (rendered over the
comp background) with BT.601 / BT.709 / BT.2020 luma and colour-difference coefficients.
## 5. Expressions
+72 -72
View File
@@ -48,21 +48,21 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| Effect | Id | Params | GPU | 32 | Status |
|---|---|---:|:-:|:-:|---|
| Bilateral Blur | `ec.blur.bilateral` | 3 | GPU | 32 | Implemented |
| CC Cross Blur | `ec.blur.cccross` | 3 | GPU | 32 | Implemented |
| CC Radial Blur | `ec.blur.ccradial` | 4 | GPU | 32 | Implemented |
| CC Radial Fast Blur | `ec.blur.ccradialfast` | 3 | GPU | 32 | Implemented |
| CC Vector Blur | `ec.blur.ccvector` | 4 | GPU | 32 | Implemented |
| Camera Lens Blur | `ec.blur.cameralens` | 16 | GPU | 32 | Implemented |
| Camera-Shake Deblur | `ec.blur.camerashakedeblur` | 8 | | 32 | Implemented |
| Channel Blur | `ec.blur.channel` | 6 | GPU | 32 | Implemented |
| Compound Blur | `ec.blur.compound` | 4 | GPU | 32 | Implemented |
| Cross Blur | `ec.blur.cccross` | 3 | GPU | 32 | Implemented |
| Directional Blur | `ec.blur.directional` | 2 | GPU | 32 | Implemented |
| Fast Box Blur | `ec.blur.fastbox` | 4 | GPU | 32 | Implemented |
| Fast Radial Blur | `ec.blur.ccradialfast` | 3 | GPU | 32 | Implemented |
| Gaussian Blur | `ec.blur.gaussian` | 3 | GPU | 32 | Implemented |
| Radial Blur | `ec.blur.radial` | 4 | GPU | 32 | Implemented |
| Sharpen | `ec.blur.sharpen` | 1 | GPU | 32 | Implemented |
| Smart Blur | `ec.blur.smart` | 3 | GPU | 32 | Implemented |
| Unsharp Mask | `ec.blur.unsharp` | 3 | GPU | 32 | Implemented |
| Variable Radial Blur | `ec.blur.ccradial` | 4 | GPU | 32 | Implemented |
| Vector Blur | `ec.blur.ccvector` | 4 | GPU | 32 | Implemented |
## Channel
@@ -70,7 +70,6 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
|---|---|---:|:-:|:-:|---|
| Arithmetic | `ec.channel.arithmetic` | 5 | GPU | 32 | Implemented |
| Blend | `ec.channel.blend` | 4 | GPU | 32 | Implemented |
| CC Composite | `ec.channel.cccomposite` | 3 | GPU | 32 | Implemented |
| Calculations | `ec.channel.calculations` | 9 | GPU | 32 | Implemented |
| Channel Combiner | `ec.channel.combiner` | 6 | GPU | 32 | Implemented |
| Compound Arithmetic | `ec.channel.compoundarithmetic` | 6 | GPU | 32 | Implemented |
@@ -81,6 +80,7 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| Set Matte | `ec.channel.setmatte` | 6 | GPU | 32 | Implemented |
| Shift Channels | `ec.channel.shiftchannels` | 4 | GPU | 32 | Implemented |
| Solid Composite | `ec.channel.solidcomposite` | 4 | GPU | 32 | Implemented |
| Source Composite | `ec.channel.cccomposite` | 3 | GPU | 32 | Implemented |
## Color Correction
@@ -92,18 +92,18 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| Black & White | `ec.color.blackwhite` | 8 | GPU | 32 | Implemented |
| Brightness & Contrast | `ec.color.brightnesscontrast` | 3 | GPU | 32 | Implemented |
| Broadcast Colors | `ec.color.broadcast` | 3 | GPU | 32 | Implemented |
| CC Color Neutralizer | `ec.color.cccolorneutralizer` | 11 | GPU | 32 | Implemented |
| CC Color Offset | `ec.color.cccoloroffset` | 4 | GPU | 32 | Implemented |
| CC Kernel | `ec.color.cckernel` | 12 | GPU | 32 | Implemented |
| CC Toner | `ec.color.cctoner` | 7 | GPU | 32 | Implemented |
| Change Color | `ec.color.changecolor` | 9 | GPU | 32 | Implemented |
| Change to Color | `ec.color.changetocolor` | 9 | GPU | 32 | Implemented |
| Channel Mixer | `ec.color.channelmixer` | 13 | GPU | 32 | Implemented |
| Channel Offset | `ec.color.cccoloroffset` | 4 | GPU | 32 | Implemented |
| Color Balance | `ec.color.colorbalance` | 10 | GPU | 32 | Implemented |
| Color Balance (HLS) | `ec.color.colorbalancehls` | 3 | GPU | 32 | Implemented |
| Color Grading | `ec.color.lumetri` | 66 | GPU | 32 | Implemented |
| Color Link | `ec.color.colorlink` | 6 | GPU | 32 | Implemented |
| Color Neutralizer | `ec.color.cccolorneutralizer` | 11 | GPU | 32 | Implemented |
| Color Stabilizer | `ec.color.colorstabilizer` | 6 | GPU | 32 | Implemented |
| Colorama | `ec.color.colorama` | 20 | GPU | 32 | Implemented |
| Convolution Kernel | `ec.color.cckernel` | 12 | GPU | 32 | Implemented |
| Curves | `ec.color.curves` | 0 | GPU | 32 | Implemented |
| Equalize | `ec.color.equalize` | 2 | GPU | 32 | Implemented |
| Exposure | `ec.color.exposure` | 14 | GPU | 32 | Implemented |
@@ -112,7 +112,7 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| Leave Color | `ec.color.leavecolor` | 5 | GPU | 32 | Implemented |
| Levels | `ec.color.levels` | 36 | GPU | 32 | Implemented |
| Levels (Individual Controls) | `ec.color.levelsic` | 27 | GPU | 32 | Implemented |
| Lumetri Color | `ec.color.lumetri` | 66 | GPU | 32 | Implemented |
| Multi-Tone Tint | `ec.color.cctoner` | 7 | GPU | 32 | Implemented |
| OCIO CDL Transform | `ec.color.ociocdl` | 12 | GPU | 32 | Implemented |
| OCIO Color Space Transform | `ec.color.ociocolorspace` | 7 | GPU | 32 | Implemented |
| OCIO Display Transform | `ec.color.ociodisplay` | 9 | GPU | 32 | Implemented |
@@ -133,40 +133,40 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
|---|---|---:|:-:|:-:|---|
| Bezier Warp | `ec.distort.bezierwarp` | 13 | GPU | 32 | Implemented |
| Bulge | `ec.distort.bulge` | 6 | GPU | 32 | Implemented |
| CC Bend It | `ec.distort.ccbendit` | 4 | GPU | 32 | Implemented |
| CC Bender | `ec.distort.ccbender` | 5 | GPU | 32 | Implemented |
| CC Blobbylize | `ec.distort.ccblobbylize` | 15 | GPU | 32 | Implemented |
| CC Flo Motion | `ec.distort.ccflomotion` | 7 | GPU | 32 | Implemented |
| CC Griddler | `ec.distort.ccgriddler` | 5 | GPU | 32 | Implemented |
| CC Lens | `ec.distort.cclens` | 3 | GPU | 32 | Implemented |
| CC Page Turn | `ec.distort.ccpageturn` | 7 | GPU | 32 | Implemented |
| CC Power Pin | `ec.distort.ccpowerpin` | 10 | GPU | 32 | Implemented |
| CC Ripple Pulse | `ec.distort.ccripplepulse` | 5 | GPU | 32 | Implemented |
| CC Slant | `ec.distort.ccslant` | 4 | GPU | 32 | Implemented |
| CC Smear | `ec.distort.ccsmear` | 4 | GPU | 32 | Implemented |
| CC Split | `ec.distort.ccsplit` | 3 | GPU | 32 | Implemented |
| CC Split 2 | `ec.distort.ccsplit2` | 4 | GPU | 32 | Implemented |
| CC Tiler | `ec.distort.cctiler` | 3 | GPU | 32 | Implemented |
| Corner Pin | `ec.distort.cornerpin` | 4 | GPU | 32 | Implemented |
| Detail-preserving Upscale | `ec.distort.upscale` | 4 | | 32 | Implemented |
| Directional Smear | `ec.distort.ccsmear` | 4 | GPU | 32 | Implemented |
| Displacement Map | `ec.distort.displacementmap` | 8 | GPU | 32 | Implemented |
| Dual Lens Warp | `ec.distort.ccflomotion` | 7 | GPU | 32 | Implemented |
| Dual Split Warp | `ec.distort.ccsplit2` | 4 | GPU | 32 | Implemented |
| Flexible Bend | `ec.distort.ccbender` | 5 | GPU | 32 | Implemented |
| Lens Distortion | `ec.distort.cclens` | 3 | GPU | 32 | Implemented |
| Liquify | `ec.distort.liquify` | 13 | GPU | 32 | Implemented |
| Magnify | `ec.distort.magnify` | 10 | GPU | 32 | Implemented |
| Mesh Warp | `ec.distort.meshwarp` | 3 | GPU | 32 | Implemented |
| Mirror | `ec.distort.mirror` | 2 | GPU | 32 | Implemented |
| Offset | `ec.distort.offset` | 2 | GPU | 32 | Implemented |
| Optics Compensation | `ec.distort.opticscompensation` | 6 | GPU | 32 | Implemented |
| Organic Displacement | `ec.distort.ccblobbylize` | 15 | GPU | 32 | Implemented |
| Page Curl | `ec.distort.ccpageturn` | 7 | GPU | 32 | Implemented |
| Perspective Pin | `ec.distort.ccpowerpin` | 10 | GPU | 32 | Implemented |
| Polar Coordinates | `ec.distort.polar` | 2 | GPU | 32 | Implemented |
| Pulsed Ripple | `ec.distort.ccripplepulse` | 5 | GPU | 32 | Implemented |
| Puppet | `ec.distort.puppet` | 1 | | 32 | Implemented |
| Reshape | `ec.distort.reshape` | 6 | GPU | 32 | Implemented |
| Ripple | `ec.distort.ripple` | 7 | GPU | 32 | Implemented |
| Rolling Shutter Repair | `ec.distort.rollingshutterrepair` | 5 | | 32 | Implemented |
| Scaled Tiles | `ec.distort.cctiler` | 3 | GPU | 32 | Implemented |
| Slant | `ec.distort.ccslant` | 4 | GPU | 32 | Implemented |
| Smear | `ec.distort.smear` | 8 | GPU | 32 | Implemented |
| Spherize | `ec.distort.spherize` | 2 | GPU | 32 | Implemented |
| Split Warp | `ec.distort.ccsplit` | 3 | GPU | 32 | Implemented |
| Tile Grid | `ec.distort.ccgriddler` | 5 | GPU | 32 | Implemented |
| Transform | `ec.distort.transform` | 12 | GPU | 32 | Implemented |
| Turbulent Displace | `ec.distort.turbulentdisplace` | 11 | GPU | 32 | Implemented |
| Twirl | `ec.distort.twirl` | 3 | GPU | 32 | Implemented |
| Twirl (Legacy) | `ec.distort.twirllegacy` | 3 | GPU | 32 | Implemented |
| Two-Point Bend | `ec.distort.ccbendit` | 4 | GPU | 32 | Implemented |
| Warp | `ec.distort.warp` | 5 | GPU | 32 | Implemented |
| Warp Stabilizer | `ec.distort.warpstabilizer` | 20 | | 32 | Implemented |
| Wave Warp | `ec.distort.wavewarp` | 7 | GPU | 32 | Implemented |
@@ -194,11 +194,6 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| Audio Spectrum | `ec.generate.audiospectrum` | 23 | GPU | 32 | Implemented |
| Audio Waveform | `ec.generate.audiowaveform` | 17 | GPU | 32 | Implemented |
| Beam | `ec.generate.beam` | 11 | GPU | 32 | Implemented |
| CC Glue Gun | `ec.generate.ccgluegun` | 7 | GPU | 32 | Implemented |
| CC Light Burst 2.5 | `ec.generate.cclightburst` | 6 | GPU | 32 | Implemented |
| CC Light Rays | `ec.generate.cclightrays` | 8 | GPU | 32 | Implemented |
| CC Light Sweep | `ec.generate.cclightsweep` | 9 | GPU | 32 | Implemented |
| CC Threads | `ec.generate.ccthreads` | 8 | GPU | 32 | Implemented |
| Cell Pattern | `ec.generate.cellpattern` | 14 | GPU | 32 | Implemented |
| Checkerboard | `ec.generate.checkerboard` | 10 | GPU | 32 | Implemented |
| Circle | `ec.generate.circle` | 11 | GPU | 32 | Implemented |
@@ -209,11 +204,16 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| Gradient Ramp | `ec.generate.gradientramp` | 7 | GPU | 32 | Implemented |
| Grid | `ec.generate.grid` | 12 | GPU | 32 | Implemented |
| Lens Flare | `ec.generate.lensflare` | 4 | GPU | 32 | Implemented |
| Light Rays | `ec.generate.cclightrays` | 8 | GPU | 32 | Implemented |
| Light Sweep | `ec.generate.cclightsweep` | 9 | GPU | 32 | Implemented |
| Paint Beads | `ec.generate.ccgluegun` | 7 | GPU | 32 | Implemented |
| Paint Bucket | `ec.generate.paintbucket` | 11 | GPU | 32 | Implemented |
| Radial Light Burst | `ec.generate.cclightburst` | 6 | GPU | 32 | Implemented |
| Radio Waves | `ec.generate.radiowaves` | 31 | GPU | 32 | Implemented |
| Scribble | `ec.generate.scribble` | 25 | GPU | 32 | Implemented |
| Stroke | `ec.generate.stroke` | 11 | GPU | 32 | Implemented |
| Vegas | `ec.generate.vegas` | 26 | GPU | 32 | Implemented |
| Woven Threads | `ec.generate.ccthreads` | 8 | GPU | 32 | Implemented |
| Write-on | `ec.generate.writeon` | 10 | GPU | 32 | Implemented |
## Immersive Video
@@ -238,15 +238,15 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| Effect | Id | Params | GPU | 32 | Status |
|---|---|---:|:-:|:-:|---|
| Advanced Spill Suppressor | `ec.key.advancedspill` | 8 | GPU | 32 | Implemented |
| CC Simple Wire Removal | `ec.key.ccsimplewireremoval` | 7 | GPU | 32 | Implemented |
| Color Difference Key | `ec.key.colordifference` | 16 | GPU | 32 | Implemented |
| Color Range | `ec.key.colorrange` | 8 | GPU | 32 | Implemented |
| Difference Matte | `ec.key.differencematte` | 6 | GPU | 32 | Implemented |
| Extract | `ec.key.extract` | 6 | GPU | 32 | Implemented |
| Inner/Outer Key | `ec.key.innerouter` | 88 | GPU | 32 | Implemented |
| Key Cleaner | `ec.key.keycleaner` | 4 | GPU | 32 | Implemented |
| Key Light | `ec.keying.keylight` | 56 | GPU | 32 | Implemented |
| Line Removal | `ec.key.ccsimplewireremoval` | 7 | GPU | 32 | Implemented |
| Linear Color Key | `ec.key.linearcolor` | 6 | GPU | 32 | Implemented |
| Screen Color Key | `ec.keying.keylight` | 56 | GPU | 32 | Implemented |
| Screen Key | `ec.key.screen` | 11 | GPU | 32 | Implemented |
| Unmult | `ec.key.unmult` | 6 | GPU | 32 | Implemented |
@@ -255,12 +255,12 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| Effect | Id | Params | GPU | 32 | Status |
|---|---|---:|:-:|:-:|---|
| Matte Choker | `ec.matte.mattechoker` | 7 | GPU | 32 | Implemented |
| Mocha shape | `ec.obsolete.mochashape` | 6 | | 32 | Implemented |
| Object Matte | `ec.matte.objectmatte` | 13 | | 32 | Implemented |
| Refine Hard Matte | `ec.matte.refinehard` | 16 | GPU | 32 | Implemented |
| Refine Soft Matte | `ec.matte.refinesoft` | 18 | GPU | 32 | Implemented |
| Roto Brush & Refine Edge | `ec.matte.rotobrush` | 25 | | 32 | Implemented |
| Simple Choker | `ec.matte.simplechoker` | 2 | GPU | 32 | Implemented |
| Tracked Shape Matte | `ec.obsolete.mochashape` | 6 | | 32 | Implemented |
## Noise & Grain
@@ -308,65 +308,65 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| 3D Glasses | `ec.perspective.3dglasses` | 8 | GPU | 32 | Implemented |
| Bevel Alpha | `ec.perspective.bevelalpha` | 4 | GPU | 32 | Implemented |
| Bevel Edges | `ec.perspective.beveledges` | 4 | GPU | 32 | Implemented |
| CC Cylinder | `ec.perspective.cccylinder` | 15 | GPU | 32 | Implemented |
| CC Environment | `ec.perspective.ccenvironment` | 4 | GPU | 32 | Implemented |
| CC Sphere | `ec.perspective.ccsphere` | 14 | GPU | 32 | Implemented |
| CC Spotlight | `ec.perspective.ccspotlight` | 8 | GPU | 32 | Implemented |
| Cylinder Wrap | `ec.perspective.cccylinder` | 15 | GPU | 32 | Implemented |
| Drop Shadow | `ec.perspective.dropshadow` | 6 | GPU | 32 | Implemented |
| Environment Map | `ec.perspective.ccenvironment` | 4 | GPU | 32 | Implemented |
| Radial Shadow | `ec.perspective.radialshadow` | 9 | GPU | 32 | Implemented |
| Sphere Wrap | `ec.perspective.ccsphere` | 14 | GPU | 32 | Implemented |
| Surface Spotlight | `ec.perspective.ccspotlight` | 8 | GPU | 32 | Implemented |
## Simulation
| Effect | Id | Params | GPU | 32 | Status |
|---|---|---:|:-:|:-:|---|
| CC Ball Action | `ec.sim.ccballaction` | 8 | GPU | 32 | Implemented |
| CC Bubbles | `ec.sim.ccbubbles` | 7 | GPU | 32 | Implemented |
| CC Drizzle | `ec.sim.ccdrizzle` | 14 | GPU | 32 | Implemented |
| CC Hair | `ec.sim.cchair` | 20 | GPU | 32 | Implemented |
| CC Mr. Mercury | `ec.sim.ccmrmercury` | 23 | GPU | 32 | Implemented |
| CC Particle Systems II | `ec.sim.ccparticlesystems2` | 21 | GPU | 32 | Implemented |
| CC Particle World | `ec.sim.ccparticleworld` | 36 | GPU | 32 | Implemented |
| CC Pixel Polly | `ec.sim.ccpixelpolly` | 10 | GPU | 32 | Implemented |
| CC Rainfall | `ec.sim.ccrainfall` | 14 | GPU | 32 | Implemented |
| CC Scatterize | `ec.sim.ccscatterize` | 4 | GPU | 32 | Implemented |
| CC Snowfall | `ec.sim.ccsnowfall` | 19 | GPU | 32 | Implemented |
| CC Star Burst | `ec.sim.ccstarburst` | 6 | GPU | 32 | Implemented |
| Ball Grid | `ec.sim.ccballaction` | 8 | GPU | 32 | Implemented |
| Bubbles | `ec.sim.ccbubbles` | 7 | GPU | 32 | Implemented |
| Card Dance | `ec.sim.carddance` | 53 | GPU | 32 | Implemented |
| Caustics | `ec.sim.caustics` | 28 | GPU | 32 | Implemented |
| Foam | `ec.sim.foam` | 36 | GPU | 32 | Implemented |
| Liquid Particles | `ec.sim.ccmrmercury` | 23 | GPU | 32 | Implemented |
| Particle Emitter 2D | `ec.sim.ccparticlesystems2` | 21 | GPU | 32 | Implemented |
| Particle Emitter 3D | `ec.sim.ccparticleworld` | 36 | GPU | 32 | Implemented |
| Particle Playground | `ec.sim.particleplayground` | 92 | GPU | 32 | Implemented |
| Pixel Scatter | `ec.sim.ccscatterize` | 4 | GPU | 32 | Implemented |
| Pixel Shatter | `ec.sim.ccpixelpolly` | 10 | GPU | 32 | Implemented |
| Rainfall | `ec.sim.ccrainfall` | 14 | GPU | 32 | Implemented |
| Shatter | `ec.sim.shatter` | 60 | GPU | 32 | Implemented |
| Snowfall | `ec.sim.ccsnowfall` | 19 | GPU | 32 | Implemented |
| Starfield | `ec.sim.ccstarburst` | 6 | GPU | 32 | Implemented |
| Surface Fibers | `ec.sim.cchair` | 20 | GPU | 32 | Implemented |
| Water Ripples | `ec.sim.ccdrizzle` | 14 | GPU | 32 | Implemented |
| Wave World | `ec.sim.waveworld` | 35 | GPU | 32 | Implemented |
## Stylize
| Effect | Id | Params | GPU | 32 | Status |
|---|---|---:|:-:|:-:|---|
| Block Reveal | `ec.stylize.ccblockload` | 5 | GPU | 32 | Implemented |
| Brush Strokes | `ec.stylize.brushstrokes` | 7 | GPU | 32 | Implemented |
| CC Block Load | `ec.stylize.ccblockload` | 5 | GPU | 32 | Implemented |
| CC Burn Film | `ec.stylize.ccburnfilm` | 3 | GPU | 32 | Implemented |
| CC Glass | `ec.stylize.ccglass` | 17 | GPU | 32 | Implemented |
| CC HexTile | `ec.stylize.cchextile` | 5 | GPU | 32 | Implemented |
| CC Kaleida | `ec.stylize.cckaleida` | 4 | GPU | 32 | Implemented |
| CC Mr. Smoothie | `ec.stylize.ccmrsmoothie` | 7 | GPU | 32 | Implemented |
| CC Plastic | `ec.stylize.ccplastic` | 18 | GPU | 32 | Implemented |
| CC RepeTile | `ec.stylize.ccrepetile` | 5 | GPU | 32 | Implemented |
| CC Threshold | `ec.stylize.ccthreshold` | 4 | GPU | 32 | Implemented |
| CC Threshold RGB | `ec.stylize.ccthresholdrgb` | 7 | GPU | 32 | Implemented |
| CC Vignette | `ec.stylize.ccvignette` | 4 | GPU | 32 | Implemented |
| Cartoon | `ec.stylize.cartoon` | 12 | GPU | 32 | Implemented |
| Channel Threshold | `ec.stylize.ccthreshold` | 4 | GPU | 32 | Implemented |
| Color Emboss | `ec.stylize.coloremboss` | 4 | GPU | 32 | Implemented |
| Edge Tile Repeat | `ec.stylize.ccrepetile` | 5 | GPU | 32 | Implemented |
| Emboss | `ec.stylize.emboss` | 4 | GPU | 32 | Implemented |
| Film Burn | `ec.stylize.ccburnfilm` | 3 | GPU | 32 | Implemented |
| Find Edges | `ec.stylize.findedges` | 2 | GPU | 32 | Implemented |
| Glass Relief | `ec.stylize.ccglass` | 17 | GPU | 32 | Implemented |
| Glow | `ec.stylize.glow` | 14 | GPU | 32 | Implemented |
| Hexagonal Tiles | `ec.stylize.cchextile` | 5 | GPU | 32 | Implemented |
| Kaleidoscope | `ec.stylize.cckaleida` | 4 | GPU | 32 | Implemented |
| Mosaic | `ec.stylize.mosaic` | 3 | GPU | 32 | Implemented |
| Motion Tile | `ec.stylize.motiontile` | 8 | GPU | 32 | Implemented |
| Palette Map | `ec.stylize.ccmrsmoothie` | 7 | GPU | 32 | Implemented |
| Plastic Shading | `ec.stylize.ccplastic` | 18 | GPU | 32 | Implemented |
| Posterize | `ec.stylize.posterize` | 1 | GPU | 32 | Implemented |
| RGB Threshold | `ec.stylize.ccthresholdrgb` | 7 | GPU | 32 | Implemented |
| Roughen Edges | `ec.stylize.roughenedges` | 13 | GPU | 32 | Implemented |
| Scatter | `ec.stylize.scatter` | 3 | GPU | 32 | Implemented |
| Strobe Light | `ec.stylize.strobe` | 7 | GPU | 32 | Implemented |
| Texturize | `ec.stylize.texturize` | 4 | GPU | 32 | Implemented |
| Threshold | `ec.stylize.threshold` | 1 | GPU | 32 | Implemented |
| Vignette | `ec.stylize.ccvignette` | 4 | GPU | 32 | Implemented |
## Text
@@ -379,11 +379,11 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| Effect | Id | Params | GPU | 32 | Status |
|---|---|---:|:-:|:-:|---|
| CC Force Motion Blur | `ec.time.ccforcemotionblur` | 4 | GPU | 32 | Implemented |
| CC Wide Time | `ec.time.ccwidetime` | 3 | GPU | 32 | Implemented |
| Echo | `ec.time.echo` | 5 | GPU | 32 | Implemented |
| Pixel Motion Blur | `ec.time.pixelmotionblur` | 4 | GPU | 32 | Implemented |
| Posterize Time | `ec.time.posterizetime` | 1 | GPU | 32 | Implemented |
| Temporal Blend | `ec.time.ccwidetime` | 3 | GPU | 32 | Implemented |
| Temporal Motion Blur | `ec.time.ccforcemotionblur` | 4 | GPU | 32 | Implemented |
| Time Difference | `ec.time.timedifference` | 5 | GPU | 32 | Implemented |
| Time Displacement | `ec.time.timedisplacement` | 4 | GPU | 32 | Implemented |
| Timewarp | `ec.time.timewarp` | 28 | GPU | 32 | Implemented |
@@ -393,29 +393,28 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| Effect | Id | Params | GPU | 32 | Status |
|---|---|---:|:-:|:-:|---|
| Block Dissolve | `ec.transition.blockdissolve` | 5 | GPU | 32 | Implemented |
| CC Glass Wipe | `ec.transition.ccglasswipe` | 5 | GPU | 32 | Implemented |
| CC Grid Wipe | `ec.transition.ccgridwipe` | 7 | GPU | 32 | Implemented |
| CC Image Wipe | `ec.transition.ccimagewipe` | 7 | GPU | 32 | Implemented |
| CC Jaws | `ec.transition.ccjaws` | 6 | GPU | 32 | Implemented |
| CC Light Wipe | `ec.transition.cclightwipe` | 8 | GPU | 32 | Implemented |
| CC Line Sweep | `ec.transition.cclinesweep` | 5 | GPU | 32 | Implemented |
| CC Radial ScaleWipe | `ec.transition.ccradialscalewipe` | 3 | GPU | 32 | Implemented |
| CC Scale Wipe | `ec.transition.ccscalewipe` | 3 | GPU | 32 | Implemented |
| CC Twister | `ec.transition.cctwister` | 5 | GPU | 32 | Implemented |
| CC WarpoMatic | `ec.transition.ccwarpomatic` | 8 | GPU | 32 | Implemented |
| Card Wipe | `ec.transition.cardwipe` | 44 | GPU | 32 | Implemented |
| Glass Wipe | `ec.transition.ccglasswipe` | 5 | GPU | 32 | Implemented |
| Gradient Wipe | `ec.transition.gradientwipe` | 5 | GPU | 32 | Implemented |
| Grid Wipe | `ec.transition.ccgridwipe` | 7 | GPU | 32 | Implemented |
| Iris Wipe | `ec.transition.iriswipe` | 7 | GPU | 32 | Implemented |
| Light Wipe | `ec.transition.cclightwipe` | 8 | GPU | 32 | Implemented |
| Line Sweep | `ec.transition.cclinesweep` | 5 | GPU | 32 | Implemented |
| Linear Wipe | `ec.transition.linearwipe` | 3 | GPU | 32 | Implemented |
| Luminance Image Wipe | `ec.transition.ccimagewipe` | 7 | GPU | 32 | Implemented |
| Radial Scale Wipe | `ec.transition.ccradialscalewipe` | 3 | GPU | 32 | Implemented |
| Radial Wipe | `ec.transition.radialwipe` | 5 | GPU | 32 | Implemented |
| Scale Wipe | `ec.transition.ccscalewipe` | 3 | GPU | 32 | Implemented |
| Serrated Wipe | `ec.transition.ccjaws` | 6 | GPU | 32 | Implemented |
| Twist Wipe | `ec.transition.cctwister` | 5 | GPU | 32 | Implemented |
| Venetian Blinds | `ec.transition.venetian` | 4 | GPU | 32 | Implemented |
| Warp Dissolve | `ec.transition.ccwarpomatic` | 8 | GPU | 32 | Implemented |
## Utility
| Effect | Id | Params | GPU | 32 | Status |
|---|---|---:|:-:|:-:|---|
| Apply Color LUT | `ec.utility.applylut` | 1 | GPU | 32 | Implemented |
| CC Overbrights | `ec.utility.ccoverbrights` | 2 | GPU | 32 | Implemented |
| Cineon Converter | `ec.utility.cineon` | 7 | GPU | 32 | Implemented |
| Color Profile Converter | `ec.utility.colorprofileconverter` | 7 | GPU | 32 | Implemented |
| Face Measurements | `ec.utility.facemeasurements` | 14 | | 32 | Implemented |
@@ -423,4 +422,5 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| Grow Bounds | `ec.utility.growbounds` | 1 | GPU | 32 | Implemented |
| HDR Compander | `ec.utility.hdrcompander` | 3 | GPU | 32 | Implemented |
| HDR Highlight Compression | `ec.utility.hdrcompression` | 1 | GPU | 32 | Implemented |
| Out-of-Range Display | `ec.utility.ccoverbrights` | 2 | GPU | 32 | Implemented |
+108
View File
@@ -0,0 +1,108 @@
# Descriptive feature names
Related to #1365. This is a naming cleanup, not a trademark clearance or a product rename.
## Naming policy
Use short, descriptive labels for EffectCraft implementations. Do not reuse a vendor prefix or
coined feature name as our branding. Qualify similar effects by what distinguishes them; each
English display name remains unique across the registry.
- Renderer: **GPU Acceleration** / **Software Rendering**.
- Panel: **Video Scopes**.
- The table below covers the 69 former CC effects plus color grading, the shape matte and keying.
## Compatibility contract
Only presentation names change. Effect IDs, command IDs, parameter IDs, enum values, panel IDs,
serialized types and AEP import/export match names stay unchanged. Old effect display names
remain case-insensitive lookup and search aliases. Existing project instance names, custom names,
expressions, animation and values are not rewritten. The UI displays a current label for an exact
legacy default name, optionally followed by its numeric instance suffix; all other names stay
verbatim. A user-entered name identical to a legacy default cannot be distinguished from it, but
its saved value is still retained.
External format specifications, comparison documents, attribution records and compatibility
fixtures may still name the external product or effect they actually describe.
## Display mapping
| Stable ID | Previous display name | Current display name |
|---|---|---|
| `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 |
`Roto Brush & Refine Edge` and `Warp Stabilizer` keep their current names; renaming them is
deferred.
No effect algorithms or formats are changed.

Some files were not shown because too many files have changed in this diff Show More