mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-14 22:33:09 +00:00
Merge remote-tracking branch 'origin/aep-audit-3300' into aep-import-bounds-3299
This commit is contained in:
commit
28206a3c1d
132 files changed
+5885
-1103
No files matched your search
+2
-2
@@ -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
@@ -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
@@ -16,7 +16,7 @@ craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-l
|
||||
craft-quit = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
craft-ecproj-format = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "ca1b5e2edb8568335c6c14f390307bd5beae8238" }
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["egui"] }
|
||||
# internal
|
||||
effectcraft-time = { path = "crates/time" }
|
||||
effectcraft-geom = { path = "crates/geom" }
|
||||
|
||||
@@ -129,18 +129,14 @@ or into the Timeline, between layers and at the time you point to.
|
||||
|
||||
308 built-in effects across its categories, each with its parameter names, order and
|
||||
defaults: blur and sharpen, channel, color correction (Curves, Levels, Hue/Saturation, a full
|
||||
color-grading panel…), distort (Warp, Bulge, Turbulent Displace, CC Power Pin…), generate (Fractal Noise,
|
||||
color-grading panel…), distort (Warp, Bulge, Turbulent Displace, Perspective Pin…), generate (Fractal Noise,
|
||||
Gradient Ramp, Stroke, Write-on, Audio Spectrum…), keying, matte, noise and grain, perspective,
|
||||
**simulation** (CC Particle World, CC Rainfall, Shatter, Card Dance, Caustics, Wave World…),
|
||||
stylize (Glow, CC Glass…), **time** (Echo, Posterize Time, Timewarp, Time Displacement…),
|
||||
**simulation** (Particle Emitter 3D, Rainfall, Shatter, Card Dance, Caustics, Wave World…),
|
||||
stylize (Glow, Glass Relief…), **time** (Echo, Posterize Time, Timewarp, Time Displacement…),
|
||||
**audio** (Reverb, Parametric EQ, Delay, Stereo Mixer…), text, transitions, utility and expression
|
||||
controls. All nine **Layer Styles** (Drop Shadow, Inner/Outer Glow, Bevel and Emboss, Satin,
|
||||
overlays, Stroke) with Global Light. Preview plays audio in sync, with meters and waveforms.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/effectcraft-effects.png" alt="A CC Particle World burst with Glow, and the Effect Controls panel listing the particle system's parameters" width="100%">
|
||||
</p>
|
||||
|
||||
## 3D
|
||||
|
||||
Classic 3D compositing: 3D layers with orientation and material options,
|
||||
|
||||
@@ -35,8 +35,8 @@
|
||||
//!
|
||||
//! Project: --project F.ecproj (or a positional *.ecproj) | --demo | --empty (default: demo; mcp: empty)
|
||||
//! Saving: --save (back to --project) | --save-as F.ecproj
|
||||
//! GPU: --gpu renders on the GPU compositor (Mercury GPU Acceleration) when an adapter exists;
|
||||
//! the default is the CPU (Mercury Software Only)
|
||||
//! GPU: --gpu renders on the GPU compositor (GPU Acceleration) when an adapter exists;
|
||||
//! the default is the CPU (Software Rendering)
|
||||
//! Bridge: --bridge PORT drives a running `effectcraft --control PORT` instead of a headless session
|
||||
//! Output: --json for one compact JSON document on stdout (errors: {"error": …}, exit 1)
|
||||
//! ```
|
||||
@@ -466,7 +466,7 @@ fn backend(args: &Args, default_demo: bool) -> Result<Backend, Failure> {
|
||||
}
|
||||
|
||||
/// A wired session; `--gpu` attaches the GPU compositor (renders then follow the project's
|
||||
/// renderer setting, Mercury GPU Acceleration by default).
|
||||
/// renderer setting, GPU Acceleration by default).
|
||||
fn session(args: &Args) -> Result<effectcraft_engine::Session, Failure> {
|
||||
let mut s = effectcraft_host::session();
|
||||
if args.flag("--autosave") {
|
||||
@@ -1443,7 +1443,7 @@ fn adv3d_bench_comp(p: &mut effectcraft_engine::project::Project) -> Option<Item
|
||||
/// `bench --gpu`: CPU vs GPU ms/frame for every comp (or `--comp`) at Full and Half. "cold"
|
||||
/// renders everything (no layer cache); "warm" reuses the layer cache (playback / scrubbing of
|
||||
/// unchanged layers: compositing cost); "viewer" is the GPU display path without readback;
|
||||
/// "auto" is Backend::Auto (Mercury GPU Acceleration) choosing per comp from measured times,
|
||||
/// "auto" is Backend::Auto (GPU Acceleration) choosing per comp from measured times,
|
||||
/// warm; "up/dn MB" are the GPU's uploads / readbacks per warm GPU frame.
|
||||
fn bench_gpu(s: &Session, args: &Args) -> Result<(), Failure> {
|
||||
use effectcraft_render::Backend;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,7 @@
|
||||
asset: ai-mask-3099.aep
|
||||
title: Small After Effects test project: placed .ai files with masks under two parented Nulls (#3099)
|
||||
author: EffectCraft contributors
|
||||
source: original work (made in After Effects 2026 from own data: four hand-written one-page PDFs named .ai, generated by tools/ai-mask-3099.py, imported and laid out by tools/ai-mask-3099.jsx)
|
||||
license: MIT OR Apache-2.0
|
||||
added: 2026-11-08 by EffectCraft contributors
|
||||
notes: Scrubbed with tools/scrub.py (footage paths C:\fixture\x\<name>.ai of the same length, host name HOST000, the XMP packet of an empty project of the contributor); its four embedded colour profiles replaced by stubs (description only, no colour data) with crates/aep/examples/stubicc.rs, then checked with the corpus repository's tools/aep/inspect-embedded.py (CLEAN). No Adobe asset is in it.
|
||||
+1
@@ -29,6 +29,7 @@ single-case files some cases are merged from, and the contributor's own animatio
|
||||
| `scrub.py` | fixture hygiene of a saved `.aep`: footage alias paths replaced by neutral ones of the same length (`C:\fixtures\...`), host name `HOST000...`, neutral ascend counts, the XMP packet replaced by the base project's |
|
||||
| `layers-comps-scrub-aepx.py` | the same XMP swap for the `.aepx` of the pair |
|
||||
| `patch-text.py` | the byte edits of the text cases 732 and 733 (see below) |
|
||||
| `ai-mask-3099.py`, `ai-mask-3099.jsx` | the committed `../ai-mask-3099.aep` (#3099): the `.py` writes four one-page PDFs named `.ai` with distinct page boxes, the `.jsx` (ExtendScript, run once through an After Effects MCP server's script call in an empty untitled project) imports them, builds composition `ai-mask` and returns After Effects' read-back that `../../it/import/ai_mask.rs` compares against |
|
||||
|
||||
## Run order (per file)
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// Issue 3099: how After Effects sizes a placed .ai (PDF) and where masks / parented layers land.
|
||||
// Generates ai-mask-3099.aep: run `python -I ai-mask-3099.py <work>` first, set `dir` to that
|
||||
// folder, then run this script (ExtendScript) in an empty, untitled project. It returns After
|
||||
// Effects' read-back (footage sizes, transforms, mask vertices, toComp points; the tests compare
|
||||
// against it) and saves <work>/i3099-ai-mask.aep, which is then scrubbed (README: steps 5 and 6).
|
||||
(function () {
|
||||
var dir = "<work>/";
|
||||
var out = {};
|
||||
if (app.project.file !== null || app.project.numItems !== 0) {
|
||||
return "ABORT: project not empty/untitled (" + app.project.numItems + " items)";
|
||||
}
|
||||
var target = new File(dir + "i3099-ai-mask.aep");
|
||||
if (target.exists) { return "ABORT: target exists"; }
|
||||
var names = ["i3099-a-boxes.ai", "i3099-b-frac.ai", "i3099-c-rot.ai", "i3099-d-trim.ai"];
|
||||
var items = {};
|
||||
out.footage = {};
|
||||
for (var i = 0; i < names.length; i++) {
|
||||
var io = new ImportOptions(new File(dir + names[i]));
|
||||
io.importAs = ImportAsType.FOOTAGE;
|
||||
var it = app.project.importFile(io);
|
||||
items[names[i]] = it;
|
||||
out.footage[names[i]] = { w: it.width, h: it.height, par: it.pixelAspect };
|
||||
}
|
||||
var comp = app.project.items.addComp("ai-mask", 1920, 1080, 1, 5, 25);
|
||||
var art = comp.layers.add(items["i3099-a-boxes.ai"]);
|
||||
art.name = "art";
|
||||
var artD = comp.layers.add(items["i3099-d-trim.ai"]);
|
||||
artD.name = "artD";
|
||||
artD.collapseTransformation = true;
|
||||
var artC = comp.layers.add(items["i3099-c-rot.ai"]);
|
||||
artC.name = "artC";
|
||||
artC.property("ADBE Transform Group").property("ADBE Position").setValue([1500, 800]);
|
||||
var n1 = comp.layers.addNull(); n1.name = "N1";
|
||||
var n2 = comp.layers.addNull(); n2.name = "N2";
|
||||
n2.setParentWithJump(n1);
|
||||
art.setParentWithJump(n2);
|
||||
artD.setParentWithJump(n2);
|
||||
var t = function (l, p, v) { l.property("ADBE Transform Group").property(p).setValue(v); };
|
||||
t(n1, "ADBE Position", [700, 400]);
|
||||
t(n1, "ADBE Rotate Z", 15);
|
||||
t(n1, "ADBE Scale", [80, 120]);
|
||||
t(n2, "ADBE Anchor Point", [10, 20]);
|
||||
t(n2, "ADBE Position", [50, -30]);
|
||||
t(n2, "ADBE Scale", [150, 150]);
|
||||
t(art, "ADBE Position", [0, 0]);
|
||||
t(artD, "ADBE Position", [300, 250]);
|
||||
// Two rectangular masks on each art layer, in layer pixels.
|
||||
var rect = function (l, nm, x0, y0, x1, y1) {
|
||||
var m = l.property("ADBE Mask Parade").addProperty("ADBE Mask Atom");
|
||||
m.name = nm;
|
||||
var s = new Shape();
|
||||
s.vertices = [[x0, y0], [x1, y0], [x1, y1], [x0, y1]];
|
||||
s.closed = true;
|
||||
m.property("ADBE Mask Shape").setValue(s);
|
||||
};
|
||||
rect(art, "M1", 40, 30, 200, 90);
|
||||
rect(art, "M2", 220, 100, 350, 160);
|
||||
rect(artD, "M1", 40, 30, 200, 90);
|
||||
rect(artD, "M2", 220, 100, 350, 160);
|
||||
// World positions of layer points through the parent chain (toComp), read via a Point Control.
|
||||
var probe = n1.property("ADBE Effect Parade").addProperty("ADBE Point Control");
|
||||
var pt = probe.property(1);
|
||||
var toComp = function (ln, p) {
|
||||
pt.expression = "thisComp.layer('" + ln + "').toComp([" + p[0] + "," + p[1] + "])";
|
||||
var v = pt.valueAtTime(0, false);
|
||||
return [v[0], v[1]];
|
||||
};
|
||||
var rd = function (l) {
|
||||
var tg = l.property("ADBE Transform Group");
|
||||
var r = {
|
||||
w: l.width, h: l.height,
|
||||
anchor: tg.property("ADBE Anchor Point").value,
|
||||
position: tg.property("ADBE Position").value,
|
||||
scale: tg.property("ADBE Scale").value,
|
||||
rot: tg.property("ADBE Rotate Z").value,
|
||||
masks: []
|
||||
};
|
||||
var mp = l.property("ADBE Mask Parade");
|
||||
for (var k = 1; k <= mp.numProperties; k++) {
|
||||
r.masks.push(mp.property(k).property("ADBE Mask Shape").value.vertices);
|
||||
}
|
||||
return r;
|
||||
};
|
||||
out.layers = { art: rd(art), artD: rd(artD), artC: rd(artC), N1: rd(n1), N2: rd(n2) };
|
||||
out.world = {};
|
||||
var pts = { tl: [0, 0], tr: [art.width, 0], bl: [0, art.height], m1: [40, 30], m2: [350, 160] };
|
||||
for (var key in pts) { out.world["art_" + key] = toComp("art", pts[key]); }
|
||||
var ptsD = { tl: [0, 0], tr: [artD.width, 0], bl: [0, artD.height], m1: [40, 30] };
|
||||
for (var key2 in ptsD) { out.world["artD_" + key2] = toComp("artD", ptsD[key2]); }
|
||||
probe.remove();
|
||||
app.project.save(target);
|
||||
out.saved = target.fsName;
|
||||
return out;
|
||||
})();
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
"""Writes hand-made one-page PDFs named .ai with distinct page boxes (issue 3099 repro)."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
out = sys.argv[1]
|
||||
os.makedirs(out, exist_ok=True)
|
||||
|
||||
|
||||
def pdf(page_extra, content):
|
||||
objs = [
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
b"<< /Type /Page /Parent 2 0 R " + page_extra.encode() + b" /Contents 4 0 R >>",
|
||||
b"<< /Length " + str(len(content)).encode() + b" >>\nstream\n" + content + b"\nendstream",
|
||||
]
|
||||
data = b"%PDF-1.4\n"
|
||||
offs = []
|
||||
for i, o in enumerate(objs, 1):
|
||||
offs.append(len(data))
|
||||
data += str(i).encode() + b" 0 obj\n" + o + b"\nendobj\n"
|
||||
x = len(data)
|
||||
data += b"xref\n0 " + str(len(objs) + 1).encode() + b"\n0000000000 65535 f \n"
|
||||
for o in offs:
|
||||
data += b"%010d 00000 n \n" % o
|
||||
data += b"trailer\n<< /Size " + str(len(objs) + 1).encode() + b" /Root 1 0 R >>\nstartxref\n" + str(x).encode() + b"\n%%EOF\n"
|
||||
return data
|
||||
|
||||
|
||||
def boxes_content(boxes):
|
||||
# One outline per box (different colours) plus a filled marker at the media box origin corner.
|
||||
cols = ["0 0 0", "1 0 0", "0 0.6 0", "0 0 1", "1 0.5 0"]
|
||||
s = b"0.85 0.85 0.85 rg "
|
||||
x0, y0, x1, y1 = boxes[0]
|
||||
s += b"%g %g %g %g re f\n" % (x0, y0, x1 - x0, y1 - y0)
|
||||
for c, (a, b, cc, d) in zip(cols, boxes):
|
||||
s += c.encode() + b" RG 1 w %g %g %g %g re S\n" % (a + 0.5, b + 0.5, cc - a - 1, d - b - 1)
|
||||
# A solid square in the lower-left of the last (innermost) box: shows y orientation.
|
||||
a, b, _, _ = boxes[-1]
|
||||
s += b"0.2 0.2 0.8 rg %g %g 20 10 re f" % (a + 4, b + 4)
|
||||
return s
|
||||
|
||||
|
||||
def arr(b):
|
||||
return "[%s]" % " ".join("%g" % v for v in b)
|
||||
|
||||
|
||||
# A: every box distinct, nested, non-square.
|
||||
media, crop, bleed, trim, art = [0, 0, 420, 220], [5, 5, 415, 215], [10, 10, 410, 210], [20, 20, 400, 200], [60, 50, 300.6, 170.4]
|
||||
a = pdf(
|
||||
f"/MediaBox {arr(media)} /CropBox {arr(crop)} /BleedBox {arr(bleed)} /TrimBox {arr(trim)} /ArtBox {arr(art)}",
|
||||
boxes_content([media, crop, bleed, trim, art]),
|
||||
)
|
||||
# B: only a fractional media box.
|
||||
mb = [0, 0, 300.6, 120.4]
|
||||
b = pdf(f"/MediaBox {arr(mb)}", boxes_content([mb]))
|
||||
# C: media box with an offset origin and /Rotate 90.
|
||||
mc = [100, 50, 400, 150]
|
||||
c = pdf(f"/MediaBox {arr(mc)} /Rotate 90", boxes_content([mc]))
|
||||
# D: media + trim + art, no crop box (bleed around the artboard).
|
||||
md, td, ad = [0, 0, 418, 218], [9, 9, 409, 209], [40, 30, 250, 150]
|
||||
d = pdf(f"/MediaBox {arr(md)} /TrimBox {arr(td)} /BleedBox {arr(md)} /ArtBox {arr(ad)}", boxes_content([md, td, ad]))
|
||||
|
||||
for name, data in [("i3099-a-boxes.ai", a), ("i3099-b-frac.ai", b), ("i3099-c-rot.ai", c), ("i3099-d-trim.ai", d)]:
|
||||
with open(os.path.join(out, name), "wb") as f:
|
||||
f.write(data)
|
||||
print(name, len(data))
|
||||
@@ -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();
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
mod helpers;
|
||||
|
||||
mod aepx;
|
||||
mod ai_mask;
|
||||
mod blend_modes;
|
||||
mod bounds;
|
||||
mod cameras;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Blur & Sharpen, batch 2: edge-preserving blurs (Bilateral, Smart), per-channel and map-driven
|
||||
//! blurs (Channel, Compound, CC Vector), bokeh (Camera Lens Blur), radial streaks and small
|
||||
//! utilities (Reduce Interlace Flicker, CC Cross Blur).
|
||||
//! utilities (Reduce Interlace Flicker, Cross Blur).
|
||||
|
||||
use effectcraft_color::luminance;
|
||||
use effectcraft_keyframe::Value;
|
||||
@@ -507,9 +507,9 @@ fn camera_lens_blur(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// CC Radial Fast Blur
|
||||
// Fast Radial Blur
|
||||
|
||||
/// Transparent pixels CC Radial Fast Blur adds on every side of a `w` × `h` buffer with Center
|
||||
/// Transparent pixels Fast Radial Blur adds on every side of a `w` × `h` buffer with Center
|
||||
/// at `c` (buffer pixels) and smear `s`: content `r` from Center streaks out to `r / (1 − s)`,
|
||||
/// so the farthest corner reaches `d · s / (1 − s)` past itself (at most 4096, as the other
|
||||
/// growing effects). Shared with the GPU path so both grow the same way.
|
||||
@@ -562,7 +562,7 @@ fn radial_fast_blur(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// CC Vector Blur
|
||||
// Vector Blur
|
||||
|
||||
fn vector_blur(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let amount = ctx.params.f("amount").max(0.0) * b.scale;
|
||||
@@ -616,7 +616,7 @@ fn vector_blur(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Reduce Interlace Flicker / CC Cross Blur
|
||||
// Reduce Interlace Flicker / Cross Blur
|
||||
|
||||
fn reduce_flicker(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let s = ctx.params.f("softness").max(0.0) * b.scale;
|
||||
@@ -735,7 +735,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.blur.ccradialfast",
|
||||
"CC Radial Fast Blur",
|
||||
"Fast Radial Blur",
|
||||
vec![
|
||||
p("center", "Center", Value::Vec2([0.5, 0.5]), ParamUi::Point),
|
||||
p("amount", "Amount", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
|
||||
@@ -745,7 +745,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.blur.ccvector",
|
||||
"CC Vector Blur",
|
||||
"Vector Blur",
|
||||
vec![
|
||||
p("type", "Type", Value::Enum(0), popup(&["Natural", "Constant Length", "Perpendicular", "Direction Center", "Direction Fading"])),
|
||||
p("amount", "Amount", num(10.0), slider(0.0, 500.0, 0.0, 100.0, 1)),
|
||||
@@ -765,7 +765,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
},
|
||||
spec(
|
||||
"ec.blur.cccross",
|
||||
"CC Cross Blur",
|
||||
"Cross Blur",
|
||||
vec![
|
||||
p("radiusX", "Radius X", num(10.0), slider(0.0, 500.0, 0.0, 100.0, 1)),
|
||||
p("radiusY", "Radius Y", num(10.0), slider(0.0, 500.0, 0.0, 100.0, 1)),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Blur & Sharpen: CC Radial Blur and Camera-Shake Deblur.
|
||||
//! Blur & Sharpen: Variable Radial Blur and Camera-Shake Deblur.
|
||||
//!
|
||||
//! * **CC Radial Blur** — zoom (straight, fading, centred both ways) and rotational (plain or
|
||||
//! * **Variable Radial Blur** — zoom (straight, fading, centred both ways) and rotational (plain or
|
||||
//! fading) blurs around a centre point, sampled along the ray / arc through each pixel with a
|
||||
//! sample count set by Quality.
|
||||
//! * **Camera-Shake Deblur** — frame-based restoration in the spirit of published video deblurring
|
||||
@@ -22,7 +22,7 @@ fn spec(id: &'static str, name: &'static str, params: Vec<crate::ParamSpec>, ren
|
||||
EffectSpec { id, name, category: "Blur & Sharpen", params, render, gpu: false, float: true }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Radial Blur
|
||||
// ---------------------------------------------------------------- Variable Radial Blur
|
||||
|
||||
pub const RADIAL_TYPES: [&str; 5] = ["Straight Zoom", "Fading Zoom", "Centered", "Rotate", "Rotate Fading"];
|
||||
|
||||
@@ -246,7 +246,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
vec![
|
||||
spec(
|
||||
"ec.blur.ccradial",
|
||||
"CC Radial Blur",
|
||||
"Variable Radial Blur",
|
||||
vec![
|
||||
p("type", "Type", Value::Enum(1), popup(&RADIAL_TYPES)),
|
||||
p("amount", "Amount", num(10.0), slider(-100.0, 100.0, -100.0, 100.0, 1)),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Channel effects, batch 2: layer-to-layer combinations (Blend, Calculations, Compound
|
||||
//! Arithmetic) and CC Composite. Second layers come from layer parameters through the effect
|
||||
//! Arithmetic) and Source Composite. Second layers come from layer parameters through the effect
|
||||
//! host; with no layer chosen the effect uses the layer itself, as After Effects does.
|
||||
|
||||
use effectcraft_color::{BlendMode, blend_pixel, hsl_to_rgb, luminance, rgb_to_hsl};
|
||||
@@ -69,7 +69,7 @@ fn blend(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
|
||||
// ---------------------------------------------------------------- Calculations
|
||||
|
||||
/// Blending modes offered by Calculations / CC Composite (menu order).
|
||||
/// Blending modes offered by Calculations / Source Composite (menu order).
|
||||
const CALC_MODES: [BlendMode; 22] = [
|
||||
BlendMode::Normal,
|
||||
BlendMode::Darken,
|
||||
@@ -150,7 +150,7 @@ fn calculations(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
b
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Composite
|
||||
// ---------------------------------------------------------------- Source Composite
|
||||
|
||||
const CC_COMPOSITE_MODES: [&str; 16] = [
|
||||
"In Front",
|
||||
@@ -321,7 +321,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.channel.cccomposite",
|
||||
"CC Composite",
|
||||
"Source Composite",
|
||||
vec![
|
||||
p("opacity", "Opacity", num(100.0), pct()),
|
||||
p("compositeOriginal", "Composite Original", Value::Enum(0), popup(&CC_COMPOSITE_MODES)),
|
||||
|
||||
@@ -878,7 +878,7 @@ fn broadcast_colors(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// CC Toner, CC Color Offset, CC Kernel
|
||||
// Multi-Tone Tint, Channel Offset, Convolution Kernel
|
||||
|
||||
fn cc_toner(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let g = |id: &str| {
|
||||
@@ -1101,7 +1101,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.color.cctoner",
|
||||
"CC Toner",
|
||||
"Multi-Tone Tint",
|
||||
vec![
|
||||
p("tones", "Tones", Value::Enum(1), popup(&["Duotone", "Tritone", "Pentone"])),
|
||||
p("highlights", "Highlights", col(1.0, 1.0, 1.0), ParamUi::Color),
|
||||
@@ -1115,7 +1115,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.color.cccoloroffset",
|
||||
"CC Color Offset",
|
||||
"Channel Offset",
|
||||
vec![
|
||||
p("redPhase", "Red Phase", num(0.0), ParamUi::Angle),
|
||||
p("greenPhase", "Green Phase", num(0.0), ParamUi::Angle),
|
||||
@@ -1124,7 +1124,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
],
|
||||
cc_color_offset,
|
||||
),
|
||||
spec("ec.color.cckernel", "CC Kernel", k_params, cc_kernel),
|
||||
spec("ec.color.cckernel", "Convolution Kernel", k_params, cc_kernel),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Color Correction effects, batch 3: Change Color, Video Limiter, CC Color Neutralizer,
|
||||
//! PS Arbitrary Map and Lumetri Color (Basic Correction, Creative, Curves, Color Wheels,
|
||||
//! Color Correction effects, batch 3: Change Color, Video Limiter, Color Neutralizer,
|
||||
//! PS Arbitrary Map and Color Grading (Basic Correction, Creative, Curves, Color Wheels,
|
||||
//! Vignette).
|
||||
|
||||
use effectcraft_color::{hsl_to_rgb, luminance, rgb_to_hsl};
|
||||
@@ -154,7 +154,7 @@ fn video_limiter(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
b
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Color Neutralizer
|
||||
// ---------------------------------------------------------------- Color Neutralizer
|
||||
|
||||
fn color_neutralizer(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let pairs = [("shadowsUnbalance", "shadowsBalance"), ("midtonesUnbalance", "midtonesBalance"), ("highlightsUnbalance", "highlightsBalance")];
|
||||
@@ -225,7 +225,7 @@ fn arbitrary_map(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
b
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Lumetri Color
|
||||
// ---------------------------------------------------------------- Color Grading
|
||||
|
||||
/// Smooth tone-curve adjustment: offsets for shadows/midtones/highlights (each -1..1), with the
|
||||
/// end points fixed.
|
||||
@@ -431,7 +431,7 @@ impl HslKey {
|
||||
}
|
||||
}
|
||||
|
||||
/// Lumetri Color: Basic Correction (input LUT, white balance, HDR range, tone, saturation),
|
||||
/// Color Grading: Basic Correction (input LUT, white balance, HDR range, tone, saturation),
|
||||
/// Creative (look, adjustments), Curves (tone sliders, RGB curves, hue/saturation curves),
|
||||
/// Color Wheels, HSL Secondary (key, refine, correction), Vignette, in that order.
|
||||
fn lumetri(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
@@ -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),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
//! Display naming must not change file, command or script identities.
|
||||
use crate::{find, instantiate, lookup, name_matches};
|
||||
use effectcraft_project::build::Ids;
|
||||
|
||||
const RENAMED: &[(&str, &str, &str)] = &[
|
||||
("ec.sim.ccballaction", "CC Ball Action", "Ball Grid"),
|
||||
("ec.distort.ccbendit", "CC Bend It", "Two-Point Bend"),
|
||||
("ec.distort.ccbender", "CC Bender", "Flexible Bend"),
|
||||
("ec.distort.ccblobbylize", "CC Blobbylize", "Organic Displacement"),
|
||||
("ec.stylize.ccblockload", "CC Block Load", "Block Reveal"),
|
||||
("ec.sim.ccbubbles", "CC Bubbles", "Bubbles"),
|
||||
("ec.stylize.ccburnfilm", "CC Burn Film", "Film Burn"),
|
||||
("ec.color.cccolorneutralizer", "CC Color Neutralizer", "Color Neutralizer"),
|
||||
("ec.color.cccoloroffset", "CC Color Offset", "Channel Offset"),
|
||||
("ec.channel.cccomposite", "CC Composite", "Source Composite"),
|
||||
("ec.blur.cccross", "CC Cross Blur", "Cross Blur"),
|
||||
("ec.perspective.cccylinder", "CC Cylinder", "Cylinder Wrap"),
|
||||
("ec.sim.ccdrizzle", "CC Drizzle", "Water Ripples"),
|
||||
("ec.perspective.ccenvironment", "CC Environment", "Environment Map"),
|
||||
("ec.distort.ccflomotion", "CC Flo Motion", "Dual Lens Warp"),
|
||||
("ec.time.ccforcemotionblur", "CC Force Motion Blur", "Temporal Motion Blur"),
|
||||
("ec.stylize.ccglass", "CC Glass", "Glass Relief"),
|
||||
("ec.transition.ccglasswipe", "CC Glass Wipe", "Glass Wipe"),
|
||||
("ec.generate.ccgluegun", "CC Glue Gun", "Paint Beads"),
|
||||
("ec.transition.ccgridwipe", "CC Grid Wipe", "Grid Wipe"),
|
||||
("ec.distort.ccgriddler", "CC Griddler", "Tile Grid"),
|
||||
("ec.sim.cchair", "CC Hair", "Surface Fibers"),
|
||||
("ec.stylize.cchextile", "CC HexTile", "Hexagonal Tiles"),
|
||||
("ec.transition.ccimagewipe", "CC Image Wipe", "Luminance Image Wipe"),
|
||||
("ec.transition.ccjaws", "CC Jaws", "Serrated Wipe"),
|
||||
("ec.stylize.cckaleida", "CC Kaleida", "Kaleidoscope"),
|
||||
("ec.color.cckernel", "CC Kernel", "Convolution Kernel"),
|
||||
("ec.distort.cclens", "CC Lens", "Lens Distortion"),
|
||||
("ec.generate.cclightburst", "CC Light Burst 2.5", "Radial Light Burst"),
|
||||
("ec.generate.cclightrays", "CC Light Rays", "Light Rays"),
|
||||
("ec.generate.cclightsweep", "CC Light Sweep", "Light Sweep"),
|
||||
("ec.transition.cclightwipe", "CC Light Wipe", "Light Wipe"),
|
||||
("ec.transition.cclinesweep", "CC Line Sweep", "Line Sweep"),
|
||||
("ec.sim.ccmrmercury", "CC Mr. Mercury", "Liquid Particles"),
|
||||
("ec.stylize.ccmrsmoothie", "CC Mr. Smoothie", "Palette Map"),
|
||||
("ec.utility.ccoverbrights", "CC Overbrights", "Out-of-Range Display"),
|
||||
("ec.distort.ccpageturn", "CC Page Turn", "Page Curl"),
|
||||
("ec.sim.ccparticlesystems2", "CC Particle Systems II", "Particle Emitter 2D"),
|
||||
("ec.sim.ccparticleworld", "CC Particle World", "Particle Emitter 3D"),
|
||||
("ec.sim.ccpixelpolly", "CC Pixel Polly", "Pixel Shatter"),
|
||||
("ec.stylize.ccplastic", "CC Plastic", "Plastic Shading"),
|
||||
("ec.distort.ccpowerpin", "CC Power Pin", "Perspective Pin"),
|
||||
("ec.blur.ccradial", "CC Radial Blur", "Variable Radial Blur"),
|
||||
("ec.blur.ccradialfast", "CC Radial Fast Blur", "Fast Radial Blur"),
|
||||
("ec.transition.ccradialscalewipe", "CC Radial ScaleWipe", "Radial Scale Wipe"),
|
||||
("ec.sim.ccrainfall", "CC Rainfall", "Rainfall"),
|
||||
("ec.stylize.ccrepetile", "CC RepeTile", "Edge Tile Repeat"),
|
||||
("ec.distort.ccripplepulse", "CC Ripple Pulse", "Pulsed Ripple"),
|
||||
("ec.transition.ccscalewipe", "CC Scale Wipe", "Scale Wipe"),
|
||||
("ec.sim.ccscatterize", "CC Scatterize", "Pixel Scatter"),
|
||||
("ec.key.ccsimplewireremoval", "CC Simple Wire Removal", "Line Removal"),
|
||||
("ec.distort.ccslant", "CC Slant", "Slant"),
|
||||
("ec.distort.ccsmear", "CC Smear", "Directional Smear"),
|
||||
("ec.sim.ccsnowfall", "CC Snowfall", "Snowfall"),
|
||||
("ec.perspective.ccsphere", "CC Sphere", "Sphere Wrap"),
|
||||
("ec.distort.ccsplit", "CC Split", "Split Warp"),
|
||||
("ec.distort.ccsplit2", "CC Split 2", "Dual Split Warp"),
|
||||
("ec.perspective.ccspotlight", "CC Spotlight", "Surface Spotlight"),
|
||||
("ec.sim.ccstarburst", "CC Star Burst", "Starfield"),
|
||||
("ec.generate.ccthreads", "CC Threads", "Woven Threads"),
|
||||
("ec.stylize.ccthreshold", "CC Threshold", "Channel Threshold"),
|
||||
("ec.stylize.ccthresholdrgb", "CC Threshold RGB", "RGB Threshold"),
|
||||
("ec.distort.cctiler", "CC Tiler", "Scaled Tiles"),
|
||||
("ec.color.cctoner", "CC Toner", "Multi-Tone Tint"),
|
||||
("ec.transition.cctwister", "CC Twister", "Twist Wipe"),
|
||||
("ec.blur.ccvector", "CC Vector Blur", "Vector Blur"),
|
||||
("ec.stylize.ccvignette", "CC Vignette", "Vignette"),
|
||||
("ec.transition.ccwarpomatic", "CC WarpoMatic", "Warp Dissolve"),
|
||||
("ec.time.ccwidetime", "CC Wide Time", "Temporal Blend"),
|
||||
("ec.color.lumetri", "Lumetri Color", "Color Grading"),
|
||||
("ec.obsolete.mochashape", "Mocha shape", "Tracked Shape Matte"),
|
||||
("ec.keying.keylight", "Key Light", "Screen Color Key"),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn descriptive_names_keep_every_legacy_lookup_and_search() {
|
||||
for &(id, old, new) in RENAMED {
|
||||
let spec = find(id).unwrap();
|
||||
assert_eq!(spec.name, new, "{id}");
|
||||
for query in [id.to_string(), old.to_string(), old.to_lowercase(), new.to_string()] {
|
||||
assert_eq!(lookup(&query).map(|s| s.id), Some(id), "{query}");
|
||||
}
|
||||
assert!(name_matches(spec, &old.to_lowercase()), "{old}");
|
||||
assert!(name_matches(spec, &new.to_lowercase()), "{new}");
|
||||
}
|
||||
for alias in ["Keylight", "Keylight (1.2)", "Key Light"] {
|
||||
assert_eq!(lookup(alias).map(|s| s.id), Some("ec.keying.keylight"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descriptive_display_names_do_not_collide() {
|
||||
let mut names = std::collections::HashSet::new();
|
||||
for spec in crate::registry() {
|
||||
assert!(names.insert(spec.name.to_lowercase()), "duplicate name {}", spec.name);
|
||||
let label = spec.name.to_lowercase();
|
||||
assert!(!label.starts_with("cc "), "{}", spec.name);
|
||||
for mark in ["lumetri", "mercury", "mocha", "keylight"] {
|
||||
assert!(!label.contains(mark), "{}", spec.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_instance_names_and_parameter_data_survive_refresh() {
|
||||
for &(id, old, _) in RENAMED {
|
||||
let spec = find(id).unwrap();
|
||||
for stored in [old.to_string(), format!("{old} 2"), "My custom instance".to_string()] {
|
||||
let mut next = 1;
|
||||
let mut ids = Ids(&mut next);
|
||||
let mut instance = instantiate(spec, &mut ids, &stored, [128.0, 72.0]);
|
||||
let before = serde_json::to_value(&instance).unwrap();
|
||||
crate::migrate::upgrade_instance(spec, &mut instance, &mut ids, [128.0, 72.0]);
|
||||
assert_eq!(instance.name, stored, "{id}: never rewrite a stored instance name");
|
||||
assert_eq!(serde_json::to_value(&instance).unwrap(), before, "{id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Distort effects, batch 2: Turbulent Displace, Displacement Map (self as map), Optics
|
||||
//! Compensation, Magnify, Mesh Warp, Bezier Warp, CC Bend It, CC Lens, CC Page Turn, CC Tiler,
|
||||
//! CC Griddler.
|
||||
//! Compensation, Magnify, Mesh Warp, Bezier Warp, Two-Point Bend, Lens Distortion, Page Curl, Scaled Tiles,
|
||||
//! Tile Grid.
|
||||
//!
|
||||
//! Mesh Warp and Bezier Warp share a grid rasteriser: a (nx+1)×(ny+1) grid of destination points
|
||||
//! and matching source points is split into triangles, bucketed per output row and filled
|
||||
@@ -550,7 +550,7 @@ pub fn bezier_grid(ctx: &EffectCtx, offset: [f64; 2], scale: f64) -> (usize, Vec
|
||||
(n, dest, srcp)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Bend It
|
||||
// ---------------------------------------------------------------- Two-Point Bend
|
||||
|
||||
fn bend_it(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let mut beta = ctx.params.f("bend").to_radians();
|
||||
@@ -606,7 +606,7 @@ fn bend_it(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
b
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Lens
|
||||
// ---------------------------------------------------------------- Lens Distortion
|
||||
|
||||
fn cc_lens(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let c = b.to_px(ctx.params.v2("center"));
|
||||
@@ -629,7 +629,7 @@ fn cc_lens(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
b
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Page Turn
|
||||
// ---------------------------------------------------------------- Page Curl
|
||||
|
||||
fn page_turn(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let f = b.to_px(ctx.params.v2("foldPosition"));
|
||||
@@ -679,7 +679,7 @@ fn page_turn(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
b
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Tiler / CC Griddler
|
||||
// ---------------------------------------------------------------- Scaled Tiles / Tile Grid
|
||||
|
||||
fn cc_tiler(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let s = (ctx.params.f("scale") / 100.0).max(0.001);
|
||||
@@ -829,7 +829,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.distort.ccbendit",
|
||||
"CC Bend It",
|
||||
"Two-Point Bend",
|
||||
vec![
|
||||
p("bend", "Bend", num(0.0), ParamUi::Angle),
|
||||
p("start", "Start", pt(0.5, 0.2), ParamUi::Point),
|
||||
@@ -840,7 +840,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.distort.cclens",
|
||||
"CC Lens",
|
||||
"Lens Distortion",
|
||||
vec![
|
||||
p("center", "Center", pt(0.5, 0.5), ParamUi::Point),
|
||||
p("size", "Size", num(50.0), slider(0.0, 400.0, 0.0, 200.0, 1)),
|
||||
@@ -850,7 +850,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.distort.ccpageturn",
|
||||
"CC Page Turn",
|
||||
"Page Curl",
|
||||
vec![
|
||||
p("foldPosition", "Fold Position", pt(0.7, 0.7), ParamUi::Point),
|
||||
p("foldDirection", "Fold Direction", num(315.0), ParamUi::Angle),
|
||||
@@ -864,7 +864,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.distort.cctiler",
|
||||
"CC Tiler",
|
||||
"Scaled Tiles",
|
||||
vec![
|
||||
p("scale", "Scale", num(50.0), slider(1.0, 10000.0, 1.0, 200.0, 1)),
|
||||
p("center", "Center", pt(0.5, 0.5), ParamUi::Point),
|
||||
@@ -874,7 +874,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.distort.ccgriddler",
|
||||
"CC Griddler",
|
||||
"Tile Grid",
|
||||
vec![
|
||||
p("horizontalScale", "Horizontal Scale", num(100.0), slider(1.0, 1000.0, 1.0, 400.0, 1)),
|
||||
p("verticalScale", "Vertical Scale", num(100.0), slider(1.0, 1000.0, 1.0, 400.0, 1)),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Distort effects, batch 3: CC Bender, CC Blobbylize, CC Power Pin, CC Ripple Pulse, CC Slant,
|
||||
//! CC Smear, CC Split, CC Split 2, Smear and Reshape (mask driven), Twirl (Legacy), Warp (the
|
||||
//! Distort effects, batch 3: Flexible Bend, Organic Displacement, Perspective Pin, Pulsed Ripple, Slant,
|
||||
//! Directional Smear, Split Warp, Dual Split Warp, Smear and Reshape (mask driven), Twirl (Legacy), Warp (the
|
||||
//! fifteen warp styles) and Detail-preserving Upscale.
|
||||
//!
|
||||
//! All warps are inverse mapped: every output pixel finds the source point that lands on it.
|
||||
@@ -59,7 +59,7 @@ fn elastic_exp(i: u32) -> f64 {
|
||||
[1.0, 1.25, 1.5, 2.0, 2.5, 3.0, 3.5, 4.5][(i as usize).min(7)]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Bender
|
||||
// ---------------------------------------------------------------- Flexible Bend
|
||||
|
||||
fn cc_bender(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let amount = ctx.params.f("amount") / 100.0;
|
||||
@@ -107,7 +107,7 @@ fn norm3(v: [f64; 3]) -> [f64; 3] {
|
||||
[v[0] / l, v[1] / l, v[2] / l]
|
||||
}
|
||||
|
||||
/// f32 normal/dot helpers for the CC Blobbylize lighting pass. The GPU kernel runs in f32;
|
||||
/// f32 normal/dot helpers for the Organic Displacement lighting pass. The GPU kernel runs in f32;
|
||||
/// matching its precision here keeps specular highlights within the CPU/GPU parity tolerance.
|
||||
fn norm3f(v: [f32; 3]) -> [f32; 3] {
|
||||
let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt().max(1e-12);
|
||||
@@ -118,7 +118,7 @@ fn dot3f(a: [f32; 3], b: [f32; 3]) -> f32 {
|
||||
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Blobbylize
|
||||
// ---------------------------------------------------------------- Organic Displacement
|
||||
|
||||
fn cc_blobbylize(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let soft = ctx.params.f("softness").max(0.0) * b.scale;
|
||||
@@ -221,7 +221,7 @@ fn cc_blobbylize(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
b
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Power Pin
|
||||
// ---------------------------------------------------------------- Perspective Pin
|
||||
|
||||
fn cc_power_pin(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let (lw, lh) = (ctx.layer_size[0].max(1.0), ctx.layer_size[1].max(1.0));
|
||||
@@ -304,7 +304,7 @@ fn cc_power_pin(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
b
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Ripple Pulse
|
||||
// ---------------------------------------------------------------- Pulsed Ripple
|
||||
|
||||
fn cc_ripple_pulse(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let level = ctx.params.f("pulseLevel") / 100.0;
|
||||
@@ -350,7 +350,7 @@ fn cc_ripple_pulse(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
b
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Slant
|
||||
// ---------------------------------------------------------------- Slant
|
||||
|
||||
fn cc_slant(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let k = ctx.params.f("slant") / 100.0;
|
||||
@@ -375,7 +375,7 @@ fn cc_slant(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
b
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Smear
|
||||
// ---------------------------------------------------------------- Directional Smear
|
||||
|
||||
fn cc_smear(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let reach = ctx.params.f("reach") / 100.0;
|
||||
@@ -403,7 +403,7 @@ fn cc_smear(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
}
|
||||
|
||||
/// The transparent pixels to add on every side of a buffer `size` pixels big (with `offset` and
|
||||
/// `scale`) for CC Smear: it moves pixels by up to Reach × From→To, and they may move out to the
|
||||
/// `scale`) for Directional Smear: it moves pixels by up to Reach × From→To, and they may move out to the
|
||||
/// layer bounds. A text or shape layer's buffer ends at its content while its bounds are
|
||||
/// comp-sized, so the smear was cut off at the content's box (#659).
|
||||
pub fn cc_smear_pad(ctx: &EffectCtx, size: [u32; 2], offset: [f64; 2], scale: f64) -> u32 {
|
||||
@@ -417,7 +417,7 @@ pub fn cc_smear_pad(ctx: &EffectCtx, size: [u32; 2], offset: [f64; 2], scale: f6
|
||||
(moved.min(4096.0) as u32).min(crate::util::layer_cover_pad(ctx, size, offset, scale))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Split / CC Split 2
|
||||
// ---------------------------------------------------------------- Split Warp / Dual Split Warp
|
||||
|
||||
fn split_impl(ctx: &EffectCtx, mut b: Buf, s1: f64, s2: f64) -> Buf {
|
||||
if s1 == 0.0 && s2 == 0.0 {
|
||||
@@ -913,7 +913,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
vec![
|
||||
spec(
|
||||
"ec.distort.ccbender",
|
||||
"CC Bender",
|
||||
"Flexible Bend",
|
||||
vec![
|
||||
p("amount", "Amount", num(0.0), slider(-200.0, 200.0, -100.0, 100.0, 1)),
|
||||
p("style", "Style", Value::Enum(0), popup(&["Bend", "Marilyn", "Sharp", "Boxer"])),
|
||||
@@ -925,7 +925,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.distort.ccblobbylize",
|
||||
"CC Blobbylize",
|
||||
"Organic Displacement",
|
||||
vec![
|
||||
p("blobLayer", "Blob Layer", Value::Layer(None), ParamUi::Layer),
|
||||
p("property", "Property", Value::Enum(0), popup(&crate::util::SRC_NAMES[..8])),
|
||||
@@ -947,7 +947,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.distort.ccpowerpin",
|
||||
"CC Power Pin",
|
||||
"Perspective Pin",
|
||||
vec![
|
||||
p("topLeft", "Top Left", pt(0.0, 0.0), ParamUi::Point),
|
||||
p("topRight", "Top Right", pt(1.0, 0.0), ParamUi::Point),
|
||||
@@ -964,7 +964,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.distort.ccripplepulse",
|
||||
"CC Ripple Pulse",
|
||||
"Pulsed Ripple",
|
||||
vec![
|
||||
p("center", "Center", pt(0.5, 0.5), ParamUi::Point),
|
||||
p("pulseLevel", "Pulse Level (animate)", num(0.0), slider(-1000.0, 1000.0, -100.0, 100.0, 1)),
|
||||
@@ -976,7 +976,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.distort.ccslant",
|
||||
"CC Slant",
|
||||
"Slant",
|
||||
vec![
|
||||
p("slant", "Slant", num(0.0), slider(-1000.0, 1000.0, -100.0, 100.0, 1)),
|
||||
p("stretching", "Stretching", Value::Bool(false), ParamUi::Checkbox),
|
||||
@@ -987,7 +987,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.distort.ccsmear",
|
||||
"CC Smear",
|
||||
"Directional Smear",
|
||||
vec![
|
||||
p("from", "From", pt(0.35, 0.5), ParamUi::Point),
|
||||
p("to", "To", pt(0.65, 0.5), ParamUi::Point),
|
||||
@@ -998,7 +998,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.distort.ccsplit",
|
||||
"CC Split",
|
||||
"Split Warp",
|
||||
vec![
|
||||
p("pointA", "Point A", pt(0.25, 0.5), ParamUi::Point),
|
||||
p("pointB", "Point B", pt(0.75, 0.5), ParamUi::Point),
|
||||
@@ -1008,7 +1008,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.distort.ccsplit2",
|
||||
"CC Split 2",
|
||||
"Dual Split Warp",
|
||||
vec![
|
||||
p("pointA", "Point A", pt(0.25, 0.5), ParamUi::Point),
|
||||
p("pointB", "Point B", pt(0.75, 0.5), ParamUi::Point),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Distort: CC Flo Motion, Liquify and Rolling Shutter Repair.
|
||||
//! Distort: Dual Lens Warp, Liquify and Rolling Shutter Repair.
|
||||
//!
|
||||
//! * **CC Flo Motion** — two knots that pull the image toward themselves (positive amount) or
|
||||
//! * **Dual Lens Warp** — two knots that pull the image toward themselves (positive amount) or
|
||||
//! push it away (negative), with a falloff exponent, optional tiling and supersampled
|
||||
//! antialiasing.
|
||||
//! * **Liquify** — brush strokes build a displacement mesh (a grid of layer-space offsets). The
|
||||
@@ -32,7 +32,7 @@ fn spec(id: &'static str, name: &'static str, params: Vec<crate::ParamSpec>, ren
|
||||
EffectSpec { id, name, category: "Distort", params, render, gpu: false, float: true }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Flo Motion
|
||||
// ---------------------------------------------------------------- Dual Lens Warp
|
||||
|
||||
fn flo_motion(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let knots = [(ctx.params.v2("knot1"), ctx.params.f("amount1") / 100.0), (ctx.params.v2("knot2"), ctx.params.f("amount2") / 100.0)];
|
||||
@@ -560,7 +560,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
vec![
|
||||
spec(
|
||||
"ec.distort.ccflomotion",
|
||||
"CC Flo Motion",
|
||||
"Dual Lens Warp",
|
||||
vec![
|
||||
p("knot1", "Knot 1", Value::Vec2([0.25, 0.5]), ParamUi::Point),
|
||||
p("amount1", "Amount 1", num(50.0), slider(-400.0, 100.0, -100.0, 100.0, 1)),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Generate effects, batch 2: Cell Pattern (Worley), Ellipse, Lens Flare, Beam, Radio Waves,
|
||||
//! Advanced Lightning, CC Light Rays, CC Light Burst 2.5, CC Light Sweep.
|
||||
//! Advanced Lightning, Light Rays, Radial Light Burst, Light Sweep.
|
||||
//!
|
||||
//! All designs are original procedural renderings written from the public descriptions of what
|
||||
//! each effect produces.
|
||||
@@ -897,7 +897,7 @@ fn advanced_lightning(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
b
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Light Rays / Burst
|
||||
// ---------------------------------------------------------------- Light Rays / Burst
|
||||
|
||||
const RAY_STEPS: usize = 32;
|
||||
|
||||
@@ -1007,7 +1007,7 @@ fn light_burst(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
b
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Light Sweep
|
||||
// ---------------------------------------------------------------- Light Sweep
|
||||
|
||||
fn light_sweep(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let c = b.to_px(ctx.params.v2("center"));
|
||||
@@ -1192,7 +1192,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.generate.cclightrays",
|
||||
"CC Light Rays",
|
||||
"Light Rays",
|
||||
vec![
|
||||
p("intensity", "Intensity", num(100.0), slider(0.0, 600.0, 0.0, 200.0, 1)),
|
||||
p("center", "Center", pt(0.5, 0.5), ParamUi::Point),
|
||||
@@ -1207,7 +1207,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.generate.cclightburst",
|
||||
"CC Light Burst 2.5",
|
||||
"Radial Light Burst",
|
||||
vec![
|
||||
p("center", "Center", pt(0.5, 0.5), ParamUi::Point),
|
||||
p("intensity", "Intensity", num(100.0), slider(0.0, 600.0, 0.0, 200.0, 1)),
|
||||
@@ -1220,7 +1220,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.generate.cclightsweep",
|
||||
"CC Light Sweep",
|
||||
"Light Sweep",
|
||||
vec![
|
||||
p("center", "Center", pt(0.5, 0.5), ParamUi::Point),
|
||||
p("direction", "Direction", num(-30.0), ang()),
|
||||
|
||||
@@ -180,7 +180,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.generate.ccgluegun",
|
||||
"CC Glue Gun",
|
||||
"Paint Beads",
|
||||
vec![
|
||||
p("brushPosition", "Brush Position", pt(0.5, 0.5), ParamUi::Point),
|
||||
p("strokeWidth", "Stroke Width", num(25.0), slider(0.0, 500.0, 0.0, 100.0, 1)),
|
||||
@@ -194,7 +194,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.generate.ccthreads",
|
||||
"CC Threads",
|
||||
"Woven Threads",
|
||||
vec![
|
||||
p("width", "Width", num(50.0), slider(1.0, 1000.0, 1.0, 200.0, 1)),
|
||||
p("height", "Height", num(50.0), slider(1.0, 1000.0, 1.0, 200.0, 1)),
|
||||
|
||||
@@ -317,7 +317,7 @@ fn unmult(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
b
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Simple Wire Removal
|
||||
// ---------------------------------------------------------------- Line Removal
|
||||
|
||||
fn wire_removal(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let pa = ctx.params.v2("pointA");
|
||||
@@ -464,7 +464,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.key.ccsimplewireremoval",
|
||||
"CC Simple Wire Removal",
|
||||
"Line Removal",
|
||||
vec![
|
||||
p("pointA", "Point A", Value::Vec2([0.25, 0.25]), ParamUi::Point),
|
||||
p("pointB", "Point B", Value::Vec2([0.75, 0.75]), ParamUi::Point),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Key Light — a full screen-colour keyer with the controls of After Effects' bundled
|
||||
//! Screen Color Key — a full screen-colour keyer with the controls of After Effects' bundled
|
||||
//! "Keylight (1.2)" (that name is a trademark of its vendor, so the effect is registered as
|
||||
//! **Key Light**; `lookup("Keylight (1.2)")` finds it through [`KEYLIGHT_ALIASES`]).
|
||||
//! **Screen Color Key**; `lookup("Keylight (1.2)")` finds it through [`KEYLIGHT_ALIASES`]).
|
||||
//!
|
||||
//! The method is the textbook *screen difference* key, written from the public description of
|
||||
//! the controls:
|
||||
@@ -31,7 +31,7 @@ use crate::util::{Plane, gauss_plane, morph_frac, point_in_poly, premul, unpremu
|
||||
use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider};
|
||||
|
||||
/// Other names the effect answers to in [`crate::lookup`].
|
||||
pub const KEYLIGHT_ALIASES: &[&str] = &["Keylight (1.2)", "Keylight"];
|
||||
pub const KEYLIGHT_ALIASES: &[&str] = &["Keylight (1.2)", "Keylight", "Key Light"];
|
||||
|
||||
pub const VIEWS: [&str; 11] = [
|
||||
"Source",
|
||||
@@ -463,7 +463,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
let cc = |id: &'static str, name: &'static str| p(id, name, num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 1));
|
||||
vec![EffectSpec {
|
||||
id: "ec.keying.keylight",
|
||||
name: "Key Light",
|
||||
name: "Screen Color Key",
|
||||
category: "Keying",
|
||||
params: vec![
|
||||
p("view", "View", Value::Enum(10), popup(&VIEWS)),
|
||||
@@ -622,7 +622,7 @@ mod tests {
|
||||
let spec = crate::find("ec.keying.keylight").unwrap();
|
||||
let mut next = 1;
|
||||
let mut ids = Ids(&mut next);
|
||||
let mut g = crate::instantiate(spec, &mut ids, "Key Light", [10.0, 10.0]);
|
||||
let mut g = crate::instantiate(spec, &mut ids, "Screen Color Key", [10.0, 10.0]);
|
||||
// Flatten it the way older versions saved it.
|
||||
let mut flat = vec![];
|
||||
g.walk_mut(&mut |p| flat.push(p.clone()));
|
||||
|
||||
@@ -23,6 +23,8 @@ mod color3;
|
||||
mod color_fx;
|
||||
pub mod controls;
|
||||
pub mod crypto_select;
|
||||
#[cfg(test)]
|
||||
mod display_names;
|
||||
mod distort;
|
||||
mod distort2;
|
||||
mod distort3;
|
||||
@@ -36,6 +38,7 @@ mod keying;
|
||||
mod keying2;
|
||||
pub mod keylight;
|
||||
mod matte;
|
||||
|
||||
pub mod migrate;
|
||||
mod misc;
|
||||
pub mod mocha_shape;
|
||||
@@ -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() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Matte ▸ Mocha shape: renders planar-tracked roto shapes as a matte.
|
||||
//! Matte ▸ Tracked Shape Matte: renders planar-tracked roto shapes as a matte.
|
||||
//!
|
||||
//! Mocha's own clipboard / export formats are not publicly specified, so the effect reads shape
|
||||
//! data in a small documented JSON format of ours (in the hidden `shapeData` parameter, either the
|
||||
@@ -184,7 +184,7 @@ fn mocha_shape(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
pub fn specs() -> Vec<EffectSpec> {
|
||||
vec![EffectSpec {
|
||||
id: "ec.obsolete.mochashape",
|
||||
name: "Mocha shape",
|
||||
name: "Tracked Shape Matte",
|
||||
category: "Matte",
|
||||
params: vec![
|
||||
p("blendMode", "Blend mode", Value::Enum(0), popup(&["Add", "Subtract", "Intersect", "Difference"])),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Perspective effects (batch 2): Bevel Alpha, Bevel Edges, Radial Shadow, CC Cylinder,
|
||||
//! CC Sphere and CC Spotlight.
|
||||
//! Perspective effects (batch 2): Bevel Alpha, Bevel Edges, Radial Shadow, Cylinder Wrap,
|
||||
//! Sphere Wrap and Surface Spotlight.
|
||||
//!
|
||||
//! Angles follow the convention used by Drop Shadow: measured clockwise from "up", so a light
|
||||
//! angle `a` sits in the direction `(sin a, -cos a)` (y grows downwards). 3D vectors use x right,
|
||||
@@ -158,7 +158,7 @@ fn radial_shadow(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
b
|
||||
}
|
||||
|
||||
/// Phong-style shading shared by CC Cylinder and CC Sphere.
|
||||
/// Phong-style shading shared by Cylinder Wrap and Sphere Wrap.
|
||||
struct Shading {
|
||||
l: [f64; 3],
|
||||
color: [f32; 4],
|
||||
@@ -451,11 +451,11 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
],
|
||||
radial_shadow,
|
||||
),
|
||||
spec("ec.perspective.cccylinder", "CC Cylinder", cyl, cylinder),
|
||||
spec("ec.perspective.ccsphere", "CC Sphere", sph, sphere),
|
||||
spec("ec.perspective.cccylinder", "Cylinder Wrap", cyl, cylinder),
|
||||
spec("ec.perspective.ccsphere", "Sphere Wrap", sph, sphere),
|
||||
spec(
|
||||
"ec.perspective.ccspotlight",
|
||||
"CC Spotlight",
|
||||
"Surface Spotlight",
|
||||
vec![
|
||||
p("from", "From", pt(0.3, 0.2), ParamUi::Point),
|
||||
p("to", "To", pt(0.5, 0.5), ParamUi::Point),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Perspective effects, batch 2: CC Environment (reflection mapping of an environment layer
|
||||
//! Perspective effects, batch 2: Environment Map (reflection mapping of an environment layer
|
||||
//! onto the layer's luminance/alpha relief) and 3D Glasses (stereo pairs and anaglyphs from a
|
||||
//! left and right view layer).
|
||||
|
||||
@@ -17,7 +17,7 @@ fn spec(id: &'static str, name: &'static str, params: Vec<crate::ParamSpec>, ren
|
||||
EffectSpec { id, name, category: "Perspective", params, render, gpu: false, float: true }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Environment
|
||||
// ---------------------------------------------------------------- Environment Map
|
||||
|
||||
/// Environment direction (x right, y down, z towards the viewer) → normalised map coordinates.
|
||||
fn env_uv(mapping: u32, r: [f64; 3]) -> (f64, f64) {
|
||||
@@ -194,7 +194,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
vec![
|
||||
spec(
|
||||
"ec.perspective.ccenvironment",
|
||||
"CC Environment",
|
||||
"Environment Map",
|
||||
vec![
|
||||
p("environment", "Environment", Value::Layer(None), ParamUi::Layer),
|
||||
p("mapping", "Mapping", Value::Enum(0), popup(&["Spherical", "Probe", "Vertical Cross"])),
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
vec![
|
||||
spec(
|
||||
"ec.sim.ccrainfall",
|
||||
"CC Rainfall",
|
||||
"Rainfall",
|
||||
vec![
|
||||
p("drops", "Drops", num(5000.0), slider(0.0, 100_000.0, 0.0, 20_000.0, 0)),
|
||||
p("size", "Size", num(1.0), slider(0.0, 50.0, 0.0, 10.0, 2)),
|
||||
@@ -53,7 +53,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.sim.ccsnowfall",
|
||||
"CC Snowfall",
|
||||
"Snowfall",
|
||||
vec![
|
||||
p("flakes", "Flakes", num(10_000.0), slider(0.0, 200_000.0, 0.0, 50_000.0, 0)),
|
||||
p("size", "Size", num(3.0), slider(0.0, 50.0, 0.0, 20.0, 2)),
|
||||
@@ -79,7 +79,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.sim.ccbubbles",
|
||||
"CC Bubbles",
|
||||
"Bubbles",
|
||||
vec![
|
||||
p("bubbleAmount", "Bubble Amount", num(100.0), slider(0.0, 10_000.0, 0.0, 1000.0, 0)),
|
||||
p("bubbleSpeed", "Bubble Speed", num(0.5), slider(-10.0, 10.0, -2.0, 2.0, 2)),
|
||||
@@ -93,7 +93,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.sim.ccdrizzle",
|
||||
"CC Drizzle",
|
||||
"Water Ripples",
|
||||
vec![
|
||||
p("dripRate", "Drip Rate", num(10.0), slider(0.0, 200.0, 0.0, 50.0, 2)),
|
||||
p("longevity", "Longevity (sec)", num(1.0), slider(0.0, 30.0, 0.0, 5.0, 2)),
|
||||
@@ -114,7 +114,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.sim.ccstarburst",
|
||||
"CC Star Burst",
|
||||
"Starfield",
|
||||
vec![
|
||||
p("scatter", "Scatter", num(100.0), slider(0.0, 1000.0, 0.0, 300.0, 1)),
|
||||
p("speed", "Speed", num(1.0), slider(-10.0, 10.0, -4.0, 4.0, 2)),
|
||||
@@ -127,7 +127,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.sim.ccparticleworld",
|
||||
"CC Particle World",
|
||||
"Particle Emitter 3D",
|
||||
vec![
|
||||
p("birthRate", "Birth Rate", num(0.5), slider(0.0, 100.0, 0.0, 10.0, 2)),
|
||||
p("longevity", "Longevity (sec)", num(1.0), slider(0.0, 100.0, 0.0, 10.0, 2)),
|
||||
@@ -170,7 +170,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.sim.ccparticlesystems2",
|
||||
"CC Particle Systems II",
|
||||
"Particle Emitter 2D",
|
||||
vec![
|
||||
p("birthRate", "Birth Rate", num(2.0), slider(0.0, 100.0, 0.0, 20.0, 2)),
|
||||
p("longevity", "Longevity (sec)", num(1.0), slider(0.0, 100.0, 0.0, 10.0, 2)),
|
||||
@@ -198,7 +198,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.sim.ccmrmercury",
|
||||
"CC Mr. Mercury",
|
||||
"Liquid Particles",
|
||||
vec![
|
||||
p("radiusX", "Radius X", num(50.0), slider(0.0, 2000.0, 0.0, 300.0, 1)),
|
||||
p("radiusY", "Radius Y", num(50.0), slider(0.0, 2000.0, 0.0, 300.0, 1)),
|
||||
|
||||
@@ -79,7 +79,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
vec![
|
||||
spec(
|
||||
"ec.sim.ccballaction",
|
||||
"CC Ball Action",
|
||||
"Ball Grid",
|
||||
vec![
|
||||
p("scatter", "Scatter", num(0.0), slider(0.0, 2000.0, 0.0, 300.0, 1)),
|
||||
p("rotationAxis", "Rotation Axis", Value::Enum(0), popup(&["X Axis", "Y Axis", "Z Axis", "XY Axis", "XZ Axis", "YZ Axis", "XYZ Axis"])),
|
||||
@@ -94,7 +94,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.sim.ccpixelpolly",
|
||||
"CC Pixel Polly",
|
||||
"Pixel Shatter",
|
||||
vec![
|
||||
p("force", "Force", num(100.0), slider(-1000.0, 1000.0, -300.0, 300.0, 1)),
|
||||
p("gravity", "Gravity", num(1.0), slider(-20.0, 20.0, -5.0, 5.0, 2)),
|
||||
@@ -111,7 +111,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.sim.ccscatterize",
|
||||
"CC Scatterize",
|
||||
"Pixel Scatter",
|
||||
vec![
|
||||
p("scatter", "Scatter", num(0.0), slider(0.0, 1000.0, 0.0, 200.0, 1)),
|
||||
p("rightTwist", "Right Twist", num(0.0), ParamUi::Angle),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Simulation: Particle Playground and CC Hair.
|
||||
//! Simulation: Particle Playground and Surface Fibers.
|
||||
//!
|
||||
//! **Particle Playground** is a deterministic 2D particle system stepped from layer time 0 at
|
||||
//! [`SPS`] steps per second through [`SimCache`] (seeking anywhere gives the same frame as
|
||||
@@ -17,7 +17,7 @@
|
||||
//! being rendered (their pixels are part of the cache key). Particles replace the layer's own
|
||||
//! pixels, as in After Effects.
|
||||
//!
|
||||
//! **CC Hair** grows strands from the layer's opaque pixels (Density per 1 000 px²), each a
|
||||
//! **Surface Fibers** grows strands from the layer's opaque pixels (Density per 1 000 px²), each a
|
||||
//! quadratic curve of Length that droops with Weight, optionally steered and scaled by a hairfall
|
||||
//! map layer (luminance → length, horizontal/vertical gradient → lean), and shades them with a
|
||||
//! Kajiya–Kay-style strand model (diffuse ∝ sin(tangent, light), specular from the half vector).
|
||||
@@ -934,7 +934,7 @@ impl PgPlan {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Hair
|
||||
// ---------------------------------------------------------------- Surface Fibers
|
||||
|
||||
fn cc_hair(ctx: &EffectCtx, b: Buf) -> Buf {
|
||||
let Some(mut plan) = hair_plan(ctx, &b) else { return b };
|
||||
@@ -942,7 +942,7 @@ fn cc_hair(ctx: &EffectCtx, b: Buf) -> Buf {
|
||||
plan.finish(b)
|
||||
}
|
||||
|
||||
/// CC Hair's strands as line sprites (each coloured from its root, see [`Tint::Root`]);
|
||||
/// Surface Fibers's strands as line sprites (each coloured from its root, see [`Tint::Root`]);
|
||||
/// `None` = no hair (the layer passes through).
|
||||
pub(crate) fn hair_plan(ctx: &EffectCtx, b: &Buf) -> Option<SpritePlan> {
|
||||
let pr = ctx.params;
|
||||
@@ -1144,7 +1144,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
spec("ec.sim.particleplayground", "Particle Playground", pg, particle_playground),
|
||||
spec(
|
||||
"ec.sim.cchair",
|
||||
"CC Hair",
|
||||
"Surface Fibers",
|
||||
vec![
|
||||
sp("length", "Length", px(30.0, 200.0)),
|
||||
p("thickness", "Thickness", num(1.0), slider(0.05, 20.0, 0.1, 5.0, 2)),
|
||||
|
||||
@@ -343,7 +343,7 @@ fn brush_strokes(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
b
|
||||
}
|
||||
|
||||
// ---- CC Kaleida ----
|
||||
// ---- Kaleidoscope ----
|
||||
|
||||
fn kaleida(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let c = b.to_px(ctx.params.v2("center"));
|
||||
@@ -368,7 +368,7 @@ fn kaleida(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
b
|
||||
}
|
||||
|
||||
// ---- CC RepeTile ----
|
||||
// ---- Edge Tile Repeat ----
|
||||
|
||||
#[inline]
|
||||
fn tile_coord(v: f64, size: f64) -> (i64, f64) {
|
||||
@@ -491,7 +491,7 @@ fn color_emboss(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
b
|
||||
}
|
||||
|
||||
// ---- CC Vignette ----
|
||||
// ---- Vignette ----
|
||||
|
||||
fn vignette(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let amt = ctx.params.f("amount") as f32 / 100.0;
|
||||
@@ -523,7 +523,7 @@ fn vignette(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
b
|
||||
}
|
||||
|
||||
// ---- CC Threshold / CC Threshold RGB ----
|
||||
// ---- Channel Threshold / RGB Threshold ----
|
||||
|
||||
fn cc_threshold(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let t = ctx.params.f("threshold") as f32 / 255.0;
|
||||
@@ -669,7 +669,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.stylize.cckaleida",
|
||||
"CC Kaleida",
|
||||
"Kaleidoscope",
|
||||
vec![
|
||||
p("center", "Center", pt(0.5, 0.5), ParamUi::Point),
|
||||
p("size", "Size", num(100.0), slider(1.0, 1000.0, 1.0, 400.0, 1)),
|
||||
@@ -680,7 +680,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.stylize.ccrepetile",
|
||||
"CC RepeTile",
|
||||
"Edge Tile Repeat",
|
||||
vec![
|
||||
p("expandRight", "Expand Right", num(0.0), slider(0.0, 4000.0, 0.0, 500.0, 0)),
|
||||
p("expandLeft", "Expand Left", num(0.0), slider(0.0, 4000.0, 0.0, 500.0, 0)),
|
||||
@@ -718,7 +718,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.stylize.ccvignette",
|
||||
"CC Vignette",
|
||||
"Vignette",
|
||||
vec![
|
||||
p("amount", "Amount", num(50.0), slider(-100.0, 100.0, -100.0, 100.0, 1)),
|
||||
p("angleOfView", "Angle of View", num(60.0), slider(0.0, 179.0, 0.0, 179.0, 1)),
|
||||
@@ -729,7 +729,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.stylize.ccthreshold",
|
||||
"CC Threshold",
|
||||
"Channel Threshold",
|
||||
vec![
|
||||
p("threshold", "Threshold", num(127.0), thr()),
|
||||
p("channel", "Channel", Value::Enum(0), popup(&["Luminance", "RGB", "Saturation", "Alpha"])),
|
||||
@@ -740,7 +740,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.stylize.ccthresholdrgb",
|
||||
"CC Threshold RGB",
|
||||
"RGB Threshold",
|
||||
vec![
|
||||
p("redThreshold", "Red Threshold", num(127.0), thr()),
|
||||
p("greenThreshold", "Green Threshold", num(127.0), thr()),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Stylize effects, batch 3: CC Block Load, CC Burn Film, CC Glass, CC HexTile, CC Mr. Smoothie
|
||||
//! and CC Plastic.
|
||||
//! Stylize effects, batch 3: Block Reveal, Film Burn, Glass Relief, Hexagonal Tiles, Palette Map
|
||||
//! and Plastic Shading.
|
||||
//!
|
||||
//! Written from the public descriptions of what these effects do (progressive block loading,
|
||||
//! burning film, bump-mapped glass / plastic surfaces lit by an effect light, hexagonal tiling,
|
||||
@@ -27,7 +27,7 @@ fn pt(x: f64, y: f64) -> Value {
|
||||
Value::Vec2([x, y])
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ CC Block Load
|
||||
// ------------------------------------------------------------------ Block Reveal
|
||||
|
||||
/// Mean colour of every `s`×`s` block (grid of `ceil(w/s)`×`ceil(h/s)`).
|
||||
fn block_means(img: &Image, s: usize) -> (usize, usize, Vec<Px>) {
|
||||
@@ -135,7 +135,7 @@ fn block_load(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
b
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ CC Burn Film
|
||||
// ------------------------------------------------------------------ Film Burn
|
||||
|
||||
fn burn_film(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let burn = (ctx.params.f("burn") / 100.0).clamp(0.0, 1.0) as f32;
|
||||
@@ -180,7 +180,7 @@ fn burn_film(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
|
||||
const BUMP_PROPS: [&str; 6] = ["Red", "Green", "Blue", "Alpha", "Luminance", "Lightness"];
|
||||
|
||||
/// The bump-map layer parameter: CC Glass's Bump Map, CC Plastic's Bump Layer.
|
||||
/// The bump-map layer parameter: Glass Relief's Bump Map, Plastic Shading's Bump Layer.
|
||||
pub fn bump_layer_id(ctx: &EffectCtx) -> &'static str {
|
||||
if ctx.params.get("bumpLayer").is_some() { "bumpLayer" } else { "bumpMap" }
|
||||
}
|
||||
@@ -208,7 +208,7 @@ fn height_field(ctx: &EffectCtx, b: &Buf) -> Plane {
|
||||
pl
|
||||
}
|
||||
|
||||
/// The effect light and surface of CC Glass / CC Plastic (shared with the GPU compositor).
|
||||
/// The effect light and surface of Glass Relief / Plastic Shading (shared with the GPU compositor).
|
||||
pub struct BumpLight {
|
||||
pub intensity: f32,
|
||||
pub color: [f32; 3],
|
||||
@@ -342,7 +342,7 @@ fn plastic(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
b
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ CC HexTile
|
||||
// ------------------------------------------------------------------ Hexagonal Tiles
|
||||
|
||||
/// Axial hex cell (pointy-top) containing (x, y) for circumradius `r`; returns the cell centre
|
||||
/// and integer cell coordinates.
|
||||
@@ -418,7 +418,7 @@ fn hextile(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
b
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ CC Mr. Smoothie
|
||||
// ------------------------------------------------------------------ Palette Map
|
||||
|
||||
const SMOOTHIE_PROPS: [&str; 8] = ["Red", "Green", "Blue", "Alpha", "Luminance", "Lightness", "Hue", "Saturation"];
|
||||
|
||||
@@ -518,7 +518,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
vec![
|
||||
spec(
|
||||
"ec.stylize.ccblockload",
|
||||
"CC Block Load",
|
||||
"Block Reveal",
|
||||
vec![
|
||||
p("completion", "Completion", num(0.0), pct()),
|
||||
p("scanlines", "Scanlines", Value::Bool(true), ParamUi::Checkbox),
|
||||
@@ -530,7 +530,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.stylize.ccburnfilm",
|
||||
"CC Burn Film",
|
||||
"Film Burn",
|
||||
vec![
|
||||
p("burn", "Burn", num(0.0), pct()),
|
||||
p("center", "Center", pt(0.5, 0.5), ParamUi::Point),
|
||||
@@ -538,10 +538,10 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
],
|
||||
burn_film,
|
||||
),
|
||||
spec("ec.stylize.ccglass", "CC Glass", glass_p, glass),
|
||||
spec("ec.stylize.ccglass", "Glass Relief", glass_p, glass),
|
||||
spec(
|
||||
"ec.stylize.cchextile",
|
||||
"CC HexTile",
|
||||
"Hexagonal Tiles",
|
||||
vec![
|
||||
p("render", "Render", Value::Enum(0), popup(&["Normal", "Mirrored", "Rotated"])),
|
||||
p("radius", "Radius", num(50.0), slider(1.0, 4000.0, 2.0, 500.0, 1)),
|
||||
@@ -553,7 +553,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.stylize.ccmrsmoothie",
|
||||
"CC Mr. Smoothie",
|
||||
"Palette Map",
|
||||
vec![
|
||||
p("flowLayer", "Flow Layer", Value::Layer(None), ParamUi::Layer),
|
||||
p("property", "Property", Value::Enum(4), popup(&SMOOTHIE_PROPS)),
|
||||
@@ -565,7 +565,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
],
|
||||
smoothie,
|
||||
),
|
||||
spec("ec.stylize.ccplastic", "CC Plastic", plastic_p, plastic),
|
||||
spec("ec.stylize.ccplastic", "Plastic Shading", plastic_p, plastic),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Transition effects (batch 2): Block Dissolve, Gradient Wipe (gradient layer or self), Iris Wipe, Card
|
||||
//! Wipe (3D cards with camera, lighting and jitter), CC Grid Wipe, CC Radial ScaleWipe, CC Scale Wipe and CC Light Wipe.
|
||||
//! Wipe (3D cards with camera, lighting and jitter), Grid Wipe, Radial Scale Wipe, Scale Wipe and Light Wipe.
|
||||
//!
|
||||
//! Every transition is the identity at 0 % completion. Angles are clockwise from "up".
|
||||
|
||||
@@ -595,7 +595,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.transition.ccgridwipe",
|
||||
"CC Grid Wipe",
|
||||
"Grid Wipe",
|
||||
vec![
|
||||
cc_done(),
|
||||
p("center", "Center", pt(0.5, 0.5), ParamUi::Point),
|
||||
@@ -609,13 +609,13 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.transition.ccradialscalewipe",
|
||||
"CC Radial ScaleWipe",
|
||||
"Radial Scale Wipe",
|
||||
vec![cc_done(), p("center", "Center", pt(0.5, 0.5), ParamUi::Point), p("reverse", "Reverse Transition", Value::Bool(false), ParamUi::Checkbox)],
|
||||
radial_scale_wipe,
|
||||
),
|
||||
spec(
|
||||
"ec.transition.ccscalewipe",
|
||||
"CC Scale Wipe",
|
||||
"Scale Wipe",
|
||||
vec![
|
||||
p("stretch", "Stretch", num(0.0), slider(0.0, 1000.0, 0.0, 50.0, 2)),
|
||||
p("center", "Center", pt(0.5, 0.5), ParamUi::Point),
|
||||
@@ -625,7 +625,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.transition.cclightwipe",
|
||||
"CC Light Wipe",
|
||||
"Light Wipe",
|
||||
vec![
|
||||
cc_done(),
|
||||
p("center", "Center", pt(0.5, 0.5), ParamUi::Point),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Transition effects, batch 2: CC Glass Wipe, CC Image Wipe, CC Jaws, CC Line Sweep,
|
||||
//! CC Twister and CC WarpoMatic.
|
||||
//! Transition effects, batch 2: Glass Wipe, Luminance Image Wipe, Serrated Wipe, Line Sweep,
|
||||
//! Twist Wipe and Warp Dissolve.
|
||||
//!
|
||||
//! Written from the public descriptions of these transitions (gradient-driven wipes with glass
|
||||
//! refraction or warping, jagged "jaws" opening, staggered line sweeps, a ribbon twist that
|
||||
@@ -68,7 +68,7 @@ fn grad(pl: &Plane, x: usize, y: usize) -> (f32, f32) {
|
||||
((pl.get_clamped(xi + 1, yi) - pl.get_clamped(xi - 1, yi)) * 0.5, (pl.get_clamped(xi, yi + 1) - pl.get_clamped(xi, yi - 1)) * 0.5)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ CC Glass Wipe
|
||||
// ------------------------------------------------------------------ Glass Wipe
|
||||
|
||||
fn glass_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let c = completion(ctx);
|
||||
@@ -105,7 +105,7 @@ fn glass_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
b
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ CC Image Wipe
|
||||
// ------------------------------------------------------------------ Luminance Image Wipe
|
||||
|
||||
fn image_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let c = completion(ctx);
|
||||
@@ -132,7 +132,7 @@ fn image_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
b
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ CC Jaws
|
||||
// ------------------------------------------------------------------ Serrated Wipe
|
||||
|
||||
fn jaws(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let c = completion(ctx) as f64;
|
||||
@@ -193,7 +193,7 @@ fn jaws(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
b
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ CC Line Sweep
|
||||
// ------------------------------------------------------------------ Line Sweep
|
||||
|
||||
fn line_sweep(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let c = completion(ctx) as f64;
|
||||
@@ -235,7 +235,7 @@ fn line_sweep(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
b
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ CC Twister
|
||||
// ------------------------------------------------------------------ Twist Wipe
|
||||
|
||||
fn twister(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let c = completion(ctx) as f64;
|
||||
@@ -283,7 +283,7 @@ fn twister(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
b
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ CC WarpoMatic
|
||||
// ------------------------------------------------------------------ Warp Dissolve
|
||||
|
||||
fn warpomatic(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let c = completion(ctx);
|
||||
@@ -350,7 +350,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
vec![
|
||||
spec(
|
||||
"ec.transition.ccglasswipe",
|
||||
"CC Glass Wipe",
|
||||
"Glass Wipe",
|
||||
vec![
|
||||
done(),
|
||||
p("layerToReveal", "Layer to Reveal", Value::Layer(None), ParamUi::Layer),
|
||||
@@ -362,7 +362,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.transition.ccimagewipe",
|
||||
"CC Image Wipe",
|
||||
"Luminance Image Wipe",
|
||||
vec![
|
||||
done(),
|
||||
p("borderSoftness", "Border Softness", num(0.0), pct()),
|
||||
@@ -376,7 +376,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.transition.ccjaws",
|
||||
"CC Jaws",
|
||||
"Serrated Wipe",
|
||||
vec![
|
||||
done(),
|
||||
p("center", "Center", pt(0.5, 0.5), ParamUi::Point),
|
||||
@@ -389,7 +389,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.transition.cclinesweep",
|
||||
"CC Line Sweep",
|
||||
"Line Sweep",
|
||||
vec![
|
||||
done(),
|
||||
p("direction", "Direction", num(0.0), ParamUi::Angle),
|
||||
@@ -401,7 +401,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.transition.cctwister",
|
||||
"CC Twister",
|
||||
"Twist Wipe",
|
||||
vec![
|
||||
done(),
|
||||
p("backside", "Backside", Value::Layer(None), ParamUi::Layer),
|
||||
@@ -413,7 +413,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
),
|
||||
spec(
|
||||
"ec.transition.ccwarpomatic",
|
||||
"CC WarpoMatic",
|
||||
"Warp Dissolve",
|
||||
vec![
|
||||
done(),
|
||||
p("layerToReveal", "Layer to Reveal", Value::Layer(None), ParamUi::Layer),
|
||||
|
||||
@@ -365,7 +365,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
spec("ec.utility.growbounds", "Grow Bounds", vec![p("pixels", "Pixels", num(0.0), slider(0.0, 4000.0, 0.0, 500.0, 0))], grow_bounds),
|
||||
spec(
|
||||
"ec.utility.ccoverbrights",
|
||||
"CC Overbrights",
|
||||
"Out-of-Range Display",
|
||||
vec![
|
||||
p("channel", "Channel", Value::Enum(0), popup(&["Max RGB", "Red", "Green", "Blue", "Alpha", "Luminance"])),
|
||||
p("highlightColor", "Highlight Color", col(1.0, 0.0, 0.0), ParamUi::Color),
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 => {}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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> =
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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:?}");
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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`
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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?}");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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]) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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![];
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
@@ -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, ¶ms, 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: ¶ms, 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
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in new issue
Block a user