Merge remote-tracking branch 'origin/aep-audit-3300' into aep-import-bounds-3299

This commit is contained in:
vangel76 committed 2026-11-09 19:22:00 +01:00
commit 28206a3c1d
132 files changed
+5885 -1103

No files matched your search

+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",
@@ -7960,7 +7960,6 @@ dependencies = [
"nix",
"typed-path 0.13.0",
"windows-sys 0.61.2",
"zopfli",
]
[[package]]
@@ -7975,18 +7974,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
@@ -71,13 +71,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
@@ -915,7 +915,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.
@@ -1271,16 +1271,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],
@@ -1418,7 +1423,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)
}
@@ -591,7 +604,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);
}
@@ -1125,6 +1138,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
@@ -101,6 +101,45 @@ fn every_ntsc_rate_reads_back_as_the_rate_it_was_written_for() {
}
}
/// #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 bounds;
mod cameras;
+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()));
+88 -9
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;
@@ -448,7 +451,9 @@ pub fn adjustment_crop(offset: [f64; 2], canvas_offset: [f64; 2], size: (u32, u3
/// Effects that grow their own buffer by what they need (blur and glow margins, Repeat Edge
/// Pixels through [`Buf::cover`], displacement through [`cover_pad`]): a stack of only these
/// skips the renderer's cover of the whole layer rectangle (#1205).
pub const SKIPS_COVER: &[&str] = &["ec.blur.gaussian", "ec.blur.fastbox", "ec.stylize.glow", "ec.distort.turbulentdisplace"];
/// Drop Shadow also pads by its distance and softness on both CPU and GPU: covering the
/// layer rectangle first only inflates working images on highly scaled text and shapes (#3357).
pub const SKIPS_COVER: &[&str] = &["ec.blur.gaussian", "ec.blur.fastbox", "ec.stylize.glow", "ec.distort.turbulentdisplace", "ec.perspective.dropshadow"];
/// The grid phase [`Buf::align`] keeps and the step [`cover_pad`] grows in: covers the levels
/// of the resampling pyramid used for downscaled layers.
@@ -560,7 +565,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 +781,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 +940,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 +1069,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 +1154,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 +1516,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 +1574,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
),
+4
View File
@@ -863,6 +863,10 @@ fn save_current_preview(s: &mut Session, p: &Value) -> Result<Value> {
storage: s.storage_quota.as_deref(),
label: "Save Current Preview".into(),
nested_switches: s.prefs.general.switches_affect_nested_comps,
caches: None,
renderer: None,
gpu_frames: s.prefs.memory.gpu_frames(),
};
let r = exporter.export(&job, &mut |_, _| true).map_err(EngineError::Other)?;
s.toast(format!("Saved preview to {}", r.path));
+4
View File
@@ -145,6 +145,10 @@ fn prerender(
storage: None,
label: format!("Pre-render {}", l.name),
nested_switches: cx.nested_switches,
caches: None,
renderer: None,
gpu_frames: None,
};
let r = exporter.export(&job, progress)?;
Ok(Prerendered { path: r.path, width: comp.width, height: comp.height, frame_rate: comp.frame_rate, duration: end - start })
+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> =
+46
View File
@@ -34,6 +34,42 @@ pub struct ExportJob<'a> {
pub label: String,
/// Settings ▸ General ▸ Switches Affect Nested Comps.
pub nested_switches: bool,
/// The layer caches of the Render Queue run this job belongs to (#3335): consecutive jobs
/// start warm, and the run trims the accelerator's pooled textures once at its end. `None`:
/// a one-off export (its own caches, trimmed when it ends).
pub caches: Option<&'a RunCaches>,
/// The compositor the queue item's output modules render on (#3335): the first module
/// chooses (measuring a GPU project's first frames) and stores it here, the others follow.
pub renderer: Option<&'a Mutex<Option<effectcraft_render::Backend>>>,
/// Settings ▸ Memory & CPU ▸ GPU Frames Rendered at Once: 1 to 3, `None` for Auto.
pub gpu_frames: Option<usize>,
}
/// What the jobs of one Render Queue run keep between them (#3335): a layer cache per
/// compositor (a buffer an effect made on the GPU is not the CPU's), together at the budget
/// each job's single cache had before.
pub struct RunCaches {
pub gpu: effectcraft_render::LayerCache,
pub cpu: effectcraft_render::LayerCache,
}
impl Default for RunCaches {
fn default() -> Self {
let half = effectcraft_render::LayerCache::default().budget() / 2;
RunCaches { gpu: effectcraft_render::LayerCache::new(half), cpu: effectcraft_render::LayerCache::new(half) }
}
}
/// Calls [`effectcraft_render::Accelerator::render_end`] when a Render Queue run ends, however
/// it ends (done, failed, stopped, panicked): its jobs leave the pooled textures to each other.
struct RunEnd<'a>(Option<&'a dyn effectcraft_render::Accelerator>);
impl Drop for RunEnd<'_> {
fn drop(&mut self) {
if let Some(a) = self.0 {
a.render_end();
}
}
}
/// What an export wrote.
@@ -212,6 +248,8 @@ struct Work {
items: Vec<Vec<(RenderQueueItem, String)>>,
/// Settings ▸ General ▸ Switches Affect Nested Comps.
nested_switches: bool,
/// Settings ▸ Memory & CPU ▸ GPU Frames Rendered at Once.
gpu_frames: Option<usize>,
}
/// Where a relative render output goes when there is no default folder and the project is unsaved:
@@ -307,6 +345,7 @@ pub(crate) async fn run_items(
storage: s.storage_quota.clone(),
items,
nested_switches: s.prefs.general.switches_affect_nested_comps,
gpu_frames: s.prefs.memory.gpu_frames(),
};
run_work(work, shared, notify).await;
}
@@ -317,6 +356,8 @@ async fn run_work(w: Work, shared: &JobShared, notify: &mut dyn FnMut(&JobShared
let mut s = lock(&shared.state);
s.items_total = w.items.len();
}
let caches = RunCaches::default();
let _run_end = RunEnd(w.accel.as_deref());
for (k, modules) in w.items.iter().enumerate() {
let Some((item, _)) = modules.first() else { continue };
if shared.cancel.load(Ordering::Relaxed) {
@@ -337,6 +378,7 @@ async fn run_work(w: Work, shared: &JobShared, notify: &mut dyn FnMut(&JobShared
// Every output module encodes the same frames (Composition ▸ Add Output Module); the
// first module's file is the item's output.
let mut r: Result<ExportResult, String> = Err("no output module".into());
let renderer = Mutex::new(None);
for (mi, (mitem, path)) in modules.iter().enumerate() {
let name = w.project.item(mitem.comp).map(|i| i.name.clone()).unwrap_or_default();
let index = w.project.render_queue.iter().position(|i| i.id == mitem.id).map_or(0, |i| i + 1);
@@ -350,6 +392,9 @@ async fn run_work(w: Work, shared: &JobShared, notify: &mut dyn FnMut(&JobShared
storage: w.storage.as_deref(),
label: format!("#{index} {name} (output module {})", mi + 1),
nested_switches: w.nested_switches,
caches: Some(&caches),
renderer: Some(&renderer),
gpu_frames: w.gpu_frames,
};
let rr = w
.exporter
@@ -610,6 +655,7 @@ impl Session {
storage: self.storage_quota.clone(),
items,
nested_switches: self.prefs.general.switches_affect_nested_comps,
gpu_frames: self.prefs.memory.gpu_frames(),
};
let shared = Arc::new(JobShared::default());
if wait {
+89
View File
@@ -248,3 +248,92 @@ fn locked_resize_uses_height_for_height_only_output_module_updates() {
let comp = s.project.comp(item.comp).unwrap();
assert_eq!(item.output.output_size(comp, &item.settings), (32, 32), "explicit width retains precedence");
}
#[test]
fn a_queue_run_keeps_its_jobs_warm() {
// #3335: every job started with a fresh layer cache and trimmed the GPU's texture pool when
// it ended, so each item's first frames were cold. A run's jobs share its caches; the pool is
// trimmed once, when the run ends; an item's output modules share one compositor choice.
use std::sync::atomic::{AtomicUsize, Ordering};
struct Recorder(Mutex<Vec<(Option<usize>, Option<usize>)>>);
impl Exporter for Recorder {
fn formats(&self) -> Vec<OutputFormat> {
OutputFormat::ALL.to_vec()
}
fn export(&self, job: &ExportJob, _: &mut dyn FnMut(u64, u64) -> bool) -> Result<ExportResult, String> {
let caches = job.caches.map(|c| c as *const _ as usize);
let slot = job.renderer.map(|r| {
// The first module chooses; the second sees its choice.
r.lock().unwrap().get_or_insert(effectcraft_render::Backend::Cpu);
r as *const _ as usize
});
self.0.lock().unwrap().push((caches, slot));
Ok(ExportResult { path: job.path.to_string(), ..Default::default() })
}
}
#[derive(Default)]
struct Ends(AtomicUsize);
impl effectcraft_render::Accelerator for Ends {
fn name(&self) -> String {
"ends".into()
}
fn comp_frame(&self, _: &effectcraft_render::Renderer, _: effectcraft_project::ItemId, _: effectcraft_time::Tick) -> Option<effectcraft_raster::Image> {
None
}
fn supports_effect(&self, _: &str) -> bool {
false
}
fn effects(&self, _: &[effectcraft_render::FxStep], _: &effectcraft_effects::Buf, _: Option<f32>) -> Option<effectcraft_effects::Buf> {
None
}
fn render_end(&self) {
self.0.fetch_add(1, Ordering::SeqCst);
}
}
let mut s = demo();
let ex = Arc::new(Recorder(Mutex::new(vec![])));
s.exporter = Some(ex.clone());
let ends = Arc::new(Ends::default());
s.accel = Some(ends.clone());
s.execute("renderQueue.add", json!({"output": crate::tests::stubs::tmp_out("rq/warm_a.mp4")})).unwrap();
s.execute("render.addOutputModule", json!({"format": "png"})).unwrap();
s.execute("renderQueue.add", json!({"output": crate::tests::stubs::tmp_out("rq/warm_b.mp4")})).unwrap();
s.execute("renderQueue.render", json!({})).unwrap();
let jobs = ex.0.lock().unwrap().clone();
assert_eq!(jobs.len(), 3, "{jobs:?}");
assert!(jobs.iter().all(|(c, r)| c.is_some() && r.is_some()), "{jobs:?}");
assert!(jobs.iter().all(|(c, _)| *c == jobs[0].0), "every job of the run shares its caches");
assert_eq!(jobs[0].1, jobs[1].1, "an item's output modules share one choice");
assert_eq!(ends.0.load(Ordering::SeqCst), 1, "the pool is trimmed once, after the run");
}
#[test]
fn the_gpu_frames_setting_reaches_every_render_queue_job() {
// #3335: Settings ▸ Memory & CPU ▸ GPU Frames Rendered at Once is Auto (`None`) or 1–3.
struct Seen(Mutex<Vec<Option<usize>>>);
impl Exporter for Seen {
fn formats(&self) -> Vec<OutputFormat> {
OutputFormat::ALL.to_vec()
}
fn export(&self, job: &ExportJob, _: &mut dyn FnMut(u64, u64) -> bool) -> Result<ExportResult, String> {
self.0.lock().unwrap().push(job.gpu_frames);
Ok(ExportResult { path: job.path.to_string(), ..Default::default() })
}
}
let mut s = demo();
let ex = Arc::new(Seen(Mutex::new(vec![])));
s.exporter = Some(ex.clone());
assert_eq!(s.prefs.memory.gpu_frames_in_flight, "auto");
let pages = crate::settings::prefs::pages_json();
assert!(pages.to_string().contains("memory.gpuFramesInFlight"), "the Memory & CPU page lists it");
for (value, want) in [("auto", None), ("1", Some(1)), ("2", Some(2)), ("3", Some(3))] {
s.execute("prefs.set", json!({"key": "memory.gpuFramesInFlight", "value": value})).unwrap();
assert_eq!(s.prefs.memory.gpu_frames(), want, "{value}");
s.execute("renderQueue.add", json!({"output": crate::tests::stubs::tmp_out(&format!("rq/frames_{value}.mp4"))})).unwrap();
s.execute("renderQueue.render", json!({})).unwrap();
assert_eq!(ex.0.lock().unwrap().last().copied(), Some(want), "{value}");
}
// A hand-edited settings file's nonsense is Auto.
s.prefs.memory.gpu_frames_in_flight = "9".into();
assert_eq!(s.prefs.memory.gpu_frames(), None);
}
@@ -310,6 +310,7 @@ pub fn pages() -> Vec<Page> {
s("memory.mediaCacheMb", "Footage Frame Cache", Kind::Int(64, 1 << 20, "MB"), true),
s("memory.previewCacheMb", "Preview (RAM) Cache", Kind::Int(64, 1 << 20, "MB"), true),
s("memory.reduceCacheWhenLow", "Reduce Cache Size When System Is Low on Memory", B, true),
s("memory.gpuFramesInFlight", "GPU Frames Rendered at Once", Kind::Choice(&[("Auto", "auto"), ("1", "1"), ("2", "2"), ("3", "3")]), true),
],
},
Page {
@@ -201,8 +201,24 @@ page!(Memory {
/// RAM preview (viewer frame) cache budget (MB).
preview_cache_mb: u32 = 3072,
reduce_cache_when_low: bool = true,
/// GPU Frames Rendered at Once (Render Queue): `auto` (from the GPU memory a frame
/// measured, 1 to 3) or `1`, `2`, `3`. `EFFECTCRAFT_GPU_INFLIGHT` overrides it.
gpu_frames_in_flight: String = "auto".into(),
});
impl Memory {
/// [`Memory::gpu_frames_in_flight`] as a count: 1 to 3, or `None` for Auto (and anything
/// else, as a hand-edited settings file may hold).
pub fn gpu_frames(&self) -> Option<usize> {
match self.gpu_frames_in_flight.trim() {
"1" => Some(1),
"2" => Some(2),
"3" => Some(3),
_ => None,
}
}
}
page!(Video {
enable_output: bool = false,
device: String = String::new(),
+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
+37 -1
View File
@@ -53,6 +53,7 @@ pub use effectcraft_project::render_queue;
pub use effectcraft_project::render_queue::StorageQuota;
pub use out::Sink;
pub(crate) use pipeline::Cx;
pub use pipeline::{CACHE_BUDGET, GPU_INFLIGHT_MAX, RENDERER_ENV, auto_gpu_frames, pick_renderer};
#[derive(Debug, thiserror::Error)]
pub enum ExportError {
@@ -107,6 +108,40 @@ pub struct JobOptions<'a> {
pub storage: Option<&'a dyn StorageQuota>,
/// Overflow folders, in order.
pub overflow: Vec<String>,
/// Layer caches shared by the consecutive jobs of a Render Queue run (#3335): the job
/// renders through them instead of fresh ones, and leaves the accelerator's pooled working
/// textures for the next job (whoever holds the caches calls
/// [`effectcraft_render::Accelerator::render_end`] after the last one). `None`: the job
/// starts cold and trims the pool when it ends, as a one-off export does.
pub caches: Option<JobCaches<'a>>,
/// The compositor of the job's frames (`Backend::Gpu` or `Backend::Cpu`; `Auto` counts as unset), shared by the output modules of
/// one queue item: when it holds a choice the job renders on it; otherwise the job stores
/// the compositor it chose there, so the item's other output modules encode the same
/// frames. `None`: the job decides alone.
pub renderer: Option<&'a std::sync::Mutex<Option<effectcraft_render::Backend>>>,
/// Settings ▸ Memory & CPU ▸ GPU Frames Rendered at Once: 1 to 3, or `None` for Auto (from
/// the device memory a frame measured, #3335). `EFFECTCRAFT_GPU_INFLIGHT` overrides it.
pub gpu_frames: Option<usize>,
}
/// The layer caches of a Render Queue run (see [`JobOptions::caches`]): one per compositor,
/// because a buffer an effect made on the GPU is not the CPU's, and neither may stand in for
/// the other.
#[derive(Clone, Copy)]
pub struct JobCaches<'a> {
pub gpu: &'a effectcraft_render::LayerCache,
pub cpu: &'a effectcraft_render::LayerCache,
}
/// 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.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum JobRenderer {
Gpu,
Cpu,
}
/// Progress after a batch of frames.
@@ -176,7 +211,8 @@ pub fn export(job: &Job, progress: &mut dyn FnMut(&Progress) -> bool) -> Result<
pub async fn export_async(job: &Job<'_>, progress: &mut dyn FnMut(&Progress) -> bool) -> Result<Report> {
let t0 = Instant::now();
let cx = Cx::new(job);
let render = RenderEnd(job.accel);
// A job of a Render Queue run leaves the pooled working textures warm for the next one.
let render = RenderEnd(job.accel.filter(|_| job.options.caches.is_none()));
let mut r = export_with(&cx, progress, t0).await;
drop(render);
if matches!(r, Err(ExportError::Cancelled)) {
+577 -47
View File
@@ -13,30 +13,107 @@ use web_time::Instant;
use crate::{Job, RenderQuality, out};
/// GPU frames rendered at once by default: one, as the export scheduler's single GPU worker
/// renders image sequences (#898: frames in flight beyond one only with asynchronous YUV
/// readback). Measured on the #879 project at 1080p on an 8 GB card: 6.3 GB peak with one,
/// 7.0 to 7.8 GB with two, and the whole card with three, where main fell back to the CPU.
/// Not sized from free VRAM: the GPU backend reports no free or total device memory on every
/// platform, so a portable estimate is not available here.
/// GPU frames rendered at once until a frame has measured the device memory, and whenever it
/// can't be measured (#898, #879: measured on the #879 project at 1080p on an 8 GB card, 6.3 GB
/// peak with one frame, 7.0 to 7.8 GB with two, the whole card with three).
pub(crate) const GPU_INFLIGHT_DEFAULT: usize = 1;
/// Overrides [`GPU_INFLIGHT_DEFAULT`]: a whole number of frames, at least 1.
/// The most GPU frames Auto renders at once (#3335: four and more measured no faster).
pub const GPU_INFLIGHT_MAX: usize = 3;
/// Overrides Settings ▸ Memory & CPU ▸ GPU Frames Rendered at Once: a whole number of frames,
/// at least 1.
pub(crate) const GPU_INFLIGHT_ENV: &str = "EFFECTCRAFT_GPU_INFLIGHT";
/// GPU frames in flight from the `EFFECTCRAFT_GPU_INFLIGHT` value: a positive whole number, or
/// the default (with a warning when a value was given) when it is 0, negative, not a number or
/// too large.
pub(crate) fn gpu_inflight(var: Option<&str>) -> usize {
let Some(v) = var else { return GPU_INFLIGHT_DEFAULT };
/// `None` (with a warning when a value was given) when it is unset, 0, negative, not a number
/// or too large.
pub(crate) fn gpu_inflight_env(var: Option<&str>) -> Option<usize> {
let v = var?;
match v.trim().parse::<usize>() {
Ok(n) if n >= 1 => n,
Ok(n) if n >= 1 => Some(n),
_ => {
log::warn!(target: "effectcraft_export", "{GPU_INFLIGHT_ENV}={v:?} is not a whole number of frames of at least 1: using {GPU_INFLIGHT_DEFAULT}");
GPU_INFLIGHT_DEFAULT
log::warn!(target: "effectcraft_export", "{GPU_INFLIGHT_ENV}={v:?} is not a whole number of frames of at least 1: ignored");
None
}
}
}
/// Auto's GPU frames in flight from the device memory after a frame (#3335): as many more
/// frames as the budget has room for beyond the allocator's peak (which holds one frame's
/// working memory) and what the caches may still grow by, 1 to [`GPU_INFLIGHT_MAX`]. Unknown
/// memory: 1.
pub fn auto_gpu_frames(m: Option<effectcraft_render::FrameMemory>) -> usize {
let Some(m) = m.filter(|m| m.budget > 0 && m.frame > 0 && m.peak > 0) else { return GPU_INFLIGHT_DEFAULT };
let more = m.budget.saturating_sub(m.peak).saturating_sub(m.reserve) / m.frame;
usize::try_from(more.saturating_add(1)).unwrap_or(GPU_INFLIGHT_MAX).clamp(1, GPU_INFLIGHT_MAX)
}
/// The GPU frames a job renders at once and why (for the render log): the environment's
/// override, else the setting (`pref`, `None` = Auto), else Auto from `measured` (`None`: no
/// GPU frame has rendered yet, so nothing is settled).
pub(crate) fn resolve_gpu_frames(env: Option<&str>, pref: Option<usize>, measured: Option<Option<effectcraft_render::FrameMemory>>) -> Option<(usize, String)> {
if let Some(n) = gpu_inflight_env(env) {
return Some((n, format!("set by {GPU_INFLIGHT_ENV}")));
}
if let Some(n) = pref.filter(|n| *n >= 1) {
return Some((n, "Settings".into()));
}
let m = measured?;
let n = auto_gpu_frames(m);
let mib = |b: u64| b >> 20;
Some((
n,
match m {
Some(m) => format!(
"Auto: {} MiB per frame, {} MiB peak and {} MiB cache reserve in a {} MiB budget",
mib(m.frame),
mib(m.peak),
mib(m.reserve),
mib(m.budget)
),
None => "Auto: the GPU's memory is not known".into(),
},
))
}
/// Overrides the Render Queue's measured choice of compositor for GPU projects with an
/// accelerator (#3335): `gpu` (no measurement, the GPU), `cpu` (Software Rendering) or
/// `auto` (measure, the default).
pub const RENDERER_ENV: &str = "EFFECTCRAFT_RQ_RENDERER";
/// Frames the GPU renders, timed, when a job measures which compositor is faster (after one
/// untimed warm-up frame each: layer cache misses, uploads, kernel builds).
const PROBE_FRAMES: usize = 2;
/// Frames the CPU renders at once, timed, in that measurement: its throughput comes from
/// rendering several frames in parallel, which two frames understate.
const PROBE_CPU_FRAMES: usize = 8;
/// Jobs shorter than this render on the project's compositor without measuring: the frames
/// the slower one renders for the measurement would cost more than they could save.
const PROBE_MIN_JOB: u64 = 12;
/// Bytes each of a job's two layer caches (one per compositor) may hold: together what the
/// job's single cache held before (`LayerCache::default()`).
pub const CACHE_BUDGET: usize = 1 << 29;
/// The faster compositor from measured milliseconds per frame (`None`: not measured, or the
/// GPU stopped). As the viewer's Auto ([`effectcraft_render::AutoPick::MARGIN`]), the CPU wins
/// only when clearly faster: ties and noise stay on the GPU.
pub fn pick_renderer(gpu_ms: Option<f64>, cpu_ms: Option<f64>) -> crate::JobRenderer {
match (gpu_ms, cpu_ms) {
(Some(g), Some(c)) if g.is_finite() && c.is_finite() && c < g * effectcraft_render::AutoPick::MARGIN => crate::JobRenderer::Cpu,
(None, Some(_)) => crate::JobRenderer::Cpu,
_ => crate::JobRenderer::Gpu,
}
}
/// How a job's compositor was chosen, for the render log.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct Choice {
pub renderer: crate::JobRenderer,
/// Measured ms per frame on each (`None`: not measured).
pub gpu_ms: Option<f64>,
pub cpu_ms: Option<f64>,
pub why: &'static str,
}
/// Caps CPU frames in flight (#1615): a whole number of frames, at least 1. Unset, CPU renders
/// keep one frame per thread in flight as before. A heavy comp can need gigabytes of working
/// memory per frame, so 16 threads at once can get the process OOM-killed; the headless CLI
@@ -168,8 +245,14 @@ pub(crate) struct Cx<'a> {
pub project: Project,
/// Processed layer buffers shared by the job's frames, as the viewer's layer cache: static
/// and transform-only layers render once per job (the GPU compositor then uploads them
/// once). Dropped with the job.
cache: LayerCache,
/// once). One per compositor; the job's own (dropped with it) or a Render Queue run's.
caches: Caches<'a>,
/// The compositor the job renders on, once chosen ([`Cx::backend`]).
choice: Mutex<Option<Choice>>,
/// GPU frames rendered at once and why, once settled ([`Cx::gpu_inflight`]).
gpu_frames: Mutex<Option<(usize, String)>>,
/// A frame has rendered on the GPU compositor: its memory can be measured.
gpu_rendered: std::sync::atomic::AtomicBool,
/// (output frame, seconds) of every rendered frame (Plus Per Frame Info logs).
pub frame_times: Option<Mutex<Vec<(u64, f64)>>>,
/// Files written to an overflow folder.
@@ -177,6 +260,23 @@ pub(crate) struct Cx<'a> {
written: Mutex<Vec<String>>,
}
/// A job's layer caches: its own, or those of the Render Queue run it belongs to.
enum Caches<'a> {
Own { gpu: Box<LayerCache>, cpu: Box<LayerCache> },
Run(crate::JobCaches<'a>),
}
impl Caches<'_> {
fn of(&self, r: crate::JobRenderer) -> &LayerCache {
match (self, r) {
(Caches::Own { gpu, .. }, crate::JobRenderer::Gpu) => gpu,
(Caches::Own { cpu, .. }, crate::JobRenderer::Cpu) => cpu,
(Caches::Run(c), crate::JobRenderer::Gpu) => c.gpu,
(Caches::Run(c), crate::JobRenderer::Cpu) => c.cpu,
}
}
}
impl<'a> std::ops::Deref for Cx<'a> {
type Target = Job<'a>;
fn deref(&self) -> &Job<'a> {
@@ -222,10 +322,95 @@ impl<'a> Cx<'a> {
// layer and effect quantises at it), not just the finished frame (#2022).
project.settings.bit_depth = job.settings.color_depth.resolve(project.settings.bit_depth);
let frame_times = (job.options.log == RenderLog::PlusPerFrameInfo).then(|| Mutex::new(Vec::new()));
let cache = LayerCache::default();
let caches = match job.options.caches {
Some(c) => Caches::Run(c),
None => Caches::Own { gpu: Box::new(LayerCache::new(CACHE_BUDGET)), cpu: Box::new(LayerCache::new(CACHE_BUDGET)) },
};
// With deferred readbacks, buffers computed from a missed pass's placeholders are not kept.
cache.set_gate(job.accel.and_then(|a| a.miss_gate()));
Cx { job, project, cache, frame_times, overflowed: Mutex::new(vec![]), written: Mutex::new(vec![]) }
for r in [crate::JobRenderer::Gpu, crate::JobRenderer::Cpu] {
caches.of(r).set_gate(job.accel.and_then(|a| a.miss_gate()));
}
let cx = Cx {
job,
project,
caches,
choice: Mutex::new(None),
gpu_frames: Mutex::new(None),
gpu_rendered: Default::default(),
frame_times,
overflowed: Mutex::new(vec![]),
written: Mutex::new(vec![]),
};
if let Some(c) = cx.fixed_choice(std::env::var(RENDERER_ENV).ok().as_deref()) {
cx.decide(c);
}
cx
}
/// 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 }
}
/// The job's compositor when it is decided without measuring: the queue item's first
/// output module's, the project's when there is nothing to choose between (Software Only,
/// no accelerator, deferred readbacks), or `env` ([`RENDERER_ENV`]). `None`: measure.
fn fixed_choice(&self, env: Option<&str>) -> Option<Choice> {
let fixed = |renderer, why| Some(Choice { renderer, gpu_ms: None, cpu_ms: None, why });
if self.project_renderer() == crate::JobRenderer::Cpu {
return fixed(crate::JobRenderer::Cpu, "the project's renderer");
}
if self.deferred() {
return fixed(crate::JobRenderer::Gpu, "the project's renderer");
}
let shared = self.job.options.renderer.and_then(|s| *s.lock().unwrap_or_else(std::sync::PoisonError::into_inner));
let shared = match shared {
Some(Backend::Gpu) => Some(crate::JobRenderer::Gpu),
Some(Backend::Cpu) => Some(crate::JobRenderer::Cpu),
Some(Backend::Auto) | None => None,
};
if let Some(r) = shared {
return Some(Choice { renderer: r, gpu_ms: None, cpu_ms: None, why: "as the queue item's first output module" });
}
match env.map(|v| v.trim().to_ascii_lowercase()).as_deref() {
Some("gpu") => fixed(crate::JobRenderer::Gpu, "set by EFFECTCRAFT_RQ_RENDERER"),
Some("cpu") => fixed(crate::JobRenderer::Cpu, "set by EFFECTCRAFT_RQ_RENDERER"),
None | Some("" | "auto") => None,
Some(v) => {
log::warn!(target: "effectcraft_export", "{RENDERER_ENV}={v:?} is not gpu, cpu or auto: measuring");
None
}
}
}
/// Settle the job's compositor (once): remember it for the queue item's other output
/// modules and say so in the log.
fn decide(&self, c: Choice) {
let mut slot = self.choice.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if slot.is_some() {
return;
}
*slot = Some(c);
if let Some(shared) = self.job.options.renderer {
let mut shared = shared.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if shared.is_none_or(|b| b == Backend::Auto) {
*shared = Some(match c.renderer {
crate::JobRenderer::Gpu => Backend::Gpu,
crate::JobRenderer::Cpu => Backend::Cpu,
});
}
}
log::info!(target: "effectcraft_export", "{}: {}", self.job.options.label, describe_choice(&c));
}
/// The job's choice, once made.
pub(crate) fn choice(&self) -> Option<Choice> {
*self.choice.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// The compositor frames render on: the choice, or the project's until it is made.
fn backend(&self) -> crate::JobRenderer {
self.choice().map_or_else(|| self.project_renderer(), |c| c.renderer)
}
pub fn comp(&self) -> Option<&Comp> {
@@ -234,12 +419,22 @@ impl<'a> Cx<'a> {
/// The renderer of the job's frames: the Render Settings' options, the job's accelerator
/// and layer cache.
#[cfg(test)]
fn renderer(&self) -> Renderer<'_> {
self.renderer_on(self.backend())
}
/// [`Cx::renderer`] on compositor `on`, with that compositor's layer cache.
fn renderer_on(&self, on: crate::JobRenderer) -> Renderer<'_> {
let s = self.job.settings;
// The whole job renders on the project's renderer (Mercury GPU Acceleration or Software
// Only), not on whichever compositor Auto timed faster for each frame: the CPU and GPU
// compositors differ slightly, so a per-frame choice made renders differ run to run.
let backend = if self.project.settings.gpu_acceleration { Backend::Gpu } else { Backend::Cpu };
// The whole job renders on one compositor (the project's, or the faster one measured on
// the first frames, #3335), not on whichever compositor Auto timed faster for each
// frame: the CPU and GPU compositors differ slightly, so a per-frame choice made renders
// differ within a render.
let backend = match on {
crate::JobRenderer::Gpu => Backend::Gpu,
crate::JobRenderer::Cpu => Backend::Cpu,
};
let opts = RenderOpts {
scale: self.comp().map(|c| s.effective_scale(c)).unwrap_or_else(|| s.resolution.clamp(0.01, 4.0)),
motion_blur: s.motion_blur_override() != SwitchOverride::OffForAll,
@@ -258,13 +453,13 @@ impl<'a> Cx<'a> {
let mut r = Renderer::new(&self.project, self.job.footage, opts);
r.expr = self.job.expr;
r.accel = self.job.accel;
r.cache = Some(&self.cache);
r.cache = Some(self.caches.of(on));
r
}
async fn render_at(&self, t: Tick) -> Image {
async fn render_at(&self, t: Tick, on: crate::JobRenderer) -> Image {
// With deferred GPU readbacks (a browser worker) the frame renders in passes.
effectcraft_render::passes::comp_frame(&self.renderer(), self.job.comp, t).await
effectcraft_render::passes::comp_frame(&self.renderer_on(on), self.job.comp, t).await
}
/// Whether frames render in passes (an accelerator with deferred readbacks).
@@ -283,10 +478,110 @@ impl<'a> Cx<'a> {
}
return out;
}
self.render_frames(ks, |k, on| self.frame_on(comp, k, on), f)
}
/// `render` then `encode` over the frames `ks` on the job's compositor, results in `ks`
/// order. The first call of a job that still has to choose its compositor measures both on
/// its first frames ([`Cx::probe`]) and keeps the faster one's.
fn render_frames<I: Send, T: Send>(
&self,
ks: Vec<u64>,
render: impl Fn(u64, crate::JobRenderer) -> I + Sync + Send,
encode: impl Fn(u64, I) -> T + Sync + Send,
) -> Vec<T> {
use rayon::prelude::*;
let probed = self.probe(&ks, &render);
// The GPU cap is for the GPU's memory: once it has stopped for good, frames render on the
// CPU, in parallel or under the CPU cap (#1615), which holds throughout.
let gpu_live = || self.job.accel.is_none_or(|a| a.available());
map_frames_while(ks, self.inflight(), gpu_live, |k| self.frame(comp, k), f)
let on = self.backend();
if probed.is_empty() {
return map_frames_while(ks, self.inflight(), gpu_live, |k| render(k, on), encode);
}
let rest: Vec<u64> = ks.iter().copied().filter(|k| !probed.iter().any(|(p, _)| p == k)).collect();
let mut done: Vec<(u64, T)> = probed.into_par_iter().map(|(k, i)| (k, encode(k, i))).collect();
done.extend(rest.iter().copied().zip(map_frames_while(rest.clone(), self.inflight(), gpu_live, |k| render(k, on), &encode)));
// Back in `ks` order.
let mut out = Vec::with_capacity(ks.len());
for k in &ks {
if let Some(i) = done.iter().position(|(d, _)| d == k) {
out.push(done.swap_remove(i).1);
}
}
out
}
/// Choose the job's compositor by timing both on the first frames of `ks` (#3335), as the
/// viewer's Auto does per comp: frame 0 warms each up (layer cache misses, uploads, kernel
/// builds) untimed, then the GPU renders the next [`PROBE_FRAMES`] under its in-flight cap
/// and the CPU up to [`PROBE_CPU_FRAMES`] after them in parallel (its throughput). The CPU
/// may render more frames at once in the job than here, so the measurement leans towards
/// the GPU, as the margin of [`pick_renderer`] does: the CPU is chosen only when clearly
/// faster. Returns
/// the frames the faster one rendered (the slower one's are rendered again); empty when
/// the choice was made already or is made without measuring (a short job).
fn probe<I: Send>(&self, ks: &[u64], render: &(impl Fn(u64, crate::JobRenderer) -> I + Sync + Send)) -> Vec<(u64, I)> {
if self.choice().is_some() {
return vec![];
}
let total = self.comp().map_or(0, |c| self.job.settings.frame_count(c));
if total < PROBE_MIN_JOB {
self.decide(Choice { renderer: crate::JobRenderer::Gpu, gpu_ms: None, cpu_ms: None, why: "the project's renderer (too few frames to measure)" });
return vec![];
}
// Nothing will be emitted for a skipped batch, so a later batch can still measure.
if ks.is_empty() {
return vec![];
}
// A resumed sequence can have only one or two missing frames in its first batch.
// Settle before emitting them: measuring a later batch could otherwise switch to the
// CPU after GPU pixels have already been written, including across output modules.
let n = PROBE_FRAMES.min(ks.len().saturating_sub(1) / 2);
if n == 0 {
self.decide(Choice {
renderer: crate::JobRenderer::Gpu,
gpu_ms: None,
cpu_ms: None,
why: "the project's renderer (too few pending frames to measure)",
});
return vec![];
}
let (Some(&first), Some(gpu_ks), Some(cpu_ks)) = (ks.first(), ks.get(1..1 + n), ks.get(1 + n..ks.len().min(1 + n + PROBE_CPU_FRAMES))) else {
return vec![];
};
let logged = || self.frame_times.as_ref().map_or(0, |ft| ft.lock().unwrap_or_else(std::sync::PoisonError::into_inner).len());
let cpu_cap = self.inflight().cpu;
let timed = |on: crate::JobRenderer, frames: &[u64]| {
let warm = render(first, on);
// The GPU's frames in flight settle with its warm-up frame (Auto measures it).
let caps = match on {
crate::JobRenderer::Gpu => Inflight { gpu: Some(self.gpu_inflight()), cpu: cpu_cap },
crate::JobRenderer::Cpu => Inflight { gpu: None, cpu: cpu_cap },
};
let t0 = Instant::now();
let gpu_live = || on == crate::JobRenderer::Cpu || self.job.accel.is_some_and(|a| a.available());
let items = map_frames_while(frames.to_vec(), caps, gpu_live, |k| render(k, on), |k, i| (k, i));
let ms = t0.elapsed().as_secs_f64() * 1e3 / frames.len().max(1) as f64;
(std::iter::once((first, warm)).chain(items).collect::<Vec<_>>(), ms)
};
let log0 = logged();
let (gpu, gpu_ms) = timed(crate::JobRenderer::Gpu, gpu_ks);
let log1 = logged();
let (cpu, cpu_ms) = timed(crate::JobRenderer::Cpu, cpu_ks);
// A GPU that stopped meanwhile rendered its frames on the CPU: it is no choice.
let gpu_ms = self.job.accel.is_some_and(|a| a.available()).then_some(gpu_ms);
let renderer = pick_renderer(gpu_ms, Some(cpu_ms));
// The per-frame log keeps the kept frames' times only.
if let Some(ft) = &self.frame_times {
let mut v = ft.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let (a, b) = if renderer == crate::JobRenderer::Gpu { (log1, v.len()) } else { (log0, log1) };
if a <= b && b <= v.len() {
v.drain(a..b);
}
}
self.decide(Choice { renderer, gpu_ms, cpu_ms: Some(cpu_ms), why: "measured on the first frames" });
if renderer == crate::JobRenderer::Gpu { gpu } else { cpu }
}
/// [`Cx::frames`] for `ks` while `encode` runs beside them on `beside`'s threads, so the
@@ -338,9 +633,29 @@ impl<'a> Cx<'a> {
/// GPU frames rendered at once (#879), decided once per batch: with a GPU accelerator every
/// frame in flight holds its own working textures, so a whole batch rendered at once can
/// exhaust VRAM (or shared memory, which crashes instead of falling back). `None` when the
/// frames render on the CPU: no accelerator, or a Software Only project.
/// frames render on the CPU: no accelerator, a Software Only project, or a job that chose
/// the CPU's compositor (#3335), whose frames render in parallel.
fn gpu_cap(&self) -> Option<usize> {
(self.job.accel.is_some() && self.project.settings.gpu_acceleration).then(|| gpu_inflight(std::env::var(GPU_INFLIGHT_ENV).ok().as_deref()))
(self.job.accel.is_some() && self.backend() == crate::JobRenderer::Gpu).then(|| self.gpu_inflight())
}
/// GPU frames in flight ([`resolve_gpu_frames`]): [`GPU_INFLIGHT_DEFAULT`] until settled,
/// which Auto is after the job's first GPU frame.
fn gpu_inflight(&self) -> usize {
if let Some((n, _)) = &*self.gpu_frames.lock().unwrap_or_else(std::sync::PoisonError::into_inner) {
return *n;
}
// Measured outside the lock (the allocator report walks every allocation).
let measured = self.gpu_rendered.load(std::sync::atomic::Ordering::Relaxed).then(|| self.job.accel.and_then(|a| a.frame_memory()));
let Some((n, why)) = resolve_gpu_frames(std::env::var(GPU_INFLIGHT_ENV).ok().as_deref(), self.job.options.gpu_frames, measured) else {
return GPU_INFLIGHT_DEFAULT;
};
let mut slot = self.gpu_frames.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
slot.get_or_insert_with(|| {
log::info!(target: "effectcraft_export", "{}: {n} GPU frame(s) at once ({why})", self.job.options.label);
(n, why)
})
.0
}
/// The GPU and CPU frames-in-flight caps every frame loop shares (#1615).
@@ -349,20 +664,34 @@ impl<'a> Cx<'a> {
}
/// Output frame `i`: sampled (fields, pulldown), at the colour depth, cropped and resized.
#[cfg(test)]
pub fn frame(&self, comp: &Comp, i: u64) -> Image {
effectcraft_render::passes::block_on(self.frame_async(comp, i))
self.frame_on(comp, i, self.backend())
}
/// [`Cx::frame`] on compositor `on`.
fn frame_on(&self, comp: &Comp, i: u64, on: crate::JobRenderer) -> Image {
effectcraft_render::passes::block_on(self.frame_async_on(comp, i, on))
}
/// [`Cx::frame`] as a future (deferred readbacks: the frame renders in passes).
pub async fn frame_async(&self, comp: &Comp, i: u64) -> Image {
self.frame_async_on(comp, i, self.backend()).await
}
async fn frame_async_on(&self, comp: &Comp, i: u64, on: crate::JobRenderer) -> Image {
let t0 = Instant::now();
let gpu = on == crate::JobRenderer::Gpu && self.job.accel.is_some();
let img = match self.job.settings.sample(comp, i) {
FrameSample::Progressive(t) => self.render_at(t).await,
FrameSample::Progressive(t) => self.render_at(t, on).await,
FrameSample::Fields { first, second, upper_first } => {
let a = self.render_at(first).await;
if second == first { a } else { interleave(&a, &self.render_at(second).await, upper_first) }
let a = self.render_at(first, on).await;
if second == first { a } else { interleave(&a, &self.render_at(second, on).await, upper_first) }
}
};
if gpu {
self.gpu_rendered.store(true, std::sync::atomic::Ordering::Relaxed);
}
let img = self.crop_resize(img);
if let Some(ft) = &self.frame_times {
ft.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push((i, t0.elapsed().as_secs_f64()));
@@ -374,7 +703,7 @@ impl<'a> Cx<'a> {
/// by the accelerator ([`effectcraft_render::Accelerator::comp_frame_yuv420`]). Only for
/// frames that need nothing done after the render (progressive, at the project's depth, no
/// crop or resize, rendered at exactly `w`×`h`). `None`: render it with [`Cx::frame`].
pub fn yuv420_frame(&self, comp: &Comp, i: u64, w: u32, h: u32) -> Option<Vec<u8>> {
fn yuv420_frame(&self, comp: &Comp, i: u64, w: u32, h: u32, on: crate::JobRenderer) -> Option<Vec<u8>> {
if self.deferred() {
return None;
}
@@ -392,7 +721,7 @@ impl<'a> Cx<'a> {
return None;
}
let t0 = Instant::now();
let r = self.renderer();
let r = self.renderer_on(on);
let a = r.active_accel()?;
let planes = a.comp_frame_yuv420(&r, self.job.comp, t, comp.background)?;
let (cw, ch) = (w.div_ceil(2) as usize, h.div_ceil(2) as usize);
@@ -401,6 +730,7 @@ impl<'a> Cx<'a> {
}
// A frame rendered off `passes::comp_frame` ends here; on a `None` the fallback renders and ends it.
a.frame_end();
self.gpu_rendered.store(true, std::sync::atomic::Ordering::Relaxed);
if let Some(ft) = &self.frame_times {
ft.lock().unwrap_or_else(|e| e.into_inner()).push((i, t0.elapsed().as_secs_f64()));
}
@@ -419,12 +749,9 @@ impl<'a> Cx<'a> {
fallback: impl Fn(u64, Image) -> T + Sync + Send,
) -> Vec<T> {
// As in `frames`: once the GPU has stopped for good, the rest render in parallel.
let gpu_live = || self.job.accel.is_none_or(|a| a.available());
map_frames_while(
self.render_frames(
ks,
self.inflight(),
gpu_live,
|k| self.yuv420_frame(comp, k, w, h).ok_or_else(|| self.frame(comp, k)),
|k, on| self.yuv420_frame(comp, k, w, h, on).ok_or_else(|| self.frame_on(comp, k, on)),
|k, r| match r {
Ok(p) => planes(p),
Err(img) => fallback(k, img),
@@ -544,6 +871,14 @@ impl<'a> Cx<'a> {
Ok(r) => s.push_str(&format!("Result: Done ({} frames, {}×{}, {} bytes) in {seconds:.2} s\n", r.frames, r.width, r.height, r.bytes)),
Err(e) => s.push_str(&format!("Result: Failed\nError: {e}\n")),
}
if let Some(c) = self.choice() {
s.push_str(&format!("Renderer: {}\n", describe_choice(&c)));
}
if self.backend() == crate::JobRenderer::Gpu
&& let Some((n, why)) = &*self.gpu_frames.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
{
s.push_str(&format!("GPU frames rendered at once: {n} ({why})\n"));
}
let over = self.overflowed.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if !over.is_empty() {
s.push_str(&format!("Storage overflow: {} file(s) written to overflow folders (first: {})\n", over.len(), over[0]));
@@ -575,6 +910,20 @@ 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 => "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() {
format!("{name} ({})", c.why)
} else {
format!("{name} ({}: GPU {}, CPU {})", c.why, ms(c.gpu_ms), ms(c.cpu_ms))
}
}
/// Weave two fields: the dominant field's lines (even lines when `upper_first`) from `first`.
pub(crate) fn interleave(first: &Image, second: &Image, upper_first: bool) -> Image {
let mut out = first.clone();
@@ -860,6 +1209,82 @@ mod tests {
assert_eq!(Cx::new(&job).gpu_cap(), None, "no accelerator");
}
/// A resumed sequence can start with one or two missing frames. Once emitted, their
/// compositor must also be used by every later batch and the item's other output modules.
#[test]
fn a_small_first_batch_settles_the_renderer_before_emitting_frames() {
if std::env::var_os(RENDERER_ENV).is_some() {
return;
}
use effectcraft_project::render_queue::{OutputFormat, OutputModule, RenderSettings};
let (settings, output) = (RenderSettings::default(), OutputModule::for_format(OutputFormat::PngSequence));
let mut p = Project::default();
p.settings.gpu_acceleration = true;
let comp = Comp::new(16, 8, effectcraft_time::FrameRate::FPS_25, Tick::from_seconds_f64(1.0));
let cid = p.add_item("Comp", effectcraft_color::Label::Sandstone, None, effectcraft_project::ItemKind::Comp(comp.into()));
let accel = NoAccel;
for n in [1, 2] {
let shared = Mutex::new(None);
let job = Job {
project: &p,
footage: &effectcraft_render::NoFootage,
expr: None,
accel: Some(&accel),
comp: cid,
settings: &settings,
output: &output,
path: "/r/f_[#####].png",
sink: None,
nested_switches: true,
options: crate::JobOptions { renderer: Some(&shared), ..Default::default() },
};
let cx = Cx::new(&job);
let empty = cx.render_frames(Vec::new(), |_, on| on, |_, on| on);
assert!(empty.is_empty());
assert_eq!(cx.choice(), None, "skipped batches do not choose");
let first = cx.render_frames((0..n).collect(), |_, on| on, |_, on| on);
assert_eq!(first, vec![crate::JobRenderer::Gpu; n as usize]);
assert_eq!(*shared.lock().unwrap(), Some(Backend::Gpu), "settled before any pixels are emitted");
let later = cx.render_frames((n..12).collect(), |_, on| on, |_, on| on);
assert!(later.iter().all(|on| *on == crate::JobRenderer::Gpu));
assert_eq!(Cx::new(&job).backend(), crate::JobRenderer::Gpu, "the next output module follows");
}
}
#[test]
fn a_job_that_chose_the_cpu_is_not_capped() {
// #3335: the CPU's frame-parallel compositor is the point of choosing it; a GPU project
// that measured the CPU faster must not keep rendering one frame at a time.
use effectcraft_project::render_queue::{OutputFormat, OutputModule, RenderSettings};
let (settings, output) = (RenderSettings::default(), OutputModule::for_format(OutputFormat::PngSequence));
let accel = NoAccel;
let mut p = Project::default();
p.settings.gpu_acceleration = true;
let job = Job {
project: &p,
footage: &effectcraft_render::NoFootage,
expr: None,
accel: Some(&accel),
comp: effectcraft_project::ItemId(0),
settings: &settings,
output: &output,
path: "/c/f_[#####].png",
sink: None,
nested_switches: true,
options: Default::default(),
};
let cx = Cx::new(&job);
if std::env::var_os(RENDERER_ENV).is_none() {
assert_eq!(cx.choice(), None, "a GPU project measures first");
assert!(cx.gpu_cap().is_some());
}
cx.decide(Choice { renderer: crate::JobRenderer::Cpu, gpu_ms: Some(117.0), cpu_ms: Some(68.0), why: "measured on the first frames" });
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()), "Software Rendering (measured on the first frames: GPU 117.0 ms/frame, CPU 68.0 ms/frame)");
}
#[test]
fn cpu_inflight_is_off_unless_a_positive_number() {
assert_eq!(cpu_inflight(None), None);
@@ -958,12 +1383,117 @@ mod tests {
}
#[test]
fn gpu_inflight_override_is_a_positive_number_or_the_default() {
assert_eq!(gpu_inflight(None), GPU_INFLIGHT_DEFAULT);
assert_eq!(gpu_inflight(Some("3")), 3);
assert_eq!(gpu_inflight(Some(" 5 ")), 5);
fn gpu_inflight_override_is_a_positive_number_or_ignored() {
assert_eq!(gpu_inflight_env(None), None);
assert_eq!(gpu_inflight_env(Some("3")), Some(3));
assert_eq!(gpu_inflight_env(Some(" 5 ")), Some(5));
for bad in ["0", "", "-2", "three", "2.5", "99999999999999999999999"] {
assert_eq!(gpu_inflight(Some(bad)), GPU_INFLIGHT_DEFAULT, "{bad:?}");
assert_eq!(gpu_inflight_env(Some(bad)), None, "{bad:?}");
}
}
#[test]
fn auto_gpu_frames_follow_the_memory_budget() {
// #3335: Auto renders as many GPU frames at once as the budget has room for, 1 to 3.
use effectcraft_render::FrameMemory;
const GIB: u64 = 1 << 30;
let m = |budget: u64, peak: u64, frame: u64, reserve: u64| Some(FrameMemory { budget, peak, frame, reserve });
// Unknown memory (no allocator report, no budget): one frame.
assert_eq!(auto_gpu_frames(None), 1);
assert_eq!(auto_gpu_frames(m(0, GIB, GIB / 2, 0)), 1);
assert_eq!(auto_gpu_frames(m(3 * GIB, 0, GIB / 2, 0)), 1);
assert_eq!(auto_gpu_frames(m(3 * GIB, GIB, 0, 0)), 1);
// #879 on an 8 GB card: one frame peaked at 6.3 GB, over the 3 GiB budget: one.
assert_eq!(auto_gpu_frames(m(3 * GIB, 63 * GIB / 10, 5 * GIB, 0)), 1);
// Frames that had to wait for memory report the whole budget per frame: one.
assert_eq!(auto_gpu_frames(m(3 * GIB, GIB, 3 * GIB, 0)), 1);
// Room for half a frame more, or room the upload cache may still take: one.
assert_eq!(auto_gpu_frames(m(2 * GIB, 3 * GIB / 2, GIB, 0)), 1);
assert_eq!(auto_gpu_frames(m(3 * GIB, GIB, GIB / 2, 3 * GIB / 2)), 2);
assert_eq!(auto_gpu_frames(m(3 * GIB, GIB, GIB / 2, 2 * GIB)), 1);
// Room for one more frame: two; ample room: three, never more.
assert_eq!(auto_gpu_frames(m(3 * GIB, 2 * GIB, GIB, 0)), 2);
assert_eq!(auto_gpu_frames(m(3 * GIB, GIB / 2, GIB / 4, 0)), 3);
assert_eq!(auto_gpu_frames(m(u64::MAX, 1, 1, 0)), 3);
}
#[test]
fn gpu_frames_come_from_the_environment_the_setting_or_auto() {
use effectcraft_render::FrameMemory;
let ample = Some(Some(FrameMemory { budget: 3 << 30, peak: 1 << 29, frame: 1 << 28, reserve: 1 << 28 }));
let n = |env, pref, measured| resolve_gpu_frames(env, pref, measured).map(|(n, _)| n);
// Auto: unsettled until a GPU frame rendered, then measured.
assert_eq!(n(None, None, None), None);
assert_eq!(n(None, None, Some(None)), Some(1));
assert_eq!(n(None, None, ample), Some(3));
// The setting's 1, 2 or 3, without measuring.
for k in 1..=3 {
assert_eq!(n(None, Some(k), None), Some(k));
assert_eq!(n(None, Some(k), ample), Some(k));
}
// The environment overrides both; a bad value is ignored.
assert_eq!(n(Some("2"), Some(1), None), Some(2));
assert_eq!(n(Some("4"), None, ample), Some(4));
assert_eq!(n(Some("zero"), Some(1), None), Some(1));
let (_, why) = resolve_gpu_frames(None, None, ample).unwrap();
assert_eq!(why, "Auto: 256 MiB per frame, 512 MiB peak and 256 MiB cache reserve in a 3072 MiB budget");
}
/// An accelerator reporting `memory` once asked (renders nothing itself).
struct MemAccel(effectcraft_render::FrameMemory);
impl effectcraft_render::Accelerator for MemAccel {
fn name(&self) -> String {
"mem".into()
}
fn comp_frame(&self, _r: &Renderer, _comp: effectcraft_project::ItemId, _t: Tick) -> Option<Image> {
None
}
fn supports_effect(&self, _id: &str) -> bool {
false
}
fn effects(&self, _chain: &[effectcraft_render::FxStep], _buf: &effectcraft_effects::Buf, _levels: Option<f32>) -> Option<effectcraft_effects::Buf> {
None
}
fn frame_memory(&self) -> Option<effectcraft_render::FrameMemory> {
Some(self.0)
}
}
#[test]
fn auto_gpu_frames_settle_after_the_first_gpu_frame() {
// #3335: one GPU frame at a time until a frame measured the memory, then Auto's count
// for the rest of the job; the setting skips the measurement.
use effectcraft_project::render_queue::{OutputFormat, OutputModule, RenderSettings};
if std::env::var_os(GPU_INFLIGHT_ENV).is_some() || std::env::var_os(RENDERER_ENV).is_some() {
return;
}
let (settings, output) = (RenderSettings::default(), OutputModule::for_format(OutputFormat::PngSequence));
let mut p = Project::default();
p.settings.gpu_acceleration = true;
let comp = Comp::new(16, 8, effectcraft_time::FrameRate::FPS_25, Tick::from_seconds_f64(1.0));
let cid = p.add_item("Comp", effectcraft_color::Label::Sandstone, None, effectcraft_project::ItemKind::Comp(comp.into()));
let accel = MemAccel(effectcraft_render::FrameMemory { budget: 3 << 30, peak: 1 << 29, frame: 1 << 28, reserve: 0 });
for (pref, want) in [(None, 3), (Some(2), 2), (Some(1), 1)] {
let job = Job {
project: &p,
footage: &effectcraft_render::NoFootage,
expr: None,
accel: Some(&accel),
comp: cid,
settings: &settings,
output: &output,
path: "/c/f_[#####].png",
sink: None,
nested_switches: true,
options: crate::JobOptions { gpu_frames: pref, ..Default::default() },
};
let cx = Cx::new(&job);
if pref.is_none() {
assert_eq!(cx.gpu_cap(), Some(1), "before any GPU frame");
}
let c = cx.comp().unwrap();
cx.frame_on(c, 0, crate::JobRenderer::Gpu);
assert_eq!(cx.gpu_cap(), Some(want), "setting {pref:?}");
}
}
@@ -1002,7 +1532,7 @@ mod tests {
let cx = Cx::new(&job);
let comp = cx.comp().unwrap();
cx.frame(comp, 0);
let renders = cx.cache.stats().misses;
let renders = cx.caches.of(cx.backend()).stats().misses;
assert!(renders > 0);
for i in 1..10 {
let img = cx.frame(comp, i);
@@ -1012,7 +1542,7 @@ mod tests {
uncached.cache = None;
assert_eq!(img.data, uncached.comp_frame(cid, t).data, "frame {i}");
}
let stats = cx.cache.stats();
let stats = cx.caches.of(cx.backend()).stats();
assert_eq!(stats.misses, renders, "the solid rendered on the first frame only");
assert!(stats.hits >= 9, "{stats:?}");
}
+3 -1
View File
@@ -104,6 +104,8 @@ fn project() -> (Project, ItemId) {
fn run(p: &Project, cid: ItemId, accel: Option<&dyn Accelerator>, s: &RenderSettings, path: &Path) -> Vec<u8> {
let om = OutputModule { bitrate_kbps: 4000, ..OutputModule::for_format(OutputFormat::H264) };
let path = path.to_string_lossy().to_string();
// These tests are about the accelerator's own path: no measuring against the CPU (#3335).
let gpu = std::sync::Mutex::new(Some(effectcraft_render::Backend::Gpu));
let job = Job {
project: p,
footage: &NoFootage,
@@ -114,7 +116,7 @@ fn run(p: &Project, cid: ItemId, accel: Option<&dyn Accelerator>, s: &RenderSett
output: &om,
path: &path,
sink: None,
options: Default::default(),
options: effectcraft_export::JobOptions { renderer: Some(&gpu), ..Default::default() },
nested_switches: true,
};
export(&job, &mut |_| true).expect("export");
+147
View File
@@ -501,3 +501,150 @@ fn a_job_renders_on_one_compositor() {
assert_eq!(cpu.frames.load(Ordering::SeqCst), 0);
assert_eq!(c[0].get_pixel(10, 44).0, [255, 0, 0, 255], "the CPU drew the red block");
}
/// A GPU compositor stand-in that renders the frame exactly as the CPU does, after `delay`, and
/// either as it is or solid green (`green`), counting its frames.
struct TimedAccel {
delay: std::time::Duration,
green: bool,
frames: AtomicUsize,
}
impl TimedAccel {
fn new(delay_ms: u64, green: bool) -> TimedAccel {
TimedAccel { delay: std::time::Duration::from_millis(delay_ms), green, frames: AtomicUsize::new(0) }
}
}
impl Accelerator for TimedAccel {
fn name(&self) -> String {
"timed".into()
}
fn comp_frame(&self, r: &Renderer, comp: ItemId, t: Tick) -> Option<Image> {
self.frames.fetch_add(1, Ordering::SeqCst);
std::thread::sleep(self.delay);
let c = r.project.comp(comp)?;
Some(if self.green { Image::filled(c.width, c.height, [0.0, 1.0, 0.0, 1.0]) } else { r.comp_frame_cpu(comp, t) })
}
fn supports_effect(&self, _id: &str) -> bool {
false
}
fn effects(&self, _chain: &[FxStep], _buf: &Buf, _levels: Option<f32>) -> Option<Buf> {
None
}
}
/// The environment forces the compositor or the GPU frames in flight: the measured choice
/// isn't what these tests check then.
fn measurement_overridden() -> bool {
std::env::var_os(effectcraft_export::RENDERER_ENV).is_some() || std::env::var_os("EFFECTCRAFT_GPU_INFLIGHT").is_some()
}
/// [`run_on`] on a pool of 8 threads (a batch of 8 frames: room to measure both compositors on
/// the first one, whatever the machine), returning the PNGs and the render log.
fn run_pooled(p: &Project, cid: ItemId, s: &RenderSettings, path: &str, options: JobOptions, accel: &dyn Accelerator) -> (Vec<image::RgbaImage>, String) {
let om = OutputModule::for_format(OutputFormat::PngSequence);
let pool = rayon::ThreadPoolBuilder::new().num_threads(8).build().unwrap();
let options = JobOptions { log: RenderLog::PlusSettings, ..options };
let (r, files) = pool.install(|| run_on(p, cid, s, &om, path, options, (Some(accel), false)));
let n = r.expect("export").frames as usize;
let log = files.lock().unwrap().iter().find(|(p, _)| p.ends_with("_RenderLog.txt")).map(|(_, d)| String::from_utf8(d.clone()).unwrap()).expect("log");
((0..n).map(|i| png(&files, i)).collect(), log)
}
#[test]
fn a_transfer_bound_gpu_job_renders_on_the_cpu() {
// #3335: a GPU project whose GPU frames are slower than the CPU's renders the whole job on
// the CPU's frame-parallel compositor, measured on its first frames, and says so in the log.
if measurement_overridden() {
return;
}
let (mut p, cid) = project(FrameRate::new(25, 1), |_, _| {});
p.settings.gpu_acceleration = true;
let s = custom(0.0, 0.8);
let slow = TimedAccel::new(60, true);
let (frames, log) = run_pooled(&p, cid, &s, "/r/a_[#####].png", JobOptions::default(), &slow);
assert_eq!(frames.len(), 20);
// The GPU rendered its warm-up and timed frames only, and none of them is in the output.
assert_eq!(slow.frames.load(Ordering::SeqCst), 3);
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: 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() };
let slow = TimedAccel::new(60, true);
run_pooled(&p, cid, &s, "/r/b_[#####].png", opts(), &slow);
assert_eq!(*shared.lock().unwrap(), Some(effectcraft_render::Backend::Cpu));
let again = TimedAccel::new(0, true);
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: Software Rendering (as the queue item's first output module)"), "{log}");
}
#[test]
fn a_gpu_job_that_measures_no_faster_on_the_cpu_stays_on_the_gpu() {
if measurement_overridden() {
return;
}
// A slow CPU (a large comp of eight rotating, translucent full-frame layers, composited
// anew every frame) against a GPU that only fills the frame: every frame is the GPU's.
let (mut p, cid) = project(FrameRate::new(25, 1), |p, comp| {
comp.width = 1920;
comp.height = 1080;
let big = p.add_item("Big", Label::Red, None, ItemKind::Solid(Solid { color: [0.2, 0.4, 0.6], width: 1920, height: 1080, pixel_aspect: 1.0 }));
for k in 0..8 {
let mut l = build::layer(p, comp, "Big", LayerSource::Solid { item: big }, (1920, 1080), None);
l.props.prop_mut("transform/rotation").unwrap().keys =
vec![Keyframe::new(Tick::ZERO, Value::Scalar(k as f64)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(90.0 + k as f64))];
l.props.prop_mut("transform/opacity").unwrap().value = Value::Scalar(50.0);
comp.layers.insert(0, l);
}
});
p.settings.gpu_acceleration = true;
let s = custom(0.0, 0.6);
let fast = TimedAccel::new(0, true);
let (frames, log) = run_pooled(&p, cid, &s, "/r/d_[#####].png", JobOptions::default(), &fast);
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: GPU Acceleration (measured on the first frames: GPU "), "{log}");
}
#[test]
fn gpu_cpu_and_measured_jobs_write_the_same_frames() {
// #3335: where the two compositors agree, a job is the same whichever renders it: the GPU
// (forced), the CPU (Software Only) or the measured choice.
if measurement_overridden() {
return;
}
let (mut p, cid) = project(FrameRate::new(25, 1), |_, _| {});
let s = custom(0.0, 0.8);
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: 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);
let (auto, _) = run_pooled(&p, cid, &s, "/i/c_[#####].png", JobOptions::default(), &echo);
assert_eq!(cpu.len(), 20);
assert_eq!(cpu, gpu);
assert_eq!(cpu, auto);
}
#[test]
fn renderer_choice_follows_the_measurement() {
use effectcraft_export::{JobRenderer, pick_renderer};
assert_eq!(pick_renderer(Some(117.0), Some(68.0)), JobRenderer::Cpu);
assert_eq!(pick_renderer(Some(68.0), Some(117.0)), JobRenderer::Gpu);
// Ties and noise stay on the GPU: the CPU must be clearly faster.
assert_eq!(pick_renderer(Some(100.0), Some(90.0)), JobRenderer::Gpu);
assert_eq!(pick_renderer(Some(100.0), Some(80.0)), JobRenderer::Cpu);
// Unmeasured or nonsense: the GPU, unless the GPU stopped while measuring.
assert_eq!(pick_renderer(None, None), JobRenderer::Gpu);
assert_eq!(pick_renderer(Some(f64::NAN), Some(1.0)), JobRenderer::Gpu);
assert_eq!(pick_renderer(None, Some(50.0)), JobRenderer::Cpu);
}
+1 -1
View File
@@ -801,7 +801,7 @@ fn read_buffer(e: &mut Enc, buf: &wgpu::Buffer, size: u64) -> Option<Vec<u8>> {
});
e.record(&[]).copy_buffer_to_buffer(buf, 0, &rb, 0, size);
e.submit();
let out = e.g.read_buffer(&rb, row, row, 1).map_err(|e| log::error!("gpu 3d readback: {e}")).ok()?;
let out = e.g.read_buffer_after(&rb, e.last_submission(), row, row, 1).map_err(|e| log::error!("gpu 3d readback: {e}")).ok()?;
Some(out)
}
+227 -41
View File
@@ -128,6 +128,11 @@ const POOL_BUDGET: usize = 256 << 20;
/// miss (a new allocation), never a wrong pixel.
const STALE_FRAMES: u64 = 2;
/// The pool owner of uploaded textures: never registered by an [`Enc`] (their ids start at 1),
/// so an upload takes only from the shared free list ([`Pool::take`], textures no open encoder
/// can read) and gives its texture back through the guarded [`Pool::release`].
const UPLOAD_OWNER: u64 = 0;
/// Readback staging buffers kept for reuse across sizes (bytes).
const STAGING_BUDGET: u64 = 256 << 20;
@@ -693,6 +698,10 @@ pub struct GpuContext {
dummy_tex: wgpu::TextureView,
dummy_buf: wgpu::Buffer,
uploads: Mutex<Uploads>,
/// Uploaded footage pictures, shared by the layers that show them (see [`crate::upload`]).
pictures: Mutex<crate::upload::Pictures>,
/// Reused staging buffers of large uploads (see [`crate::upload::Staging`]).
upload_staging: crate::upload::Staging,
upload_budget: std::sync::atomic::AtomicUsize,
upload_full_warned: std::sync::atomic::AtomicBool,
pool: Arc<Mutex<Pool>>,
@@ -1017,6 +1026,8 @@ impl GpuContext {
dummy,
dummy_buf,
uploads: Mutex::new(Uploads::default()),
pictures: Default::default(),
upload_staging: Default::default(),
upload_budget: std::sync::atomic::AtomicUsize::new(UPLOAD_BUDGET),
upload_full_warned: Default::default(),
pool,
@@ -1124,6 +1135,24 @@ impl GpuContext {
}
}
/// The memory budget (the memory cap: a conservative share of the device, see `memcap`),
/// the allocator's peak, one frame's share of it and what the upload cache may still add.
/// `None` where any is unknown: no allocator report (Metal, WebGPU, GL) or the cap is off.
pub fn frame_memory(&self) -> Option<effectcraft_render::FrameMemory> {
let budget = self.memcap.cap();
let now = self.allocated_bytes();
self.memcap.note(now);
// The allocator's peak (sampled while frames render) holds a frame's working textures
// at their most; what is gone again after the frame (and at least what the pool keeps
// for the next) is one frame's share. Frames that had to wait for memory: no room.
let peak = self.memcap.peak();
let frame = if self.memcap.pressed() { budget } else { peak.saturating_sub(now).max(self.pool_retained_bytes() as u64) };
// The upload cache may still fill up to its budget.
let uploads = self.uploads.lock().map(|u| u.bytes).unwrap_or(0);
let reserve = self.upload_budget().saturating_sub(uploads) as u64;
(budget > 0 && peak > 0 && frame > 0).then_some(effectcraft_render::FrameMemory { budget, peak, frame, reserve })
}
/// Bytes held by wgpu's device allocator (`0` on backends that do not report).
pub(crate) fn allocated_bytes(&self) -> u64 {
self.device.generate_allocator_report().map_or(0, |r| r.total_allocated_bytes)
@@ -1132,11 +1161,11 @@ impl GpuContext {
/// Bytes held in long-lived or still-referenced GPU caches/leases that submitting and waiting
/// on `owner` (`closing_enc`) cannot free.
fn kept_bytes(&self, owner: u64, closing_enc: Option<u64>) -> u64 {
let uploads = self.uploads.lock().map(|u| u.bytes as u64).unwrap_or(0);
// (uploaded layer and footage textures are pooled: the pool's leases count them)
let pool = self.pool.lock().map(|p| p.kept_after_flush(owner, closing_enc) as u64).unwrap_or(0);
let staging: u64 = self.staging.lock().map(|s| s.iter().map(|(k, v)| k * v.len() as u64).sum()).unwrap_or(0);
let zeros: u64 = self.zeros.lock().map(|z| z.keys().map(|&(w, h)| w as u64 * h as u64 * 16).sum()).unwrap_or(0);
uploads + pool + staging + zeros
pool + staging + self.upload_staging.bytes() + zeros
}
#[cfg(test)]
@@ -1198,18 +1227,99 @@ impl GpuContext {
/// never consumes the readback deadline or holds the mutex across the whole timeout.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn read_buffer(&self, buffer: &wgpu::Buffer, row: usize, stride: usize, height: usize) -> Result<Vec<u8>, String> {
self.read_buffer_after(buffer, None, row, stride, height)
}
/// [`Self::read_buffer`] waiting only for `submission` (the one that wrote `buffer`), not
/// for every submission so far: another frame's GPU work submitted meanwhile doesn't hold
/// this readback up (#3335). `None`: wait for all submitted work.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn read_buffer_after(
&self,
buffer: &wgpu::Buffer,
submission: Option<wgpu::SubmissionIndex>,
row: usize,
stride: usize,
height: usize,
) -> Result<Vec<u8>, String> {
self.read_mapped(buffer, submission, |view| crate::readback::packed(view, row, stride, height))
}
/// Map `buffer`, wait for the mapping (only for `submission` when given) and hand the
/// mapped bytes to `read`, on this thread, before unmapping: `read` copies the rows
/// straight into their destination, once (#3335). The copy happens outside `poll_lock`, so
/// it doesn't hold up other threads' readbacks the way copying inside the map callback
/// (which runs during a device poll) would.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn read_mapped<R>(
&self,
buffer: &wgpu::Buffer,
submission: Option<wgpu::SubmissionIndex>,
read: impl FnOnce(&[u8]) -> Result<R, String>,
) -> Result<R, String> {
let (tx, rx) = std::sync::mpsc::channel();
self.map_readback(buffer, row, stride, height, move |r| {
let ticket = self.readbacks.start(move |r| {
let _ = tx.send(r);
})?;
if !ticket.active() {
return Err("GPU readback cancelled".into());
}
let b = buffer.clone();
buffer.map_async(wgpu::MapMode::Read, .., move |r| {
// Timed out or cancelled: nobody reads the mapping.
if !ticket.active() {
b.unmap();
return;
}
// Mapped: the waiting thread reads the rows and unmaps (an empty vector = mapped).
ticket.complete(r.map(|_| Vec::new()).map_err(|e| format!("GPU buffer mapping: {e}")));
});
// On any failure, unmap (harmless when not mapped; cancels a pending map) so a mapped
// buffer can never be handed out again.
let fail = |e: String| {
buffer.unmap();
e
};
self.wait_mapped(&rx, submission).map_err(fail)?.map_err(fail)?;
let result = {
let view = buffer.get_mapped_range(..).map_err(|e| format!("GPU mapped range: {e}"));
view.and_then(|view| read(&view))
};
buffer.unmap();
result
}
/// Poll until `rx` reports the mapping (see [`Self::read_mapped`]): polling and receiving
/// share one deadline, started once `poll_lock` is first acquired and polled in
/// [`Self::POLL_SLICE`] slices so lock contention never consumes the readback deadline or
/// holds the mutex across the whole timeout. While another thread holds `poll_lock` it is
/// polling the device, which delivers this mapping too: wait for it instead of queueing.
#[cfg(not(target_arch = "wasm32"))]
fn wait_mapped(
&self,
rx: &std::sync::mpsc::Receiver<Result<Vec<u8>, String>>,
submission: Option<wgpu::SubmissionIndex>,
) -> Result<Result<Vec<u8>, String>, String> {
let mut deadline = None;
loop {
self.readbacks.check()?;
if let Ok(result) = rx.try_recv() {
return result;
return Ok(result);
}
let (remaining, slice, poll_res) = {
let _guard = self.poll_lock.lock().unwrap_or_else(|e| e.into_inner());
let _guard = match self.poll_lock.try_lock() {
Ok(g) => g,
Err(std::sync::TryLockError::Poisoned(e)) => e.into_inner(),
Err(std::sync::TryLockError::WouldBlock) => {
match rx.recv_timeout(std::time::Duration::from_millis(1)) {
Ok(result) => return Ok(result),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
Err(e) => return Err(format!("GPU readback completion channel: {e}")),
}
// The ticket's own deadline still bounds a wait that never gets the lock.
continue;
}
};
self.readbacks.check()?;
let d = *deadline.get_or_insert_with(|| std::time::Instant::now() + crate::readback::TIMEOUT);
let remaining = d.saturating_duration_since(std::time::Instant::now());
@@ -1217,12 +1327,12 @@ impl GpuContext {
(remaining, std::time::Duration::ZERO, Ok(wgpu::PollStatus::Poll))
} else {
let slice = remaining.min(Self::POLL_SLICE);
let res = self.device.poll(wgpu::PollType::Wait { submission_index: None, timeout: Some(slice) });
let res = self.device.poll(wgpu::PollType::Wait { submission_index: submission.clone(), timeout: Some(slice) });
(remaining, slice, res)
}
};
if let Ok(result) = rx.try_recv() {
return result;
return Ok(result);
}
if remaining.is_zero() {
let error = "GPU readback receive timed out; context retired".to_string();
@@ -1241,7 +1351,7 @@ impl GpuContext {
}
// The callback may be running on another thread that polled the same device.
match rx.recv_timeout(remaining.min(std::time::Duration::from_millis(1))) {
Ok(result) => return result,
Ok(result) => return Ok(result),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
Err(e) => return Err(format!("GPU readback completion channel: {e}")),
}
@@ -1253,6 +1363,28 @@ impl GpuContext {
Err("synchronous GPU readback is unavailable in the browser".into())
}
#[cfg(target_arch = "wasm32")]
pub(crate) fn read_buffer_after(
&self,
_buffer: &wgpu::Buffer,
_submission: Option<wgpu::SubmissionIndex>,
_row: usize,
_stride: usize,
_height: usize,
) -> Result<Vec<u8>, String> {
Err("synchronous GPU readback is unavailable in the browser".into())
}
#[cfg(target_arch = "wasm32")]
pub(crate) fn read_mapped<R>(
&self,
_buffer: &wgpu::Buffer,
_submission: Option<wgpu::SubmissionIndex>,
_read: impl FnOnce(&[u8]) -> Result<R, String>,
) -> Result<R, String> {
Err("synchronous GPU readback is unavailable in the browser".into())
}
/// Switch to deferred readbacks (see [`crate::deferred`]): readbacks are never waited for.
pub fn set_deferred(&mut self, on: bool) {
self.readbacks.cancel_pending("GPU readback mode changed".into());
@@ -1446,6 +1578,8 @@ impl GpuContext {
dummy: self.dummy.clone(),
dummy_buf: self.dummy_buf.clone(),
uploads: Mutex::new(Uploads::default()),
pictures: Default::default(),
upload_staging: Default::default(),
upload_budget: std::sync::atomic::AtomicUsize::new(UPLOAD_BUDGET),
upload_full_warned: Default::default(),
pool: self.pool.clone(),
@@ -1625,16 +1759,54 @@ impl GpuContext {
return None;
}
self.transfers.count(true, img.data.len() * 16);
let g = self.image(img.width, img.height);
self.queue.write_texture(
wgpu::TexelCopyTextureInfo { texture: &g.texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All },
bytemuck::cast_slice(&img.data),
wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(img.width * 16), rows_per_image: Some(img.height) },
wgpu::Extent3d { width: img.width, height: img.height, depth_or_array_layers: 1 },
);
// A texture from the pool's shared free list: no open encoder can still read it, and the
// copy below runs before anything recorded after this call.
let (g, _) = self.pooled(img.width, img.height, UPLOAD_OWNER);
if !self.upload_staging.write(&self.device, &self.queue, img, &g.texture) {
self.queue.write_texture(
wgpu::TexelCopyTextureInfo { texture: &g.texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All },
bytemuck::cast_slice(&img.data),
wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(img.width * 16), rows_per_image: Some(img.height) },
wgpu::Extent3d { width: img.width, height: img.height, depth_or_array_layers: 1 },
);
}
Some(g)
}
/// Upload a decoded footage picture once while the footage source keeps it: every layer
/// and effect input showing the same frame shares the texture (`Renderer::footage_picture`).
pub(crate) fn upload_picture(&self, img: &Arc<Image>) -> Option<GpuImage> {
if let Some(t) = self.pictures.lock().ok().and_then(|mut p| p.get(img)) {
return Some(t);
}
let tex = self.upload_image(img)?;
if let Ok(mut p) = self.pictures.lock() {
p.insert(img, tex.clone(), crate::upload::PICTURE_BUDGET.min(self.upload_budget() / 4));
}
Some(tex)
}
/// Entries in the footage picture cache.
#[cfg(test)]
pub(crate) fn picture_entries(&self) -> usize {
self.pictures.lock().map(|p| p.len()).unwrap_or_default()
}
/// The upload staging buffers (tests).
#[cfg(test)]
pub(crate) fn upload_staging(&self) -> &crate::upload::Staging {
&self.upload_staging
}
/// A render is done: drop the footage picture textures and the upload staging buffers
/// (the next render's pictures upload again; their textures come from the pool).
pub fn trim_uploads(&self) {
if let Ok(mut p) = self.pictures.lock() {
p.clear();
}
self.upload_staging.clear();
}
/// Entries in the layer upload cache.
#[cfg(test)]
pub(crate) fn upload_entries(&self) -> usize {
@@ -1696,6 +1868,10 @@ impl GpuContext {
u.map.clear();
u.bytes = 0;
}
if let Ok(mut p) = self.pictures.lock() {
p.clear();
}
self.upload_staging.clear();
if let Ok(mut p) = self.pool.lock() {
p.clear();
}
@@ -1952,6 +2128,11 @@ impl<'g> Enc<'g> {
dst
}
/// The last submission of this encoder's work (what a readback after it waits for).
pub(crate) fn last_submission(&self) -> Option<wgpu::SubmissionIndex> {
self.last_submit.clone()
}
/// Submit recorded work.
pub fn submit(&mut self) {
if let Some((enc, id)) = self.enc.take() {
@@ -1970,6 +2151,7 @@ impl<'g> Enc<'g> {
return;
}
let allocated = self.g.allocated_bytes();
self.g.memcap.note(allocated);
if allocated <= self.g.memcap.cap() {
let _ = self.g.memcap.should_wait(allocated, 0);
return;
@@ -1994,13 +2176,13 @@ impl<'g> Enc<'g> {
let layout = crate::readback::Layout::new(img.width, img.height, 16, self.g.device.limits().max_buffer_size)
.map_err(|e| log::error!("gpu image readback: {e}"))
.ok()?;
let mut data = Vec::new();
data.try_reserve_exact(layout.len / 16).map_err(|e| log::error!("gpu image allocation: {e}")).ok()?;
data.resize(layout.len / 16, [0.0; 4]);
let mut out = Image { width: img.width, height: img.height, data };
let dst: &mut [u8] = bytemuck::cast_slice_mut(&mut out.data);
self.read_texture_into(&img.texture, [0, 0, img.width, img.height], 16, dst)?;
Some(out)
// The mapped rows go straight into the image's pixels: one copy (#3335).
let data: Vec<[f32; 4]> = self.read_texture_with(&img.texture, [0, 0, img.width, img.height], 16, crate::readback::rows::<[f32; 4]>)?;
if data.len() != layout.len / 16 {
log::error!("gpu image readback size mismatch");
return None;
}
Some(Image { width: img.width, height: img.height, data })
}
/// Tightly packed texel bytes of a texture (`bpp` bytes per pixel).
@@ -2013,27 +2195,32 @@ impl<'g> Enc<'g> {
pub fn read_rect(&mut self, texture: &wgpu::Texture, x: u32, y: u32, w: u32, h: u32, bpp: u32) -> Option<Vec<u8>> {
let layout =
crate::readback::Layout::new(w, h, bpp, self.g.device.limits().max_buffer_size).map_err(|e| log::error!("gpu texture readback: {e}")).ok()?;
let mut out = Vec::new();
out.try_reserve_exact(layout.len).map_err(|e| log::error!("gpu readback allocation: {e}")).ok()?;
out.resize(layout.len, 0);
self.read_texture_into(texture, [x, y, w, h], bpp, &mut out)?;
let out: Vec<u8> = self.read_texture_with(texture, [x, y, w, h], bpp, crate::readback::rows::<u8>)?;
if out.len() != layout.len {
log::error!("gpu texture readback size mismatch");
return None;
}
Some(out)
}
/// [`Enc::read_rect`] into `out` (`w × bpp × h` bytes), through a reused staging buffer.
/// `rect` is `[x, y, w, h]`.
fn read_texture_into(&mut self, texture: &wgpu::Texture, rect: [u32; 4], bpp: u32, out: &mut [u8]) -> Option<()> {
/// Read `rect` (`[x, y, w, h]`) of `texture` back through a reused staging buffer: `read`
/// gets the mapped bytes, the row length, the padded row stride and the row count, and
/// copies the rows into their destination itself (one copy, #3335). Waits only for this
/// encoder's own submission, so frames rendering on other threads don't delay it.
fn read_texture_with<R>(
&mut self,
texture: &wgpu::Texture,
rect: [u32; 4],
bpp: u32,
read: impl FnOnce(&[u8], usize, usize, usize) -> Result<R, String>,
) -> Option<R> {
let [x, y, w, h] = rect;
if !self.g.can_wait() {
return None;
}
self.g.transfers.count(false, out.len());
let layout =
crate::readback::Layout::new(w, h, bpp, self.g.device.limits().max_buffer_size).map_err(|e| log::error!("gpu texture readback: {e}")).ok()?;
if out.len() != layout.len {
log::error!("gpu readback destination size mismatch");
return None;
}
self.g.transfers.count(false, layout.len);
let row = layout.row as usize;
let padded = layout.stride;
let size = layout.size;
@@ -2051,12 +2238,11 @@ impl<'g> Enc<'g> {
wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
);
self.submit();
let bytes = self.g.read_buffer(&buf, row, padded as usize, h as usize).map_err(|e| log::error!("gpu texture readback: {e}")).ok()?;
if bytes.len() != out.len() {
log::error!("gpu texture readback size mismatch");
return None;
}
out.copy_from_slice(&bytes);
let out = self
.g
.read_mapped(&buf, self.last_submit.clone(), |view| read(view, row, padded as usize, h as usize))
.map_err(|e| log::error!("gpu texture readback: {e}"))
.ok()?;
// Keep a few staging buffers per size (frames read back at the same size every time).
// Layers whose bounds change every frame read back at a new size each time: drop the
// other sizes once over budget, or mapped host memory grows for the whole render (#2081).
@@ -2070,7 +2256,7 @@ impl<'g> Enc<'g> {
v.push(buf);
}
}
Some(())
Some(out)
}
/// [`Enc::read_texture`] without waiting for the GPU (the browser's main thread can't): `done`
+7
View File
@@ -84,6 +84,7 @@ mod memcap;
mod ops;
mod particles;
mod readback;
mod upload;
mod walk;
mod yuv;
@@ -432,6 +433,11 @@ impl Accelerator for Gpu {
Some(&self.auto)
}
fn frame_memory(&self) -> Option<effectcraft_render::FrameMemory> {
self.ctx.check_health().ok()?;
self.ctx.frame_memory()
}
fn render_end(&self) {
let (hits, misses) = self.ctx.pool_stats();
let pct = hits.saturating_mul(100) / hits.saturating_add(misses).max(1);
@@ -439,6 +445,7 @@ impl Accelerator for Gpu {
target: "effectcraft_gpu",
"gpu pool: {hits} working textures reused, {misses} allocated ({pct}% reuse) so far"
);
self.ctx.trim_uploads();
self.ctx.trim_pool();
}
+19
View File
@@ -40,6 +40,8 @@ pub(crate) struct MemCap {
floor: AtomicU64,
/// Set after the first over-cap sample where non-reclaimable memory alone exceeds the cap.
warned: AtomicBool,
/// The most the allocator held in any sample (what frames in flight are sized by, #3335).
peak: AtomicU64,
#[cfg(test)]
timeout_ns: AtomicU64,
}
@@ -59,6 +61,7 @@ impl MemCap {
waits: AtomicU64::new(0),
floor: AtomicU64::new(0),
warned: AtomicBool::new(false),
peak: AtomicU64::new(0),
#[cfg(test)]
timeout_ns: AtomicU64::new(crate::readback::TIMEOUT.as_nanos() as u64),
}
@@ -74,6 +77,7 @@ impl MemCap {
waits: AtomicU64::new(0),
floor: AtomicU64::new(0),
warned: AtomicBool::new(false),
peak: AtomicU64::new(0),
timeout_ns: AtomicU64::new(self.timeout_ns.load(Relaxed)),
}
}
@@ -82,6 +86,21 @@ impl MemCap {
self.cap
}
/// Remember an allocator sample: the peak only grows.
pub(crate) fn note(&self, allocated: u64) {
self.peak.fetch_max(allocated, Relaxed);
}
/// The most the allocator held in any sample so far.
pub(crate) fn peak(&self) -> u64 {
self.peak.load(Relaxed)
}
/// Whether frames have had to wait for memory (the device ran over the cap).
pub(crate) fn pressed(&self) -> bool {
self.waits.load(Relaxed) > 0
}
/// Whether to sample the allocator on this call.
pub(crate) fn due(&self) -> bool {
self.cap != 0 && self.calls.fetch_add(1, Relaxed).is_multiple_of(EVERY)
+41 -5
View File
@@ -367,20 +367,32 @@ thread_local! {
/// Validate every row before publishing tightly packed bytes.
pub(crate) fn packed(view: &[u8], row: usize, stride: usize, height: usize) -> Result<Vec<u8>, Error> {
if row == 0 || height == 0 || stride < row {
rows(view, row, stride, height)
}
/// The `height` rows of `row` bytes each, `stride` bytes apart in the mapped `view`, as tightly
/// packed `T`s: one copy, straight from the mapped rows into a vector reserved once (no
/// zero-filled buffer first, no intermediate byte vector). Every row is validated before any
/// is copied. A row that is not aligned for `T` is decoded value by value (same bytes).
pub(crate) fn rows<T: bytemuck::Pod>(view: &[u8], row: usize, stride: usize, height: usize) -> Result<Vec<T>, Error> {
let unit = std::mem::size_of::<T>().max(1);
if row == 0 || height == 0 || stride < row || !row.is_multiple_of(unit) {
return Err("invalid GPU readback layout".into());
}
let size = row.checked_mul(height).ok_or("GPU readback output size overflow")?;
let size = row.checked_mul(height).ok_or("GPU readback output size overflow")? / unit;
let end = (height - 1).checked_mul(stride).and_then(|s| s.checked_add(row)).ok_or("GPU readback mapped size overflow")?;
if view.len() < end {
return Err(format!("short GPU readback: {} bytes, needs {end}", view.len()));
}
let mut out = Vec::new();
let mut out: Vec<T> = Vec::new();
out.try_reserve_exact(size).map_err(|e| format!("allocating GPU readback output: {e}"))?;
for y in 0..height {
let start = y.checked_mul(stride).ok_or("GPU readback row overflow")?;
let bytes = view.get(start..start + row).ok_or("short GPU readback row")?;
out.extend_from_slice(bytes);
let bytes = start.checked_add(row).and_then(|e| view.get(start..e)).ok_or("short GPU readback row")?;
match bytemuck::try_cast_slice::<u8, T>(bytes) {
Ok(values) => out.extend_from_slice(values),
Err(_) => out.extend(bytes.chunks_exact(unit).map(bytemuck::pod_read_unaligned::<T>)),
}
}
Ok(out)
}
@@ -658,4 +670,28 @@ mod tests {
assert!(packed(&[0; 7], 4, 4, 2).is_err());
assert_eq!(packed(&[1, 2, 0, 0, 3, 4], 2, 4, 2).unwrap(), vec![1, 2, 3, 4]);
}
/// #3335: rows go from the mapped buffer straight into the destination type, skipping the
/// row padding, whether or not the mapped bytes are aligned for it.
#[test]
fn rows_copy_once_into_the_destination_type() {
// Two rows of two RGBA f32 pixels, padded to a 48-byte stride.
let px = |i: f32| [i, i + 0.25, i + 0.5, 1.0];
let want = vec![px(1.0), px(2.0), px(3.0), px(4.0)];
let mut view = Vec::new();
for r in want.chunks(2) {
view.extend_from_slice(bytemuck::cast_slice(r));
view.extend_from_slice(&[0xAB; 16]);
}
assert_eq!(rows::<[f32; 4]>(&view, 32, 48, 2).unwrap(), want);
// The same bytes one byte off any f32 alignment decode the same.
let mut shifted = vec![0u8];
shifted.extend_from_slice(&view);
assert_eq!(rows::<[f32; 4]>(&shifted[1..], 32, 48, 2).unwrap(), want);
// A row that isn't whole pixels, a short last row and a stride under the row fail.
assert!(rows::<[f32; 4]>(&view, 30, 48, 2).is_err());
assert!(rows::<[f32; 4]>(&view[..view.len() - 17], 32, 48, 2).is_err());
assert!(rows::<[f32; 4]>(&view, 32, 16, 2).is_err());
assert_eq!(rows::<u8>(&[1, 2, 9, 3, 4], 2, 3, 2).unwrap(), vec![1, 2, 3, 4]);
}
}
+45
View File
@@ -28,8 +28,10 @@ use crate::Gpu;
mod adjust;
mod adv3d;
mod bench_share;
mod deferred;
mod deferred_jobs;
mod footage_share;
mod fx_color;
mod fx_depth;
mod fx_distort;
@@ -1247,6 +1249,49 @@ fn readback_staging_stays_bounded_across_sizes() {
assert!(held <= (256 << 20) + 2 * 1024 * 16 * 376, "staging buffers hold {held} bytes");
}
/// #3335: a readback copies the mapped rows straight into the image (no zero-filled buffer,
/// no intermediate byte vector), skipping each row's padding: odd widths, sub-rectangles and
/// readbacks from several threads at once (each waiting for its own submission) return the
/// uploaded pixels exactly.
#[test]
fn single_copy_readback_returns_the_exact_pixels() {
let Some(g) = gpu() else { return };
if !g.ctx.can_wait() {
return;
}
let image = |w: u32, h: u32, seed: f32| {
let mut img = Image::new(w, h);
for (i, p) in img.data.iter_mut().enumerate() {
let v = i as f32 * 0.37 + seed;
*p = [v, -v, v * 1e-3, 1.0 / (1.0 + v)];
}
img
};
// 37 × 16 bytes = 592 per row: padded to 768 in the staging buffer.
let src = image(37, 5, 0.5);
let up = g.ctx.upload_image(&src).expect("upload");
let mut e = crate::context::Enc::new(&g.ctx);
let back = e.download(&up).expect("download");
assert_eq!((back.width, back.height), (37, 5));
assert_eq!(back.data, src.data);
let rect = e.read_rect(&up.texture, 3, 1, 5, 2, 16).expect("read_rect");
let want: Vec<[f32; 4]> = (1..3).flat_map(|y| (3..8).map(move |x| (x, y))).map(|(x, y)| src.data[src.idx(x, y)]).collect();
assert_eq!(rect, bytemuck::cast_slice::<[f32; 4], u8>(&want));
// Frames on several threads, each with its own encoder and submission.
std::thread::scope(|s| {
for t in 0..4 {
s.spawn(move || {
for k in 0..3 {
let src = image(61 + t, 7 + k, (t * 10 + k) as f32);
let up = g.ctx.upload_image(&src).expect("upload");
let back = crate::context::Enc::new(&g.ctx).download(&up).expect("download");
assert_eq!(back.data, src.data, "thread {t} frame {k}");
}
});
}
});
}
/// A static adjustment layer with a time-dependent effect (Noise) keeps its footprint key
/// across frames (the footprint is the source through the masks, not the effects), so the GPU
/// uploads nothing after the first frame; matches the CPU.
+83
View File
@@ -0,0 +1,83 @@
//! Warm-frame benchmark of #3333 (run by hand): three layers of the same 1080p footage frame,
//! 32 bpc, a 30-frame sequence already decoded (as the media pool serves a warm frame), with a
//! layer cache as the Render Queue has. Prints the median and mean ms per frame and a hash of
//! every frame (to compare the pixels of two builds).
//!
//! `cargo test -p effectcraft-gpu --release --lib bench_share -- --ignored --nocapture`
use std::collections::HashMap;
use std::sync::Mutex;
use super::*;
struct Decoded {
frames: Mutex<HashMap<i64, Arc<Image>>>,
}
impl FootageSource for Decoded {
fn frame(&self, item: ItemId, f: &Footage, t: Tick) -> Option<Arc<Image>> {
let i = f.frame_rate.frame_at(t).clamp(0, 29);
let mut m = self.frames.lock().unwrap();
Some(m.entry(i).or_insert_with(|| Arc::new(pattern(item.0 as u32 + i as u32, f.width, f.height))).clone())
}
}
#[test]
#[ignore]
fn bench_share() {
let Some(g) = gpu() else { return };
let (w, h) = (1920, 1080);
let mut p = Project::default();
p.settings.bit_depth = BitDepth::Bpc32;
let comp = Comp::new(w, h, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
let f = Footage {
path: "seq.exr".into(),
kind: FootageKind::Sequence,
width: w,
height: h,
pixel_aspect: 1.0,
frame_rate: FrameRate::FPS_30,
duration: Tick::from_seconds_f64(1.0),
has_video: true,
alpha: AlphaMode::Premultiplied,
..Default::default()
};
let fid = p.add_item("Sequence", Label::Lavender, None, ItemKind::Footage(f));
for i in 0..3 {
let mut l = build::layer(&mut p, &comp, "Sequence", LayerSource::Footage { item: fid }, (w, h), None);
if i > 0 {
l.blend_mode = BlendMode::Add;
set(&mut l, "transform/opacity", Value::Scalar(25.0));
}
p.comp_mut(cid).unwrap().layers.insert(0, l);
}
let src = Decoded { frames: Mutex::new(HashMap::new()) };
let frames = 30;
let mut runs = vec![];
let mut hashes = vec![];
for pass in 0..4 {
let cache = effectcraft_render::cache::LayerCache::new(1 << 30);
let mut r = Renderer::new(&p, &src, RenderOpts { backend: Backend::Gpu, ..Default::default() });
r.accel = Some(g);
r.cache = Some(&cache);
for k in 0..frames {
let t0 = std::time::Instant::now();
let img = g.render(&r, cid, Tick::from_seconds_f64(k as f64 / 30.0)).expect("the GPU renders the frame");
let ms = t0.elapsed().as_secs_f64() * 1e3;
if pass > 0 {
runs.push(ms);
}
if pass == 1 {
use std::hash::{Hash, Hasher};
let mut hs = std::collections::hash_map::DefaultHasher::new();
img.data.iter().for_each(|px| px.map(f32::to_bits).hash(&mut hs));
hashes.push(hs.finish());
}
}
}
runs.sort_by(f64::total_cmp);
let mean = runs.iter().sum::<f64>() / runs.len() as f64;
eprintln!("bench_share: {} frames, median {:.1} ms, mean {:.1} ms, min {:.1} ms", runs.len(), runs[runs.len() / 2], mean, runs[0]);
eprintln!("bench_share frame hashes: {hashes:016x?}");
}
+233
View File
@@ -0,0 +1,233 @@
//! #3333: a footage frame uploads once per frame and its texture is shared by every layer and
//! effect input that shows it; the frame is bit-identical to drawing each layer from its own copy.
use std::collections::HashMap;
use std::sync::Mutex;
use super::*;
use crate::walk::SHARED_PICTURES;
/// Keeps decoded frames as the media pool does: the same frame (at the same size) of the same
/// footage is the same `Arc` while kept. Counts the frames it decodes.
#[derive(Default)]
struct Kept {
frames: Mutex<HashMap<(ItemId, u32, u32, i64, Option<(u32, u32)>), Arc<Image>>>,
}
impl Kept {
fn get(&self, item: ItemId, f: &Footage, t: Tick, size: Option<(u32, u32)>) -> Option<Arc<Image>> {
let i = f.frame_rate.frame_at(t);
let mut m = self.frames.lock().unwrap();
let img = m.entry((item, f.width, f.height, i, size)).or_insert_with(|| {
let full = pattern(item.0 as u32 + i as u32 * 7 + f.width, f.width, f.height);
Arc::new(match size {
Some((w, h)) => effectcraft_raster::resample(&full, w, h),
None => full,
})
});
Some(img.clone())
}
}
impl FootageSource for Kept {
fn frame(&self, item: ItemId, f: &Footage, t: Tick) -> Option<Arc<Image>> {
self.get(item, f, t, None)
}
fn frame_at_size(&self, item: ItemId, f: &Footage, t: Tick, width: u32, height: u32) -> Option<Arc<Image>> {
self.get(item, f, t, Some((width, height)))
}
}
/// One footage item (a sequence: a new picture every frame) shown by `layers` layers, each
/// placed differently (the first fills the comp).
fn shared_scene(depth: BitDepth, layers: usize) -> (Scene, ItemId) {
let mut s = Scene::new(depth);
let f = Footage {
path: "seq.exr".into(),
kind: FootageKind::Sequence,
width: 97,
height: 61,
pixel_aspect: 1.0,
frame_rate: FrameRate::FPS_30,
duration: Tick::from_seconds_f64(2.0),
has_video: true,
alpha: AlphaMode::Premultiplied,
..Default::default()
};
let fid = s.p.add_item("Sequence", Label::Lavender, None, ItemKind::Footage(f));
for i in 0..layers {
let mut l = build::layer(&mut s.p, &s.comp, "Sequence", LayerSource::Footage { item: fid }, (97, 61), None);
if i > 0 {
set(&mut l, "transform/rotation", Value::Scalar(11.0 * i as f64));
set(&mut l, "transform/scale", Value::Vec3([70.0 + 9.0 * i as f64, 64.0, 100.0]));
set(&mut l, "transform/position", v3(30.0 + 13.0 * i as f64, 25.0 + 4.0 * i as f64));
set(&mut l, "transform/opacity", Value::Scalar(80.0));
l.blend_mode = if i % 2 == 0 { BlendMode::Screen } else { BlendMode::Add };
}
s.push(l);
}
(s, fid)
}
/// The GPU frame with shared pictures on and off, and the number of uploads of each.
fn render_both(g: &crate::Gpu, s: &Scene, src: &Kept, opts: RenderOpts, t: Tick) -> (Image, Image, u64, u64) {
let mut r = Renderer::new(&s.p, src, RenderOpts { backend: Backend::Gpu, ..opts });
r.accel = Some(g);
let before = g.ctx.transfer_stats().uploads;
let on = g.render(&r, s.cid, t).expect("the GPU renders the frame");
let uploads = g.ctx.transfer_stats().uploads - before;
SHARED_PICTURES.store(false, std::sync::atomic::Ordering::Relaxed);
let before = g.ctx.transfer_stats().uploads;
let off = g.render(&r, s.cid, t);
let copied = g.ctx.transfer_stats().uploads - before;
SHARED_PICTURES.store(true, std::sync::atomic::Ordering::Relaxed);
(on, off.expect("the GPU renders the frame from per-layer copies"), uploads, copied)
}
fn check_cpu(label: &str, s: &Scene, src: &Kept, opts: RenderOpts, t: Tick, gpu_img: &Image) {
let cpu = Renderer::new(&s.p, src, RenderOpts { backend: Backend::Cpu, ..opts }).comp_frame_cpu(s.cid, t);
let mut d = diff(&cpu, gpu_img, tolerance(s.p.settings.bit_depth));
d.quantized = s.p.settings.bit_depth != BitDepth::Bpc32;
check(label, Some(d), 0.0);
}
/// Three layers showing the same frame upload it once; a fourth that runs an effect on it
/// (resident on the GPU) reads the same texture as its input. Every frame of the sequence
/// uploads its own picture once.
#[test]
fn three_layers_of_one_footage_frame_upload_once() {
let Some(g) = gpu() else { return };
let src = Kept::default();
let (mut s, fid) = shared_scene(BitDepth::Bpc32, 3);
for k in 0..3 {
let t = Tick::from_seconds_f64(k as f64 / 30.0);
let (on, off, uploads, copied) = render_both(g, &s, &src, opts(), t);
assert_eq!(uploads, 1, "frame {k}: three layers of one footage frame uploaded {uploads} times");
assert_eq!(copied, 3, "frame {k}: per-layer copies upload once per layer");
assert!(on.data == off.data, "frame {k}: the shared picture changed the frame");
check_cpu(&format!("shared picture frame {k}"), &s, &src, opts(), t, &on);
}
// An effect input: the blur reads the shared texture. Alone, the blurred layer uploads
// its picture (and whatever the blur itself uploads); the three plain layers showing the
// same frame add no upload.
let mut fx = build::layer(&mut s.p, &s.comp, "Blurred", LayerSource::Footage { item: fid }, (97, 61), None);
set(&mut fx, "transform/position", v3(20.0, 20.0));
s.effect(&mut fx, "ec.blur.gaussian", &[("blurriness", n(3.0))]);
let (mut alone, _) = shared_scene(BitDepth::Bpc32, 0);
alone.p = s.p.clone();
alone.p.comp_mut(alone.cid).unwrap().layers = vec![fx.clone()].into();
let (_, _, blur_uploads, _) = render_both(g, &alone, &src, opts(), Tick::from_seconds_f64(0.4));
s.push(fx);
let t = Tick::from_seconds_f64(0.5);
let (on, off, uploads, _) = render_both(g, &s, &src, opts(), t);
assert_eq!(uploads, blur_uploads, "three layers and an effect input of one footage frame uploaded {uploads} times, the effect layer alone {blur_uploads}");
assert!(on.data == off.data, "the shared picture changed the frame with an effect input");
check_cpu("shared picture with an effect input", &s, &src, opts(), t, &on);
}
/// The frame is bit-identical with shared pictures on and off wherever they apply or not: every
/// bit depth, colour management and linear blending, half resolution, a track matte, Preserve
/// Transparency, motion blur, masks, layer styles and a proxy.
#[test]
fn shared_pictures_do_not_change_the_frame() {
let Some(g) = gpu() else { return };
let src = Kept::default();
let t = Tick::from_seconds_f64(0.2);
for depth in [BitDepth::Bpc8, BitDepth::Bpc16, BitDepth::Bpc32] {
let mut cases: Vec<(String, Scene, RenderOpts)> = vec![];
cases.push(("plain".into(), shared_scene(depth, 3).0, opts()));
cases.push(("half resolution".into(), shared_scene(depth, 3).0, RenderOpts { scale: 0.5, ..opts() }));
for (ws, blend_linear) in [(Some(ColorSpace::Rec709), false), (None, true)] {
let (mut s, _) = shared_scene(depth, 3);
s.p.settings.working_space = ws;
s.p.settings.blend_linear = blend_linear;
cases.push((format!("colour {ws:?} linear blending {blend_linear}"), s, opts()));
}
{
let (mut s, _) = shared_scene(depth, 3);
let comp = s.p.comp_mut(s.cid).unwrap();
comp.enable_motion_blur = true;
let matte = comp.layers[0].id;
comp.layers[1].track_matte = Some(TrackMatte { layer: matte, kind: MatteKind::Luma });
comp.layers[2].preserve_transparency = true;
comp.layers[2].switches.motion_blur = true;
let pos = comp.layers[2].props.prop_mut("transform/position").unwrap();
pos.keys = vec![Keyframe::new(Tick::ZERO, v3(10.0, 20.0)), Keyframe::new(Tick::from_seconds_f64(0.5), v3(90.0, 45.0))];
s.comp.enable_motion_blur = true;
cases.push(("matte, preserve transparency, motion blur".into(), s, opts()));
}
{
let (mut s, _) = shared_scene(depth, 3);
let mut next = s.p.next_id;
let m = build::mask(&mut Ids(&mut next), "Mask 1", ShapePath::ellipse([40.0, 30.0], 60.0, 40.0), MaskMode::Add, [255, 255, 0]);
let comp = s.p.comp_mut(s.cid).unwrap();
comp.layers[0].props.sub_mut("masks").unwrap().children.push(m.into());
effectcraft_project::styles::add_style(&mut comp.layers[1], &mut Ids(&mut next), "dropShadow", &s.comp.global_light, true).unwrap();
s.p.next_id = next;
cases.push(("mask and layer style".into(), s, opts()));
}
{
let (mut s, fid) = shared_scene(depth, 3);
let proxy = Footage { path: "proxy.png".into(), kind: FootageKind::Still, width: 48, height: 30, has_video: true, ..Default::default() };
s.p.item_mut(fid).unwrap().proxy = Some(Box::new(effectcraft_project::Proxy { footage: proxy, enabled: true }));
cases.push(("proxy".into(), s, opts()));
}
for (label, s, o) in cases {
let (on, off, _, _) = render_both(g, &s, &src, o, t);
assert!(on.data == off.data, "{label} {depth:?}: the shared picture changed the frame");
check_cpu(&format!("{label} {depth:?}"), &s, &src, o, t, &on);
}
}
}
/// The picture cache keeps no texture of a picture the footage source dropped.
#[test]
fn picture_cache_drops_textures_of_dropped_pictures() {
let Some(g) = gpu() else { return };
let before = g.ctx.picture_entries();
for i in 0..6 {
let img = Arc::new(pattern(i, 64, 64));
let up = g.ctx.upload_picture(&img).expect("upload");
let again = g.ctx.upload_picture(&img).expect("upload");
assert!(up.texture == again.texture, "the same picture uploaded twice");
drop((up, again, img));
}
let kept = g.ctx.picture_entries();
assert!(kept <= before + 1, "the picture cache kept {kept} textures of dropped pictures (had {before})");
}
/// Large uploads copy through reused staging buffers (rows padded to the copy alignment) with
/// the same pixels as `Queue::write_texture`; the buffers stay within their budget, and a
/// clear while one is in flight forgets it.
#[test]
fn staging_uploads_keep_pixels_and_reuse_buffers() {
let Some(g) = gpu() else { return };
let wait = || {
let _ = g.ctx.poll_readbacks(wgpu::PollType::wait_indefinitely());
};
// 300 px: 4800-byte rows, padded to 4864.
for (w, h) in [(300u32, 300u32), (512, 600)] {
let before = g.ctx.upload_staging().copies();
for i in 0..4 {
let img = pattern(40 + i, w, h);
let up = g.ctx.upload_image(&img).expect("upload");
let mut e = crate::context::Enc::new(&g.ctx);
let back = e.download(&up).expect("readback");
assert!(back.data.iter().flatten().map(|v| v.to_bits()).eq(img.data.iter().flatten().map(|v| v.to_bits())), "{w}×{h} upload {i} changed pixels");
drop((up, e));
wait();
}
assert!(g.ctx.upload_staging().copies() - before >= 2, "{w}×{h}: no upload went through a reused staging buffer");
assert!(g.ctx.upload_staging().bytes() <= 192 << 20);
}
// A clear with a copy in flight: nothing kept, and later uploads still work.
let img = pattern(7, 300, 300);
let up = g.ctx.upload_image(&img).expect("upload");
g.ctx.upload_staging().clear();
wait();
assert_eq!(g.ctx.upload_staging().bytes(), 0, "a cleared staging kept its buffers");
let mut e = crate::context::Enc::new(&g.ctx);
assert!(e.download(&up).expect("readback").data == img.data);
let again = g.ctx.upload_image(&img).expect("upload after a clear");
assert!(e.download(&again).expect("readback").data == img.data);
}
+264
View File
@@ -0,0 +1,264 @@
//! Footage uploads (#3333): each decoded footage picture uploads once and its texture is shared
//! by every layer and effect input that shows it ([`Pictures`]), and large uploads copy through
//! staging buffers that are kept mapped and reused ([`Staging`]) instead of one that
//! `Queue::write_texture` allocates and frees per upload.
use std::collections::HashMap;
use std::sync::{Arc, Mutex, PoisonError, Weak};
use effectcraft_raster::Image;
use crate::context::GpuImage;
/// Bytes of picture textures kept after their frame (a sequence's next frame takes the oldest
/// one's texture back from the pool), at most a quarter of the context's upload budget. The
/// newest picture is always kept, whatever its size.
pub(crate) const PICTURE_BUDGET: usize = 256 << 20;
/// Uploads from this size go through [`Staging`]; smaller ones through `Queue::write_texture`.
const STAGING_MIN: u64 = 1 << 20;
/// Bytes of staging buffers that may exist at once (mapped and ready, or in flight).
const STAGING_BUDGET: u64 = 192 << 20;
struct Picture {
img: Weak<Image>,
tex: GpuImage,
bytes: usize,
last_use: u64,
}
/// Uploaded footage pictures by the identity of the decoded frame (its `Arc`): the footage
/// source serves one `Arc` per (file, frame, size, colour curve) while it keeps the frame, and
/// a new one when the file changed, so the same `Arc` means the same pixels. The `Weak` keeps
/// the allocation, so a key cannot be reused by another picture while its entry exists.
#[derive(Default)]
pub(crate) struct Pictures {
map: HashMap<usize, Picture>,
bytes: usize,
clock: u64,
}
impl Pictures {
/// The texture of `img` if it was uploaded and is still kept.
pub(crate) fn get(&mut self, img: &Arc<Image>) -> Option<GpuImage> {
self.clock += 1;
let now = self.clock;
let e = self.map.get_mut(&(Arc::as_ptr(img) as usize))?;
if !e.img.upgrade().is_some_and(|i| Arc::ptr_eq(&i, img)) {
return None;
}
e.last_use = now;
Some(e.tex.clone())
}
/// Keep `tex`, the upload of `img`. Pictures the footage source no longer holds go at once
/// (nothing can ask for them again), then the least recently used over [`PICTURE_BUDGET`],
/// never `img` itself. A dropped texture goes back to the pool once no open encoder can
/// still read it.
pub(crate) fn insert(&mut self, img: &Arc<Image>, tex: GpuImage, budget: usize) {
let key = Arc::as_ptr(img) as usize;
let bytes = img.data.len().saturating_mul(16);
self.clock += 1;
let entry = Picture { img: Arc::downgrade(img), tex, bytes, last_use: self.clock };
if let Some(old) = self.map.insert(key, entry) {
self.bytes = self.bytes.saturating_sub(old.bytes);
}
self.bytes = self.bytes.saturating_add(bytes);
let dead: Vec<usize> = self.map.iter().filter(|(_, e)| e.img.strong_count() == 0).map(|(k, _)| *k).collect();
for k in dead {
if let Some(e) = self.map.remove(&k) {
self.bytes = self.bytes.saturating_sub(e.bytes);
}
}
while self.bytes > budget {
let Some(k) = self.map.iter().filter(|(k, _)| **k != key).min_by_key(|(_, e)| e.last_use).map(|(k, _)| *k) else { break };
if let Some(e) = self.map.remove(&k) {
self.bytes = self.bytes.saturating_sub(e.bytes);
}
}
}
#[cfg(test)]
pub(crate) fn len(&self) -> usize {
self.map.len()
}
pub(crate) fn clear(&mut self) {
self.map.clear();
self.bytes = 0;
}
}
#[derive(Default)]
struct StagingState {
/// Buffers mapped for writing and not in use, each mapped whole.
ready: Vec<wgpu::Buffer>,
/// Bytes of every staging buffer that exists (ready, being mapped or in flight).
total: u64,
/// Raised by [`Staging::clear`]: a buffer of an earlier generation is dropped when its
/// mapping completes.
generation: u64,
/// Uploads copied through a staging buffer (diagnostics, tests).
copies: u64,
}
/// Reused upload staging buffers (`MAP_WRITE | COPY_SRC`). A buffer is written while mapped,
/// unmapped, copied into the texture by a command buffer submitted at once, and mapped again
/// when that submission is done (`CommandEncoder::map_buffer_on_submit`; the callback runs when
/// the device is polled, as every frame does). A new buffer is mapped asynchronously too (never
/// `mapped_at_creation`, which fails hard on drivers that report a failed allocation as an
/// internal error, #591), so the upload that made it, and any upload while no ready buffer fits
/// or the budget is spent, goes through `Queue::write_texture` instead.
#[derive(Default)]
pub(crate) struct Staging {
state: Arc<Mutex<StagingState>>,
}
/// `bytes_per_row` of a staging copy: rows padded to `COPY_BYTES_PER_ROW_ALIGNMENT`.
fn padded_row(width: u32) -> Option<u64> {
let row = u64::from(width).checked_mul(16)?;
let a = u64::from(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT);
row.div_ceil(a).checked_mul(a)
}
impl Staging {
fn lock(&self) -> std::sync::MutexGuard<'_, StagingState> {
self.state.lock().unwrap_or_else(PoisonError::into_inner)
}
/// Bytes of staging buffers that exist (see [`STAGING_BUDGET`]).
pub(crate) fn bytes(&self) -> u64 {
self.lock().total
}
/// Uploads copied through a staging buffer so far.
#[cfg(test)]
pub(crate) fn copies(&self) -> u64 {
self.lock().copies
}
/// Forget every staging buffer (after a failed frame, #1319): those in flight are dropped
/// when their mapping completes.
pub(crate) fn clear(&self) {
let mut s = self.lock();
s.ready.clear();
// (buffers still in flight are no longer counted: they drop when their mapping ends)
s.total = 0;
s.generation = s.generation.wrapping_add(1);
}
/// The callback of a write mapping of `buffer`: once mapped it is ready for the next
/// upload; it is dropped (and its bytes uncounted) when mapping failed, and dropped when
/// the staging was cleared meanwhile (whose [`Staging::clear`] stopped counting it).
fn on_mapped(&self, buffer: &wgpu::Buffer, generation: u64) -> impl FnOnce(Result<(), wgpu::BufferAsyncError>) + wgpu::WasmNotSend + 'static {
let state = Arc::downgrade(&self.state);
let buffer = buffer.clone();
move |res: Result<(), wgpu::BufferAsyncError>| {
let Some(state) = state.upgrade() else { return };
let mut s = state.lock().unwrap_or_else(PoisonError::into_inner);
if s.generation != generation {
return;
}
if res.is_ok() {
s.ready.push(buffer);
} else {
s.total = s.total.saturating_sub(buffer.size());
}
}
}
/// Copy `img` into `texture` (its size) through a ready staging buffer, recorded and
/// submitted at once: before anything an open encoder records later, as
/// `Queue::write_texture` would. `false`: not done (small, no ready buffer fits, or the
/// mapping failed); the caller writes it through the queue. Starts mapping a new buffer for
/// later uploads when none fits and the budget allows.
pub(crate) fn write(&self, device: &wgpu::Device, queue: &wgpu::Queue, img: &Image, texture: &wgpu::Texture) -> bool {
let (w, h) = (img.width, img.height);
let row = w as usize * 16;
let Some(stride) = padded_row(w) else { return false };
let (Some(need), Ok(stride32)) = (stride.checked_mul(u64::from(h)), u32::try_from(stride)) else { return false };
if need < STAGING_MIN || img.data.len() != w as usize * h as usize {
return false;
}
let (buffer, generation) = {
let mut s = self.lock();
let generation = s.generation;
// The smallest ready buffer that holds it.
let best = s.ready.iter().enumerate().filter(|(_, b)| b.size() >= need).min_by_key(|(_, b)| b.size()).map(|(i, _)| i);
match best {
Some(i) => (Some(s.ready.swap_remove(i)), generation),
None => {
// Ready buffers too small for it make room (a size that no longer occurs).
while s.total.saturating_add(need) > STAGING_BUDGET
&& let Some(b) = s.ready.pop()
{
s.total = s.total.saturating_sub(b.size());
}
if s.total.saturating_add(need) <= STAGING_BUDGET {
s.total += need;
drop(s);
let b = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("upload staging"),
size: need,
usage: wgpu::BufferUsages::MAP_WRITE | wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
let done = self.on_mapped(&b, generation);
b.map_async(wgpu::MapMode::Write, .., done);
}
(None, generation)
}
}
};
let Some(buffer) = buffer else { return false };
self.lock().copies += 1;
{
let Ok(mut view) = buffer.get_mapped_range_mut(..need) else {
// Not mapped after all: give it up (its bytes too).
let mut s = self.lock();
if s.generation == generation {
s.total = s.total.saturating_sub(buffer.size());
}
return false;
};
let src: &[u8] = bytemuck::cast_slice(&img.data);
let stride = stride as usize;
if stride == row {
view.slice(..src.len()).copy_from_slice(src);
} else {
for (y, line) in src.chunks_exact(row).enumerate() {
let at = y * stride;
view.slice(at..at + row).copy_from_slice(line);
}
}
}
buffer.unmap();
let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("upload") });
enc.copy_buffer_to_texture(
wgpu::TexelCopyBufferInfo {
buffer: &buffer,
layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(stride32), rows_per_image: Some(h) },
},
wgpu::TexelCopyTextureInfo { texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All },
wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
);
let done = self.on_mapped(&buffer, generation);
enc.map_buffer_on_submit(&buffer, wgpu::MapMode::Write, .., done);
queue.submit([enc.finish()]);
true
}
}
#[cfg(test)]
mod tests {
use super::padded_row;
#[test]
fn staging_rows_are_padded_to_the_copy_alignment() {
assert_eq!(padded_row(1920), Some(30720));
assert_eq!(padded_row(97), Some(1792));
assert_eq!(padded_row(16), Some(256));
assert_eq!(padded_row(1), Some(256));
}
}
+69 -6
View File
@@ -241,6 +241,9 @@ fn draw_layer_on(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas
if let Some(done) = draw_resident_fx(e, r, ctx, layer, canvas, opacity) {
return done;
}
if let Some(done) = draw_picture(e, r, ctx, layer, canvas, opacity) {
return done;
}
let Some(st) = r.styled_layer(ctx, layer) else { return Some(()) };
if st.plain {
return composite_layer(e, r, ctx, layer, &st.body, canvas, opacity);
@@ -286,14 +289,28 @@ fn draw_resident_fx(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, can
{
return None;
}
let input = r.layer_input(ctx, layer, 0)?;
if input.img.is_empty() {
return Some(Some(()));
}
let Some(src) = e.g.upload_buf(&input) else { return Some(None) };
// The decoded footage picture itself when it is the layer's input (uploaded once per frame
// and shared with every layer that shows it), else the cached input buffer.
let (src, offset, scale) = match picture(r, ctx, layer) {
Some(p) => {
if p.img.is_empty() {
return Some(Some(()));
}
let Some(src) = e.g.upload_picture(&p.img) else { return Some(None) };
(src, p.effect_offset, p.scale)
}
None => {
let input = r.layer_input(ctx, layer, 0)?;
if input.img.is_empty() {
return Some(Some(()));
}
let Some(src) = e.g.upload_buf(&input) else { return Some(None) };
(src, input.offset, input.scale)
}
};
let pipe = r.pipe();
// `layer_input` is in effect space: run the stack there, then back to layer space.
let mut fx = crate::effects::GpuFx::new(e, src, input.offset, input.scale, pipe.levels);
let mut fx = crate::effects::GpuFx::new(e, src, offset, scale, pipe.levels);
let origin = r.run_layer_effects_on(ctx, layer, &mut fx);
let Some((mut img, mut offset, scale)) = fx.finish() else { return Some(None) };
offset = [offset[0] - origin[0] * scale, offset[1] - origin[1] * scale];
@@ -308,6 +325,52 @@ fn draw_resident_fx(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, can
Some(place_src(e, r, ctx, layer, &shape, img, canvas, layer.blend_mode, opacity, pipe.levels).map(|c| *canvas = c))
}
/// Shared footage pictures ([`picture`]); tests turn it off to compare.
pub(crate) static SHARED_PICTURES: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
/// The layer's source as the decoded footage picture (`Renderer::footage_picture`), unless
/// sharing is off or per-layer timings are being recorded (those time the layer-cache path).
fn picture(r: &Renderer, ctx: &EvalCtx, layer: &Layer) -> Option<effectcraft_render::FootagePicture> {
if !SHARED_PICTURES.load(std::sync::atomic::Ordering::Relaxed) || r.profile.is_some() {
return None;
}
r.footage_picture(ctx, layer)
}
/// A footage layer without effects or layer styles whose source is the decoded picture as it
/// is (`Renderer::footage_picture`) and needs no conversion to the blending space: placed
/// straight from the picture's texture, uploaded once per frame and shared by every layer that
/// shows the same frame, instead of from a copy per layer in the layer cache, uploaded per
/// layer. The pixels are the same: the layer-cache buffer is that picture, copied. `None`: not
/// this kind of layer; `Some(None)`: the GPU failed.
fn draw_picture(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas: &mut GpuImage, opacity: f32) -> Option<Option<()>> {
if (layer.switches.effects && layer.effects().is_some_and(|f| !f.children.is_empty())) || styles::active(ctx, layer) || r.pipe().to_blend().is_some() {
return None;
}
let p = picture(r, ctx, layer)?;
if p.img.is_empty() {
return Some(Some(()));
}
let Some(src) = e.g.upload_picture(&p.img) else { return Some(None) };
// The placement only reads the buffer's size, offset and scale.
let shape = Buf { img: Image { width: p.img.width, height: p.img.height, data: Vec::new() }, offset: [0.0; 2], scale: p.scale };
Some(composite_src(e, r, ctx, layer, &shape, src, canvas, opacity))
}
/// [`composite_layer`] of a GPU image `src` shaped like `shape`.
#[allow(clippy::too_many_arguments)]
fn composite_src(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, shape: &Buf, src: GpuImage, canvas: &mut GpuImage, opacity: f32) -> Option<()> {
if opacity <= 0.0 {
return Some(());
}
if r.track_matte(ctx, layer).is_none() && !layer.preserve_transparency {
*canvas = place_src(e, r, ctx, layer, shape, src, canvas, layer.blend_mode, opacity, r.pipe().levels)?;
return Some(());
}
let iso = place_src(e, r, ctx, layer, shape, src, &e.zeros(canvas.width, canvas.height), BlendMode::Normal, 1.0, None)?;
composite_iso(e, r, ctx, layer, iso, canvas, opacity)
}
/// A 2D precomp layer, its nested comp rendered resident on the GPU (`walk::render` one level
/// down) instead of by the CPU compositor and uploaded every frame (`Renderer::layer_source`
/// renders nested comps on the CPU: depth > 0). Then the layer's effects (resident), its
+1 -1
View File
@@ -147,7 +147,7 @@ pub(crate) fn record(e: &mut Enc, yp: &YuvPipe, img: &GpuImage, bg: [f32; 3]) ->
pub(crate) fn convert(e: &mut Enc, yp: &YuvPipe, img: &GpuImage, bg: [f32; 3]) -> Option<Vec<u8>> {
let stg = record(e, yp, img, bg)?;
let n = planes_len(img.width, img.height) as usize;
e.g.read_buffer(&stg, n, n, 1).map_err(|err| log::error!(target: "effectcraft_gpu", "yuv420 readback: {err}")).ok()
e.g.read_buffer_after(&stg, e.last_submission(), n, n, 1).map_err(|err| log::error!(target: "effectcraft_gpu", "yuv420 readback: {err}")).ok()
}
#[cfg(test)]
+3
View File
@@ -62,6 +62,9 @@ impl Exporter for FileExporter {
label: job.label.clone(),
storage: job.storage,
overflow: job.project.render_prefs.overflow_folders.clone(),
caches: job.caches.map(|c| effectcraft_export::JobCaches { gpu: &c.gpu, cpu: &c.cpu }),
renderer: job.renderer,
gpu_frames: job.gpu_frames,
},
nested_switches: job.nested_switches,
};
+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,
}
}
}
+27 -2
View File
@@ -21,16 +21,35 @@ 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,
}
}
}
/// An accelerator's device memory, to size how many frames an export renders on it at once
/// (see [`Accelerator::frame_memory`]).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct FrameMemory {
/// Bytes the accelerator may use (a conservative share of the device, not its free memory).
pub budget: u64,
/// The most its allocator has held while frames rendered (one frame's working memory
/// included).
pub peak: u64,
/// Bytes of that peak one frame needed (what it released again), or the whole budget once
/// frames have had to wait for memory.
pub frame: u64,
/// Bytes long-lived caches (uploaded layers) may still grow by.
pub reserve: u64,
}
/// One effect of a GPU effect chain (see [`Accelerator::effects`]).
pub struct FxStep<'x> {
pub spec: &'static effectcraft_effects::EffectSpec,
@@ -91,6 +110,12 @@ pub trait Accelerator: Send + Sync {
fn auto_pick(&self) -> Option<&AutoPick> {
None
}
/// The device memory budget and use, after at least one frame rendered: what an export
/// sizes its frames in flight by. `None` where it isn't known (no allocator report, no
/// budget): the export then renders one frame at a time.
fn frame_memory(&self) -> Option<FrameMemory> {
None
}
/// Whether the accelerator can still render frames. `false` once it has stopped for good
/// (its device was lost or retired): every frame it is asked for falls back to the CPU, so
/// callers that limit frames in flight for it can stop doing so.
+8 -1
View File
@@ -230,6 +230,11 @@ impl LayerCache {
/// used. (A render that started before a drop may publish its buffer after the drop's
/// purge, Reload Footage clearing the cache while the buffer was being written: the check
/// here, not the order of the two, keeps it from being served.)
/// Whether a buffer is kept in memory under `key` (a peek: no hit, no miss, no disk read).
pub(crate) fn holds(&self, key: u64) -> bool {
self.kept(key, false).is_some()
}
fn kept(&self, key: u64, hit: bool) -> Option<(Arc<Buf>, Arc<ReadFiles>)> {
let found = {
let mut g = self.inner.lock().ok()?;
@@ -689,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).
+8
View File
@@ -295,7 +295,15 @@ impl<'a> Renderer<'a> {
/// in this render ([`Self::mask_blur`]'s gate: the render options, the precomp layers above).
/// The key has the layer's own motion blur state, but a mask set to On blurs without it.
pub(crate) fn mask_gate_key(&self, ctx: &EvalCtx, layer: &Layer, key: Option<u64>) -> Option<u64> {
// Queue jobs share this cache but may override Proxy Use differently. The item's
// proxy switch alone does not describe the source a renderer actually reads.
let k = key?;
let k = match self.opts.proxy {
effectcraft_project::render_queue::ProxyUse::CurrentSettings => k,
effectcraft_project::render_queue::ProxyUse::UseAll => cache::derive(k, 0x7072_6f78_7901),
effectcraft_project::render_queue::ProxyUse::UseCompOnly => cache::derive(k, 0x7072_6f78_7902),
effectcraft_project::render_queue::ProxyUse::UseNone => cache::derive(k, 0x7072_6f78_7903),
};
let masked = layer.masks().is_some_and(|m| !m.children.is_empty());
Some(if masked && !self.mask_blur(ctx, layer).allowed { cache::derive(k, 0x6d62_6f66) } else { k })
}
+3 -3
View File
@@ -46,8 +46,8 @@ mod layers;
mod opts;
mod pipeline;
pub use accel::{Accelerator, Backend, FxStep, FxTarget};
pub use footage::{CancellableFootage, FloatCurve, FootageSource, NoFootage, is_vector_footage, missing_item_problem};
pub use accel::{Accelerator, Backend, FrameMemory, FxStep, FxTarget};
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,
@@ -61,7 +61,7 @@ pub use layers::{Placement, StyledLayer};
pub(crate) use opts::Collapse3d;
pub use opts::{LayerTiming, RenderOpts};
use opts::{MAX_THROUGH, Through};
pub use pipeline::{Collapsed, MediaOverride, PropertyOverride};
pub use pipeline::{Collapsed, FootagePicture, MediaOverride, PropertyOverride};
pub struct Renderer<'a> {
pub project: &'a Project,
+95 -14
View File
@@ -19,6 +19,24 @@ pub(crate) struct EffectStack {
pub origin: [f64; 2],
}
/// A footage layer's source as the decoded picture itself (see [`Renderer::footage_picture`]):
/// its buffer in layer space is `img` at offset `[0, 0]` and `scale`; in effect space (what
/// [`Renderer::layer_input`] gives) at `effect_offset`.
#[derive(Clone, Debug)]
pub struct FootagePicture {
pub img: Arc<Image>,
pub scale: f64,
pub effect_offset: [f64; 2],
}
/// How a decoded footage frame becomes a layer buffer ([`Renderer::footage_buf`]).
enum FootageLayout {
/// Its own pixels, at this buffer scale.
Native { scale: f64 },
/// Resampled to `width`×`height` at this buffer scale.
Resample { width: u32, height: u32, scale: f64 },
}
/// A property value an Essential Properties override gives a layer of a collapsed precomp.
#[derive(Clone, Debug)]
pub struct PropertyOverride {
@@ -861,12 +879,7 @@ impl<'a> Renderer<'a> {
return Some(buf);
}
}
let lt = ctx.source_time(layer);
// The proxy is decoded instead, at its own size, and fills the footage's frame.
let proxy = self.proxy_for(item);
let pf = proxy.unwrap_or(f);
let size = self.reduced_size(f.width, f.height).filter(|_| proxy.is_none());
let img = self.footage_frame(ctx, layer, item, pf, lt, size)?;
let (img, pf) = self.footage_picture_of(ctx, layer, item, f)?;
Some(self.footage_buf(&img, pf, f.width, f.height))
}
// (the font files go to this render's log, which `ctx` may not carry: see
@@ -898,14 +911,9 @@ impl<'a> Renderer<'a> {
/// A decoded frame of `f` as a layer buffer covering `w`×`h` layer pixels (the footage's
/// nominal size; proxies are scaled up to it), converted from its colour profile.
fn footage_buf(&self, img: &Image, f: &Footage, w: u32, h: u32) -> Buf {
let s = self.opts.scale;
let k = if img.width > 0 { w.max(1) as f64 / img.width as f64 } else { 1.0 };
// Keep native pixels when downsampling is small; resample otherwise.
let mut buf = match self.reduced_size(w, h) {
// The source resampled it already (`FootageSource::frame_at_size`).
Some(size) if (img.width, img.height) == size => Buf { img: img.clone(), offset: [0.0; 2], scale: s },
Some((rw, rh)) if k <= 1.0 + 1e-9 => Buf { img: effectcraft_raster::resample(img, rw, rh), offset: [0.0; 2], scale: s },
_ => Buf { img: img.clone(), offset: [0.0; 2], scale: 1.0 / k },
let mut buf = match self.footage_layout(img, w, h) {
FootageLayout::Native { scale } => Buf { img: img.clone(), offset: [0.0; 2], scale },
FootageLayout::Resample { width, height, scale } => Buf { img: effectcraft_raster::resample(img, width, height), offset: [0.0; 2], scale },
};
if let Some(c) = self.pipe.media_in(f) {
color::convert(&mut buf.img, &c);
@@ -913,6 +921,79 @@ impl<'a> Renderer<'a> {
buf
}
/// How [`Renderer::footage_buf`] lays out a decoded frame `img` of footage `w`×`h` layer
/// pixels: its own pixels, or resampled.
fn footage_layout(&self, img: &Image, w: u32, h: u32) -> FootageLayout {
let s = self.opts.scale;
let k = if img.width > 0 { w.max(1) as f64 / img.width as f64 } else { 1.0 };
// Keep native pixels when downsampling is small; resample otherwise.
match self.reduced_size(w, h) {
// The source resampled it already (`FootageSource::frame_at_size`).
Some(size) if (img.width, img.height) == size => FootageLayout::Native { scale: s },
Some((width, height)) if k <= 1.0 + 1e-9 => FootageLayout::Resample { width, height, scale: s },
_ => FootageLayout::Native { scale: 1.0 / k },
}
}
/// A footage layer's decoded frame at the context time (the proxy's when one is used) and
/// the footage it was decoded from: what [`Renderer::source`] makes the layer's buffer of.
fn footage_picture_of(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, f: &'a Footage) -> Option<(Arc<Image>, &'a Footage)> {
let lt = ctx.source_time(layer);
// The proxy is decoded instead, at its own size, and fills the footage's frame.
let proxy = self.proxy_for(item);
let pf = proxy.unwrap_or(f);
let size = self.reduced_size(f.width, f.height).filter(|_| proxy.is_none());
let img = self.footage_frame(ctx, layer, item, pf, lt, size)?;
Some((img, pf))
}
/// The decoded footage picture a layer's masked source is, bit for bit, with no copy: a
/// footage layer (not continuously rasterised vector footage) without masks whose frame
/// needs no colour conversion, no resampling and no quantisation (32 bpc). The buffer
/// [`Renderer::layer_input`] gives for no effects is this picture at `effect_offset` and
/// `scale`.
/// Layers showing the same frame get the same `Arc` (the footage source's cached frame), so
/// an accelerator uploads it once and shares the texture. `None`: the layer's buffer is
/// made the usual way (any of the above applies, or the frame is missing), and also when the
/// frame is made per call rather than served by the footage source (frame blending,
/// separate fields, Invert Alpha, Interpret As Linear Light, Cineon conversion: a new
/// picture each time, nothing to share) or when the layer cache already holds the layer's
/// buffer (served from memory as before, without asking the footage source).
pub fn footage_picture(&self, ctx: &EvalCtx, layer: &Layer) -> Option<FootagePicture> {
let LayerSource::Footage { item } = &layer.source else { return None };
if self.pipe.levels.is_some() || layer.masks().is_some_and(|m| !m.children.is_empty()) || frame_blend_mode(ctx, layer) != FrameBlend::Off {
return None;
}
let item = ctx.media_item(layer, *item);
let ItemKind::Footage(f) = &self.project.item(item)?.kind else { return None };
if !f.has_video || (layer.switches.collapse && is_vector_footage(f)) {
return None;
}
let pf = self.proxy_for(item).unwrap_or(f);
if self.pipe.media_in(pf).is_some()
|| pf.fields != effectcraft_project::FieldOrder::Off
|| pf.invert_alpha
|| pf.linear_light_applies()
|| pf.cineon_applies()
{
return None;
}
if let Some(cache) = self.cache {
let (scale, draft, blur) = (self.raster_scale(ctx, layer), self.opts.draft, self.mb_on(ctx, layer));
let content = self.content_key(ctx, layer, cache::layer_key(ctx, layer, scale, draft, blur));
let input = self.content_key(ctx, layer, cache::input_key(ctx, layer, scale, draft, blur, 0));
if [content, input].into_iter().flatten().any(|k| cache.holds(k)) {
return None;
}
}
let (img, _) = self.footage_picture_of(ctx, layer, item, f)?;
let FootageLayout::Native { scale } = self.footage_layout(&img, f.width, f.height) else { return None };
// `layer_input`'s rebase into effect space (`Buf::rebase` of a buffer at the origin).
let mut at = Buf { img: Image { width: 0, height: 0, data: Vec::new() }, offset: [0.0; 2], scale };
at.rebase(ctx.effect_bounds(layer).1);
Some(FootagePicture { img, scale, effect_offset: at.offset })
}
/// The size of `w` × `h` layer pixels in this render when it is reduced enough that footage
/// is resampled to it (below 3/4 scale).
fn reduced_size(&self, w: u32, h: u32) -> Option<(u32, u32)> {
Loaded 100 of 132 files, more files were not shown because too many files have changed in this diff. Show more