Merge main into M9.6 text animation branch

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-02 00:14:33 -04:00
commit f5f0b7ef52
24 files changed
+2364 -94

No files matched your search

+12
View File
@@ -17,6 +17,18 @@ iconography, images or other Adobe assets; open licences only; every asset attri
| `assets/fonts/NotoSerif-Regular.ttf` | The Noto Project Authors | https://github.com/notofonts/latin-greek-cyrillic | OFL-1.1 (`assets/fonts/OFL-NotoSerif.txt`) |
| `assets/fonts/JetBrainsMono-Regular.ttf` | The JetBrains Mono Project Authors | https://github.com/JetBrains/JetBrainsMono | OFL-1.1 (`assets/fonts/OFL-JetBrainsMono.txt`) |
## Screenshots
Screenshots of EffectCraft itself, rendered headlessly, showing only procedurally generated demo content.
| Asset | Author | Source | Licence |
|---|---|---|---|
| `docs/images/effectcraft-hero.png` | EffectCraft contributors | Original work: screenshot of EffectCraft (EffectCraft main window) | MIT OR Apache-2.0 |
| `docs/images/effectcraft-3d.png` | EffectCraft contributors | Original work: screenshot of EffectCraft (EffectCraft Classic 3D) | MIT OR Apache-2.0 |
| `docs/images/effectcraft-graph-editor.png` | EffectCraft contributors | Original work: screenshot of EffectCraft (EffectCraft Graph Editor) | MIT OR Apache-2.0 |
| `docs/images/effectcraft-effects.png` | EffectCraft contributors | Original work: screenshot of EffectCraft (EffectCraft effects) | MIT OR Apache-2.0 |
| `docs/images/effectcraft-render-queue.png` | EffectCraft contributors | Original work: screenshot of EffectCraft (EffectCraft Render Queue panel with the demo compositions queued for H.264 and ProRes export) | MIT OR Apache-2.0 |
## Code-drawn assets (original work, MIT OR Apache-2.0)
- UI icons drawn in code: `crates/ui-egui/src/icons.rs`.
+96 -66
View File
@@ -15,12 +15,12 @@
<p align="center">
A free, open-source compositor in the spirit of After Effects: compositions, layers,
keyframes, effects and expressions, native on macOS, Windows and Linux, and in the browser later on.
It is in early development. The engine crates are being written now and there is no app to run yet.
keyframes, 241 effects, expressions, 3D cameras and lights, and a render queue, native on macOS,
Windows and Linux, and in the browser later on. Young, moving fast, and already usable.
</p>
<p align="center">
<img alt="Status: early development" src="https://img.shields.io/badge/status-early%20development-e0368f?style=flat-square">
<img alt="Status: young and moving fast" src="https://img.shields.io/badge/status-young%20and%20moving%20fast-e0368f?style=flat-square">
<img alt="Written in Rust" src="https://img.shields.io/badge/rust-1.95%2B-b0206c?style=flat-square&logo=rust&logoColor=white">
<img alt="License: MIT or Apache-2.0" src="https://img.shields.io/badge/license-MIT%20%2F%20Apache--2.0-555?style=flat-square">
</p>
@@ -35,6 +35,12 @@
<a href="https://getartcraft.com/apps">All Crafting Apps</a>
</p>
<br>
<p align="center">
<img src="docs/images/effectcraft-hero.png" alt="EffectCraft's main window: the animated demo composition in the Composition panel, the Project panel, a Timeline with text, shape and solid layers, and the Properties panel showing the selected text layer's transform, font and paragraph settings" width="100%">
</p>
> [!NOTE]
> **ArtCraft is a community of artists from all walks of life.** Painters, photographers,
> filmmakers, illustrators, designers, animators, hobbyists, and people who picked up a pencil
@@ -42,9 +48,13 @@
<p align="center">
<a href="#what-effectcraft-is">What it is</a> ·
<a href="#where-it-stands">Where it stands</a> ·
<a href="#the-plan">The plan</a> ·
<a href="#building-from-source">Building</a> ·
<a href="#animate">Animate</a> ·
<a href="#effects">Effects</a> ·
<a href="#3d">3D</a> ·
<a href="#export">Export</a> ·
<a href="#built-for-agents">Agents</a> ·
<a href="#get-started">Get started</a> ·
<a href="#where-it-stands">Status</a> ·
<a href="#how-its-made">How it's made</a> ·
<a href="#the-crafting-apps">The Crafting Apps</a> ·
<a href="#license-and-credits">License</a>
@@ -71,84 +81,104 @@ then to go further in a few places where it matters to us:
- **No FFmpeg.** Video and audio decoding and encoding come from FilmCraft's own pure-Rust
codecs.
## Where it stands
## Animate
> [!IMPORTANT]
> **There is no app to run yet.** EffectCraft is in its first milestone. The planning is done and
> the low-level engine crates are being written. The windowed app, the renderer and the effects
> come next. If you want to follow along or help shape it, the
> [Discord](https://discord.gg/artcraft) is the place.
The panels, menus and shortcuts follow After Effects, so your muscle memory carries over:
Project, Composition, Timeline, Effect Controls, Properties, Effects & Presets, Character,
Paragraph, Align, Info, Preview, Audio and the Render Queue, docked the way you expect.
| | What | State |
|---|---|---|
| **Built** | The plan: an After Effects UI and feature reference, a full effects list, the architecture and a milestone plan (see [`plan/`](plan/)) | Done |
| **In progress** | Engine foundation crates: time, geometry, color and blend modes, keyframes, the project model, raster images, Bezier paths, text | Early code and first tests |
| **Next** | The compositor, a first set of effects, the editing engine and the After Effects style interface rendering an animated demo composition | Not started |
| **Later** | Precomps and preview, shapes and masks, 3D, expressions, text animators, export, GPU rendering, the web build | Planned |
- **Layers of every kind:** solids, shapes, text, footage, nested compositions, nulls,
adjustment layers, cameras and lights; parenting, track mattes, all 38 blend modes, motion blur.
- **Keyframes that behave the same:** linear, Bezier, hold, auto and continuous Bezier, roving
keys, Easy Ease (F9), Keyframe Velocity and Interpolation dialogs, copy and paste at the current
time, and a **Graph Editor** with value and speed graphs and draggable handles.
- **Time:** time remapping, time stretch, time-reverse, freeze frame, work area, markers, exact
frame-accurate timing at every frame rate including 29.97 drop-frame.
- **Shapes and masks:** shape layers with trim paths, repeaters, round corners, offset, zig zag,
twist, wiggle, merge paths and gradient strokes; masks drawn with the pen tool, with modes,
feather, expansion and vertex editing in the viewer.
- **Text:** point and paragraph text with real shaping, the Character and Paragraph panels, and
text animators with range selectors.
- **Expressions:** JavaScript with the After Effects object model (`wiggle`, `loopOut`,
`thisComp.layer("…")`, vector maths on arrays), an inline editor and the pick-whip.
The live checklist is [`plan/STATUS.md`](plan/STATUS.md).
<p align="center">
<img src="docs/images/effectcraft-graph-editor.png" alt="The Graph Editor showing an eased value curve for a text animator's Range Start, with the graph editor's button bar for hold, linear, auto Bezier and Easy Ease" width="100%">
</p>
### The crates so far
## Effects
| Crate | What it does |
|---|---|
| `effectcraft-time` | Exact media time in integer ticks, so 23.976 and 29.97 fps, drop-frame timecode and audio sample rates never drift |
| `effectcraft-geom` | 2D and 3D vectors, matrices and the layer transform (anchor, position, scale, rotation, orientation) |
| `effectcraft-color` | RGBA color, sRGB transfer, HSL and HSV, and the 38 After Effects blend modes |
| `effectcraft-keyframe` | Animated values: linear, hold and Bezier keyframes with speed and influence, and spatial motion paths |
| `effectcraft-project` | The document: project items, compositions, layers and the property tree |
| `effectcraft-raster` | Floating-point premultiplied images with warps, blurs and compositing, parallel across cores |
| `effectcraft-path` | Bezier shapes, path operations (trim, round corners, zig zag, offset and more), stroking and anti-aliased fills |
| `effectcraft-text` | Fonts, shaping, bidirectional text, line breaking and glyph outlines for text layers |
241 effects across After Effects' categories, each with its parameter names, order and
defaults: blur and sharpen, channel, color correction (Curves, Levels, Hue/Saturation, Lumetri
Color…), distort (Warp, Bulge, Turbulent Displace, CC Power 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…), text, transitions, utility and expression controls.
These are moving fast. Expect names and APIs to change.
<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>
## The plan
## 3D
EffectCraft is built in milestones. The first one is about getting something you can see on
screen as early as possible, then filling in depth underneath.
Classic 3D the way After Effects does it: 3D layers with orientation and material options,
one- and two-node **cameras** with depth of field, **lights** (parallel, spot, point, ambient)
with soft ray-traced shadows, layers that intersect correctly, orbit, pan and dolly camera
tools, and Front, Top, Left and Custom views.
| | Milestone | What you'll get |
|---|---|---|
| **M0** | Skeleton and visual shell | The full panel layout over a real CPU compositor playing an animated demo composition, about 8 effects, and the control channel and MCP server |
| M1 | Keyframes | Complete temporal and spatial interpolation, Easy Ease, roving keys, velocity |
| M2 | Compositing | All 38 blend modes, track mattes, parenting, 8/16/32 bit per channel behaviour |
| M3 | Project operations | Composition and layer settings, layer commands, save and open `.ecproj`, undo, clipboard |
| M4 | Preview and precomps | Nested compositions, adjustment layers, motion blur, cached RAM preview with audio |
| M5 | Timeline depth | Keyframe editing, the graph editor, markers, trims, time remapping |
| M6 | Shapes, masks and footage | Shape layers and path operations, masks, the pen tool, video and image import |
| M7 | 3D | 3D layers, cameras and lights |
| M8 | Expressions | JavaScript expressions with the After Effects object model (`wiggle`, `loopOut`, `thisComp` and friends) |
| M9 | Text and effects | Text animators, text on a path, layer styles, the first 60 effects |
| M10 | Export | Render queue, image sequences, H.264 and ProRes, GIF, command-line rendering |
| M11 | Animation tools | Motion Sketch, Wiggler, presets, Lottie import and export, audio |
| M12 | Performance | GPU compositing and effects, disk cache, motion tracking |
| M13 to M16 | Beyond | More effects, puppet and paint tools, polish, the web app, a plugin API |
<p align="center">
<img src="docs/images/effectcraft-3d.png" alt="The 3D Showcase demo in Custom View 1: intersecting cards lit by a spot light with soft shadows on a gridded floor, with the camera frustum and light drawn as wireframes" width="100%">
</p>
The details, with an acceptance test for every task, are in
[`plan/execution-plan.md`](plan/execution-plan.md) and [`plan/architecture.md`](plan/architecture.md).
## Export
## Building from source
A Render Queue like After Effects', with Render Settings and Output Modules: **H.264** MP4 and
**ProRes** MOV (Proxy to 4444 XQ with alpha) with audio, PNG, JPEG, TIFF and 32-bit EXR
sequences, and animated GIF. The same queue runs from the command line. Every encoder is
FilmCraft's pure-Rust code; there is no FFmpeg inside.
You need [Rust](https://rustup.rs/) 1.95 or newer. There is nothing to launch yet, but you can
build and test the engine crates:
<p align="center">
<img src="docs/images/effectcraft-render-queue.png" alt="The Render Queue panel with two compositions queued" width="100%">
</p>
## Built for agents
Everything you can do from a menu is a command with an id, and agents can reach every one of
them:
- **MCP server:** `effectcraft-cli mcp` speaks the Model Context Protocol over stdio, headless
or bridged to the running app (`--bridge 9877`). Tools cover commands, the project and property
tree, keyframes and rendered frames. This repository ships a ready [`.mcp.json`](.mcp.json).
- **Command line:** one-shot calls with JSON output, for example
`effectcraft-cli set Main '#1' transform/position '[100,360]' --time 0 main.ecproj --save`
or `effectcraft-cli render --comp Main --out main.mp4`.
- **Control channel:** `effectcraft --control 9877` accepts JSON lines to run commands, inspect
and click any widget by its automation id, and take screenshots.
See [docs/agents.md](docs/agents.md) and [docs/control-protocol.md](docs/control-protocol.md).
## Get started
You need [Rust](https://rustup.rs/) 1.95 or newer.
```sh
git clone https://github.com/storytold/effectcraft
cd effectcraft
cargo build
cargo test
cargo run --release -p effectcraft # the app, with the demo project open
cargo run --release -p effectcraft-cli -- render --out intro.mp4 # render the demo headless
```
To work on one crate:
To work on it: `cargo test --workspace`, and `cargo xtask ci` before every commit (format, lints,
tests, layering, asset attribution and the WebAssembly build). See [CONTRIBUTING.md](CONTRIBUTING.md),
[docs/architecture.md](docs/architecture.md) and [docs/testing.md](docs/testing.md).
```sh
cargo test -p effectcraft-keyframe
```
## Where it stands
The workspace picks up every crate under `crates/`, so while a new crate is half-written the
whole build can break for a while. The `cargo xtask` helpers named in the plan (layer checks,
asset checks, CI) are not written yet.
EffectCraft is young. The core of After Effects is in place and gets deeper every week: what is
done and what comes next is in the [ROADMAP](ROADMAP.md). Not there yet: layer styles, time and
audio effects, audio playback, motion tracking, puppet and paint tools, the web build and GPU
compositing. Projects are saved as `.ecproj`, readable versioned JSON; After Effects `.aep`
files cannot be opened.
## How it's made
@@ -156,10 +186,10 @@ asset checks, CI) are not written yet.
top and nothing underneath depends on it.
- **Clean room.** We work from After Effects' public documentation and how it behaves, never
from Adobe's files, icons, presets or code. No code is copied from GPL projects. The full rules
are in [`plan/README.md`](plan/README.md).
are in [AGENTS.md](AGENTS.md).
- **Private by default.** No telemetry, and no network access unless you ask for it.
- **One family.** EffectCraft shares its time model and text engine design with FilmCraft, and
gets its video and audio codecs from it (see [`plan/adr/`](plan/adr/)).
gets its video and audio codecs from it.
## The Crafting Apps
+2
View File
@@ -14,6 +14,7 @@ mod mask;
mod prop;
mod query;
mod render_queue;
mod styles;
mod text_anim;
mod three_d;
mod time;
@@ -75,6 +76,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(edit::specs());
v.extend(comp::specs());
v.extend(layer::specs());
v.extend(styles::specs());
v.extend(three_d::specs());
v.extend(text_anim::specs());
v.extend(layer_time::specs());
+244
View File
@@ -0,0 +1,244 @@
//! Layer ▸ Layer Styles: add / remove / show styles and the comp's Global Light.
//!
//! Styles are property groups under the layer's `layerStyles` group (see
//! [`effectcraft_project::styles`]); their values are edited with `prop.set` like any other
//! property (`layerStyles/dropShadow/distance`). Global Light is shared by every layer of a comp:
//! editing one layer's `layerStyles/blendingOptions/globalLightAngle` updates them all.
use effectcraft_project::build::Ids;
use effectcraft_project::styles::{self as st, GROUP, STYLES};
use effectcraft_project::{ItemId, LayerId, Uid};
use serde_json::{Value, json};
use super::{CommandSpec, b_p, bad, f_p, has_comp, has_layers, layer_mut, layer_p, layers_p, str_p, time_p};
use crate::{EngineError, Result, Session, cmd};
/// Selected (or given) layers that take layer styles.
fn style_layers(s: &Session, p: &Value) -> Result<(ItemId, Vec<LayerId>)> {
let (cid, ids) = layers_p(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
Ok((cid, ids.into_iter().filter(|id| comp.layer(*id).is_some_and(|l| l.can_have_styles())).collect()))
}
fn add_styles(s: &mut Session, p: &Value, cmd: &str, styles: &[&str], enabled: bool, label: &str) -> Result<Value> {
let (cid, ids) = style_layers(s, p)?;
if ids.is_empty() {
return Err(bad(cmd, "no layer that takes layer styles (footage, solid, precomp, text or shape layer)"));
}
let uids = s.edit(label, None, |proj, state| {
let gl = proj.comp(cid).ok_or(EngineError::NoComp)?.global_light.clone();
let mut out = vec![];
let mut sel = vec![];
for lid in &ids {
let mut next = proj.next_id;
let l = layer_mut(proj, cid, *lid)?;
for id in styles {
let had = l.layer_styles().and_then(|g| g.sub(id)).is_some();
let uid = st::add_style(l, &mut Ids(&mut next), id, &gl, enabled).ok_or_else(|| bad(cmd, format!("unknown style `{id}`")))?;
// Choosing a style that is already there (e.g. after Show All) switches it on.
if had
&& enabled
&& let Some(g) = l.props.find_group_mut(uid)
{
g.enabled = true;
}
if let Some(g) = l.props.sub_mut(GROUP) {
g.enabled = true;
}
out.push(json!({"layer": lid.0, "style": id, "uid": uid}));
sel.push((*lid, uid));
}
proj.next_id = next;
}
state.selected_props = sel;
Ok(out)
})?;
Ok(json!({"styles": uids}))
}
fn add(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_p(p, "style").ok_or_else(|| bad("layer.style.add", "missing `style` (e.g. dropShadow or \"Drop Shadow\")"))?;
let id = st::style_id(name)
.ok_or_else(|| bad("layer.style.add", format!("unknown style `{name}`; one of {}", STYLES.iter().map(|s| s.0).collect::<Vec<_>>().join(", "))))?;
let label = STYLES.iter().find(|s| s.0 == id).map(|s| s.1).unwrap_or(id);
add_styles(s, p, "layer.style.add", &[id], true, label)
}
macro_rules! style_cmd {
($fn:ident, $id:literal) => {
fn $fn(s: &mut Session, p: &Value) -> Result<Value> {
let label = STYLES.iter().find(|x| x.0 == $id).map(|x| x.1).unwrap_or($id);
add_styles(s, p, concat!("layer.style.", $id), &[$id], true, label)
}
};
}
style_cmd!(drop_shadow, "dropShadow");
style_cmd!(inner_shadow, "innerShadow");
style_cmd!(outer_glow, "outerGlow");
style_cmd!(inner_glow, "innerGlow");
style_cmd!(bevel_emboss, "bevelEmboss");
style_cmd!(satin, "satin");
style_cmd!(color_overlay, "colorOverlay");
style_cmd!(gradient_overlay, "gradientOverlay");
style_cmd!(stroke, "stroke");
/// Show All: every style is added (new ones switched off) so they can be revealed and edited.
fn show_all(s: &mut Session, p: &Value) -> Result<Value> {
let all: Vec<&str> = STYLES.iter().map(|s| s.0).collect();
add_styles(s, p, "layer.style.showAll", &all, false, "Show All Layer Styles")
}
fn remove_all(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = style_layers(s, p)?;
let n = s.edit("Remove All Layer Styles", None, |proj, state| {
let mut n = 0;
for lid in &ids {
let l = layer_mut(proj, cid, *lid)?;
if let Some(g) = l.layer_styles() {
let gone: Vec<Uid> = std::iter::once(g.uid).chain(g.groups().map(|x| x.uid)).collect();
l.props.children.retain(|c| c.match_id() != GROUP);
state.selected_props.retain(|(_, u)| !gone.contains(u));
n += 1;
}
}
Ok(n)
})?;
Ok(json!({"layers": n}))
}
/// A style group of a layer by match id, display name, uid or the selection.
fn find_style(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, LayerId, Uid)> {
let (cid, lid) = layer_p(s, p, cmd)?;
let layer = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?;
let g = layer.layer_styles().ok_or_else(|| bad(cmd, "the layer has no layer styles"))?;
let found = match p.get("style") {
Some(Value::Number(n)) => {
let n = n.as_u64().unwrap_or(0);
if n == g.uid { Some(g.uid) } else { g.groups().find(|x| x.uid == n).map(|x| x.uid) }
}
Some(Value::String(name)) if name == GROUP || name.eq_ignore_ascii_case("Layer Styles") => Some(g.uid),
Some(Value::String(name)) => {
let id = st::style_id(name).unwrap_or(name.as_str());
g.groups().find(|x| x.match_id == id || x.name.eq_ignore_ascii_case(name)).map(|x| x.uid)
}
_ => s.state.selected_props.iter().find_map(|(l, u)| (*l == lid).then(|| g.groups().find(|x| x.uid == *u).map(|x| x.uid)).flatten()),
}
.ok_or_else(|| bad(cmd, "no such layer style"))?;
Ok((cid, lid, found))
}
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid, uid) = find_style(s, p, "layer.style.remove")?;
s.edit("Remove Layer Style", None, |proj, state| {
let l = layer_mut(proj, cid, lid)?;
let whole = l.layer_styles().is_some_and(|g| g.uid == uid);
if whole {
l.props.children.retain(|c| c.match_id() != GROUP);
} else if let Some(g) = l.props.sub_mut(GROUP) {
if g.sub(st::BLENDING).is_some_and(|b| b.uid == uid) {
return Err(bad("layer.style.remove", "Blending Options can't be removed"));
}
g.children.retain(|c| c.uid() != uid);
// The last style gone removes the Layer Styles group, as in AE.
if g.children.iter().all(|c| c.match_id() == st::BLENDING) {
l.props.children.retain(|c| c.match_id() != GROUP);
}
}
state.selected_props.retain(|(_, u)| *u != uid);
Ok(())
})?;
Ok(Value::Null)
}
/// Eye switch of a style (or of the whole Layer Styles group: `style: "layerStyles"`).
fn toggle(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid, uid) = find_style(s, p, "layer.style.toggle")?;
let v = b_p(p, "value");
let r = s.edit("Toggle Layer Style", None, |proj, _| {
let g = layer_mut(proj, cid, lid)?.props.find_group_mut(uid).ok_or_else(|| bad("layer.style.toggle", "gone"))?;
g.enabled = v.unwrap_or(!g.enabled);
Ok(g.enabled)
})?;
Ok(json!(r))
}
fn global_light(s: &mut Session, p: &Value) -> Result<Value> {
let cid = super::comp_id(s, p)?;
let (angle, altitude) = (f_p(p, "angle"), f_p(p, "altitude"));
if angle.is_none() && altitude.is_none() {
let c = s.project.comp(cid).ok_or(EngineError::NoComp)?;
return Ok(json!({"angle": c.global_light.angle.value.as_f64(), "altitude": c.global_light.altitude.value.as_f64()}));
}
let t = time_p(s, p, s.project.comp(cid));
s.edit("Global Light", super::merge_p(p), |proj, _| {
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
st::set_global_light(c, angle, altitude, t);
Ok(())
})?;
let c = s.project.comp(cid).ok_or(EngineError::NoComp)?;
Ok(json!({"angle": c.global_light.angle.value_at(t).as_f64(), "altitude": c.global_light.altitude.value_at(t).as_f64()}))
}
fn no_psd_styles(s: &Session) -> std::result::Result<(), String> {
has_layers(s)?;
Err("no Photoshop layer styles to convert (only layers imported from PSD files carry them)".into())
}
fn convert(_: &mut Session, _: &Value) -> Result<Value> {
Err(EngineError::Other("no Photoshop layer styles to convert".into()))
}
fn list(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid) = layer_p(s, p, "layer.style.list")?;
let l = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?;
let styles: Vec<Value> = l
.layer_styles()
.map(|g| {
g.groups()
.filter(|x| x.match_id != st::BLENDING)
.map(|x| json!({"style": x.match_id, "name": x.name, "uid": x.uid, "enabled": x.enabled}))
.collect()
})
.unwrap_or_default();
Ok(
json!({"group": l.layer_styles().map(|g| g.uid), "enabled": l.layer_styles().map(|g| g.enabled), "styles": styles, "available": STYLES.iter().map(|s| json!({"style": s.0, "name": s.1})).collect::<Vec<_>>()}),
)
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("layer.style.convertToEditable", "Convert to Editable Styles", ["Layer", "Layer Styles"], None, "{layers?}", no_psd_styles, convert),
cmd!("layer.style.showAll", "Show All", ["Layer", "Layer Styles"], None, "{layers?}", has_layers, show_all),
cmd!("layer.style.removeAll", "Remove All", ["Layer", "Layer Styles"], None, "{layers?}", has_layers, remove_all),
cmd!("layer.style.dropShadow", "Drop Shadow", ["Layer", "Layer Styles"], None, "{layers?}", has_layers, drop_shadow),
cmd!("layer.style.innerShadow", "Inner Shadow", ["Layer", "Layer Styles"], None, "{layers?}", has_layers, inner_shadow),
cmd!("layer.style.outerGlow", "Outer Glow", ["Layer", "Layer Styles"], None, "{layers?}", has_layers, outer_glow),
cmd!("layer.style.innerGlow", "Inner Glow", ["Layer", "Layer Styles"], None, "{layers?}", has_layers, inner_glow),
cmd!("layer.style.bevelEmboss", "Bevel and Emboss", ["Layer", "Layer Styles"], None, "{layers?}", has_layers, bevel_emboss),
cmd!("layer.style.satin", "Satin", ["Layer", "Layer Styles"], None, "{layers?}", has_layers, satin),
cmd!("layer.style.colorOverlay", "Color Overlay", ["Layer", "Layer Styles"], None, "{layers?}", has_layers, color_overlay),
cmd!("layer.style.gradientOverlay", "Gradient Overlay", ["Layer", "Layer Styles"], None, "{layers?}", has_layers, gradient_overlay),
cmd!("layer.style.stroke", "Stroke", ["Layer", "Layer Styles"], None, "{layers?}", has_layers, stroke),
cmd!(
"layer.style.add",
"Add Layer Style",
[],
None,
"{style: dropShadow|innerShadow|outerGlow|innerGlow|bevelEmboss|satin|colorOverlay|gradientOverlay|stroke (or display name), layers?}",
has_layers,
add
),
cmd!("layer.style.remove", "Remove Layer Style", [], None, "{layer?, style: id|name|uid|layerStyles}", has_layers, remove),
cmd!("layer.style.toggle", "Toggle Layer Style", [], None, "{layer?, style: id|name|uid|layerStyles, value?}", has_layers, toggle),
cmd!(
"layer.style.globalLight",
"Global Light",
[],
None,
"{comp?, angle? (deg), altitude? (deg, 0..90), time? (s)} — no values returns the current setting",
has_comp,
global_light
),
crate::query!("layer.style.list", "List Layer Styles", "{layer?}", list),
]
}
+4
View File
@@ -243,6 +243,8 @@ impl Session {
let mut p = (*self.project).clone();
let mut st = self.state.clone();
let r = f(&mut p, &mut st)?;
// Layer styles: one Global Light per comp, whichever layer edited it.
effectcraft_project::styles::sync_global_light(&before, &mut p);
let same = merge.is_some() && merge.map(str::to_string) == self.history.merge_key;
if !same {
self.history.undo.push((label.to_string(), before));
@@ -419,6 +421,8 @@ mod tests;
#[cfg(test)]
mod tests_3d;
#[cfg(test)]
mod tests_styles;
#[cfg(test)]
mod tests_text;
#[cfg(test)]
mod tests_timeline;
+119
View File
@@ -0,0 +1,119 @@
//! Layer Styles commands: add/remove/show, eye switches, Global Light linkage, undo, serde.
use serde_json::json;
use crate::Session;
fn session() -> (Session, u64, u64) {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Styles", "width": 200, "height": 100, "frameRate": 30, "duration": 2})).unwrap();
let a = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 40, "height": 40})).unwrap()["layer"].as_u64().unwrap();
let b = s.execute("layer.newSolid", json!({"color": "#808080", "width": 40, "height": 40})).unwrap()["layer"].as_u64().unwrap();
(s, a, b)
}
fn styles_of(s: &Session, layer: u64) -> Vec<(String, bool)> {
let v = s.execute_query("layer.style.list", json!({"layer": layer}));
v["styles"].as_array().unwrap().iter().map(|x| (x["style"].as_str().unwrap().to_string(), x["enabled"].as_bool().unwrap())).collect()
}
impl Session {
fn execute_query(&self, id: &str, p: serde_json::Value) -> serde_json::Value {
let mut c = Session { project: self.project.clone(), state: self.state.clone(), ..Default::default() };
c.execute(id, p).unwrap()
}
}
#[test]
fn add_remove_show_all_with_undo() {
let (mut s, a, _) = session();
s.execute("layer.select", json!({"layers": [a]})).unwrap();
s.execute("layer.style.stroke", json!({})).unwrap();
s.execute("layer.style.dropShadow", json!({})).unwrap();
s.execute("layer.style.add", json!({"style": "Bevel and Emboss"})).unwrap();
assert_eq!(styles_of(&s, a), vec![("dropShadow".into(), true), ("bevelEmboss".into(), true), ("stroke".into(), true)]);
// Values are ordinary properties.
s.execute("prop.set", json!({"layer": a, "path": "layerStyles/dropShadow/distance", "value": 12})).unwrap();
let v = s.execute("prop.get", json!({"layer": a, "path": "layerStyles/dropShadow/distance"})).unwrap();
assert!(v.to_string().contains("12"));
// Eye switch.
s.execute("layer.style.toggle", json!({"layer": a, "style": "stroke"})).unwrap();
assert!(styles_of(&s, a).contains(&("stroke".into(), false)));
s.execute("edit.undo", json!({})).unwrap();
assert!(styles_of(&s, a).contains(&("stroke".into(), true)));
// Remove one, then everything.
s.execute("layer.style.remove", json!({"layer": a, "style": "bevelEmboss"})).unwrap();
assert_eq!(styles_of(&s, a).len(), 2);
s.execute("layer.style.removeAll", json!({})).unwrap();
assert!(styles_of(&s, a).is_empty());
let layer = s.active_comp().unwrap().layer(effectcraft_project::LayerId(a)).unwrap().clone();
assert!(layer.layer_styles().is_none());
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(styles_of(&s, a).len(), 2);
s.execute("edit.redo", json!({})).unwrap();
assert!(styles_of(&s, a).is_empty());
// Show All adds every style switched off; choosing one switches it on.
s.execute("layer.style.showAll", json!({})).unwrap();
let all = styles_of(&s, a);
assert_eq!(all.len(), 9);
assert!(all.iter().all(|(_, on)| !on));
s.execute("layer.style.satin", json!({})).unwrap();
assert!(styles_of(&s, a).contains(&("satin".into(), true)));
// Removing the last style removes the group.
s.execute("layer.style.removeAll", json!({})).unwrap();
s.execute("layer.style.colorOverlay", json!({})).unwrap();
s.execute("layer.style.remove", json!({"layer": a, "style": "Color Overlay"})).unwrap();
assert!(s.active_comp().unwrap().layer(effectcraft_project::LayerId(a)).unwrap().layer_styles().is_none());
// Convert to Editable Styles is unavailable without PSD styles.
assert!(!s.is_enabled("layer.style.convertToEditable"));
}
#[test]
fn global_light_is_shared_by_the_comp() {
let (mut s, a, b) = session();
s.execute("layer.style.dropShadow", json!({"layers": [a]})).unwrap();
s.execute("layer.style.bevelEmboss", json!({"layers": [b]})).unwrap();
// Editing one layer's Global Light Angle updates the other layer and the comp.
s.execute("prop.set", json!({"layer": a, "path": "layerStyles/blendingOptions/globalLightAngle", "value": 45})).unwrap();
let bv = s.execute("prop.get", json!({"layer": b, "path": "layerStyles/blendingOptions/globalLightAngle"})).unwrap();
assert!(bv.to_string().contains("45"), "{bv}");
let gl = s.execute("layer.style.globalLight", json!({})).unwrap();
assert_eq!(gl["angle"].as_f64(), Some(45.0));
// The command sets both and new style groups pick it up.
s.execute("layer.style.globalLight", json!({"angle": 10, "altitude": 60})).unwrap();
let c = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 10, "height": 10})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.style.innerShadow", json!({"layers": [c]})).unwrap();
for l in [a, b, c] {
let v = s.execute("prop.get", json!({"layer": l, "path": "layerStyles/blendingOptions/globalLightAltitude"})).unwrap();
assert!(v.to_string().contains("60"), "{l}: {v}");
}
// Undo restores the previous shared value on every layer.
s.execute("edit.undo", json!({})).unwrap();
s.execute("edit.undo", json!({})).unwrap();
s.execute("edit.undo", json!({})).unwrap();
let v = s.execute("prop.get", json!({"layer": b, "path": "layerStyles/blendingOptions/globalLightAngle"})).unwrap();
assert!(v.to_string().contains("45"), "{v}");
}
#[test]
fn styles_render_and_round_trip() {
let (mut s, a, _) = session();
s.execute("layer.style.stroke", json!({"layers": [a]})).unwrap();
s.execute("prop.set", json!({"layer": a, "path": "layerStyles/stroke/size", "value": 6})).unwrap();
let cid = s.active_comp_id().unwrap();
// Cached session render == uncached render, and the stroke shows outside the 40×40 square.
let img = s.render(cid, s.time(), Default::default());
let r = effectcraft_render::render_frame(&s.project, cid, s.time(), 1.0);
assert_eq!(img.data, r.data);
assert!(img.get(123, 50)[0] > 0.98 && img.get(123, 50)[1] < 0.02, "{:?}", img.get(123, 50));
// Change the colour through a command: the cache must not serve stale pixels.
s.execute("prop.set", json!({"layer": a, "path": "layerStyles/stroke/color", "value": [0, 0, 1, 1]})).unwrap();
let img2 = s.render(cid, s.time(), Default::default());
assert!(img2.get(123, 50)[2] > 0.98 && img2.get(123, 50)[0] < 0.02);
// Serde round trip keeps styles and the comp's Global Light.
s.execute("layer.style.globalLight", json!({"angle": 33})).unwrap();
let json = serde_json::to_string(&*s.project).unwrap();
let back: effectcraft_project::Project = serde_json::from_str(&json).unwrap();
assert_eq!(back, *s.project);
assert_eq!(back.comp(cid).unwrap().global_light.angle.value.as_f64(), 33.0);
}
+5
View File
@@ -7,6 +7,7 @@
pub mod build;
pub mod props;
pub mod render_queue;
pub mod styles;
use std::collections::BTreeMap;
use std::sync::Arc;
@@ -150,6 +151,9 @@ pub struct Comp {
pub draft_3d: bool,
#[serde(default)]
pub poster_time: Tick,
/// Global Light for layer styles (Layer ▸ Layer Styles ▸ Blending Options).
#[serde(default)]
pub global_light: styles::GlobalLight,
}
fn yes() -> bool {
@@ -178,6 +182,7 @@ impl Comp {
enable_frame_blending: true,
draft_3d: false,
poster_time: Tick::ZERO,
global_light: styles::GlobalLight::default(),
}
}
pub fn layer(&self, id: LayerId) -> Option<&Layer> {
+485
View File
@@ -0,0 +1,485 @@
//! Layer Styles (Layer ▸ Layer Styles): the `layerStyles` property group and its styles.
//!
//! After Effects' layer styles are Photoshop's: Blending Options (Global Light, Advanced
//! Blending), Drop Shadow, Inner Shadow, Outer Glow, Inner Glow, Bevel and Emboss, Satin, Color
//! Overlay, Gradient Overlay and Stroke. Every style is a group under the layer's `layerStyles`
//! group (which sits after Transform, as in the AE timeline), with AE's property names, order,
//! defaults and ranges; every property animates. A style group's `enabled` flag is its eye
//! switch. Styles appear in the group in menu order whatever order they were added in.
//!
//! Global Light (angle/altitude) is one setting per composition: [`Comp::global_light`] holds it,
//! each layer's Blending Options shows it, and [`sync_global_light`] keeps every layer of a comp
//! in step after any edit.
use effectcraft_color::BlendMode;
use effectcraft_keyframe::{Gradient, Value};
use serde::{Deserialize, Serialize};
use crate::build::Ids;
use crate::props::{GroupKind, ParamUi, PropGroup, Property};
use crate::{Comp, Layer, Project};
/// Match id of the layer's Layer Styles group.
pub const GROUP: &str = "layerStyles";
/// Match id of the Blending Options group.
pub const BLENDING: &str = "blendingOptions";
/// The styles in menu / timeline order: (match id, display name).
pub const STYLES: &[(&str, &str)] = &[
("dropShadow", "Drop Shadow"),
("innerShadow", "Inner Shadow"),
("outerGlow", "Outer Glow"),
("innerGlow", "Inner Glow"),
("bevelEmboss", "Bevel and Emboss"),
("satin", "Satin"),
("colorOverlay", "Color Overlay"),
("gradientOverlay", "Gradient Overlay"),
("stroke", "Stroke"),
];
/// Look a style up by match id or display name (case-insensitive).
pub fn style_id(s: &str) -> Option<&'static str> {
STYLES.iter().find(|(id, name)| id.eq_ignore_ascii_case(s) || name.eq_ignore_ascii_case(s)).map(|(id, _)| *id)
}
/// The Blend Mode popup of layer styles (Photoshop's list) and the compositor mode for each.
pub const STYLE_BLEND_MODES: &[(&str, BlendMode)] = &[
("Normal", BlendMode::Normal),
("Dissolve", BlendMode::Dissolve),
("Darken", BlendMode::Darken),
("Multiply", BlendMode::Multiply),
("Color Burn", BlendMode::ColorBurn),
("Linear Burn", BlendMode::LinearBurn),
("Darker Color", BlendMode::DarkerColor),
("Lighten", BlendMode::Lighten),
("Screen", BlendMode::Screen),
("Color Dodge", BlendMode::ColorDodge),
("Linear Dodge (Add)", BlendMode::LinearDodge),
("Lighter Color", BlendMode::LighterColor),
("Overlay", BlendMode::Overlay),
("Soft Light", BlendMode::SoftLight),
("Hard Light", BlendMode::HardLight),
("Vivid Light", BlendMode::VividLight),
("Linear Light", BlendMode::LinearLight),
("Pin Light", BlendMode::PinLight),
("Hard Mix", BlendMode::HardMix),
("Difference", BlendMode::Difference),
("Exclusion", BlendMode::Exclusion),
("Subtract", BlendMode::Subtract),
("Divide", BlendMode::Divide),
("Hue", BlendMode::Hue),
("Saturation", BlendMode::Saturation),
("Color", BlendMode::Color),
("Luminosity", BlendMode::Luminosity),
];
/// Compositor blend mode for a style Blend Mode popup index.
pub fn style_blend_mode(i: u32) -> BlendMode {
STYLE_BLEND_MODES.get(i as usize).map(|m| m.1).unwrap_or(BlendMode::Normal)
}
fn mode_index(m: BlendMode) -> u32 {
STYLE_BLEND_MODES.iter().position(|x| x.1 == m).unwrap_or(0) as u32
}
pub const KNOCKOUT: &[&str] = &["None", "Shallow", "Deep"];
pub const COLOR_TYPE: &[&str] = &["Color", "Gradient"];
pub const GLOW_TECHNIQUE: &[&str] = &["Softer", "Precise"];
pub const GLOW_SOURCE: &[&str] = &["Center", "Edge"];
pub const BEVEL_STYLE: &[&str] = &["Outer Bevel", "Inner Bevel", "Emboss", "Pillow Emboss", "Stroke Emboss"];
pub const BEVEL_TECHNIQUE: &[&str] = &["Smooth", "Chisel Hard", "Chisel Soft"];
pub const BEVEL_DIRECTION: &[&str] = &["Up", "Down"];
pub const GRADIENT_STYLE: &[&str] = &["Linear", "Radial", "Angle", "Reflected", "Diamond"];
pub const STROKE_POSITION: &[&str] = &["Outside", "Inside", "Center"];
/// Default Global Light (Photoshop / After Effects defaults).
pub const DEFAULT_ANGLE: f64 = 120.0;
pub const DEFAULT_ALTITUDE: f64 = 30.0;
/// A composition's Global Light, mirrored into every layer's Blending Options.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct GlobalLight {
pub angle: Property,
pub altitude: Property,
}
impl Default for GlobalLight {
fn default() -> Self {
GlobalLight {
angle: Property::new(0, "globalLightAngle", "Global Light Angle", Value::Scalar(DEFAULT_ANGLE)).with_ui(ParamUi::Angle),
altitude: Property::new(0, "globalLightAltitude", "Global Light Altitude", Value::Scalar(DEFAULT_ALTITUDE)).with_ui(ParamUi::Angle),
}
}
}
fn slider(min: f64, max: f64, decimals: u8) -> ParamUi {
ParamUi::Slider { min, max, slider_min: min, slider_max: max, decimals }
}
fn slider2(min: f64, max: f64, smin: f64, smax: f64, decimals: u8) -> ParamUi {
ParamUi::Slider { min, max, slider_min: smin, slider_max: smax, decimals }
}
fn popup(opts: &[&str]) -> ParamUi {
ParamUi::Popup { options: opts.iter().map(|s| s.to_string()).collect() }
}
fn style_modes() -> ParamUi {
ParamUi::Popup { options: STYLE_BLEND_MODES.iter().map(|m| m.0.to_string()).collect() }
}
fn rgb(r: u8, g: u8, b: u8) -> Value {
Value::Color([r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0, 1.0])
}
fn mode(ids: &mut Ids, m: &str, name: &str, d: BlendMode) -> Property {
ids.prop(m, name, Value::Enum(mode_index(d))).with_ui(style_modes())
}
fn color(ids: &mut Ids, m: &str, name: &str, v: Value) -> Property {
ids.prop(m, name, v).with_ui(ParamUi::Color)
}
fn pct(ids: &mut Ids, m: &str, name: &str, v: f64) -> Property {
ids.prop(m, name, Value::Scalar(v)).with_ui(slider(0.0, 100.0, 0))
}
fn px(ids: &mut Ids, m: &str, name: &str, v: f64, max: f64) -> Property {
ids.prop(m, name, Value::Scalar(v)).with_ui(slider(0.0, max, 1))
}
fn check(ids: &mut Ids, m: &str, name: &str, v: bool) -> Property {
ids.prop(m, name, Value::Bool(v)).with_ui(ParamUi::Checkbox)
}
fn pop(ids: &mut Ids, m: &str, name: &str, v: u32, opts: &[&str]) -> Property {
ids.prop(m, name, Value::Enum(v)).with_ui(popup(opts))
}
fn angle(ids: &mut Ids, m: &str, name: &str, v: f64) -> Property {
ids.prop(m, name, Value::Scalar(v)).with_ui(ParamUi::Angle)
}
fn gradient(ids: &mut Ids, g: Gradient) -> Property {
ids.prop("colors", "Colors", Value::Gradient(g)).with_ui(ParamUi::Gradient)
}
/// The default glow gradient: the glow colour fading to transparent.
fn glow_gradient() -> Gradient {
let c = [1.0, 1.0, 190.0 / 255.0, 1.0];
Gradient { colors: vec![(0.0, c), (1.0, c)], opacities: vec![(0.0, 1.0), (1.0, 0.0)] }
}
/// Global Light properties as they appear on a layer (copied from the comp's setting).
fn global_props(ids: &mut Ids, gl: &GlobalLight) -> (Property, Property) {
let mut a = gl.angle.clone();
a.uid = ids.alloc();
let mut b = gl.altitude.clone();
b.uid = ids.alloc();
(a, b)
}
/// The Blending Options group.
pub fn blending_options(ids: &mut Ids, gl: &GlobalLight) -> PropGroup {
let (a, b) = global_props(ids, gl);
let adv = ids
.group("advancedBlending", "Advanced Blending")
.with(pct(ids, "fillOpacity", "Fill Opacity", 100.0))
.with(check(ids, "red", "Red", true))
.with(check(ids, "green", "Green", true))
.with(check(ids, "blue", "Blue", true))
.with(pop(ids, "knockout", "Knockout", 0, KNOCKOUT))
.with(check(ids, "blendInteriorAsGroup", "Blend Interior Styles as Group", false))
.with(check(ids, "useBlendRanges", "Use Blend Ranges from Source", true));
ids.group(BLENDING, "Blending Options").with(a).with(b).with(adv)
}
/// An empty Layer Styles group (Blending Options only).
pub fn layer_styles(ids: &mut Ids, gl: &GlobalLight) -> PropGroup {
let bo = blending_options(ids, gl);
ids.group(GROUP, "Layer Styles").with(bo)
}
/// A style group with AE's defaults (`id` from [`STYLES`]).
pub fn style(ids: &mut Ids, id: &str) -> Option<PropGroup> {
let (_, name) = STYLES.iter().find(|s| s.0 == id)?;
let mut g = ids.group(id, name);
g.kind = GroupKind::Plain;
let props: Vec<Property> = match id {
"dropShadow" => vec![
mode(ids, "blendMode", "Blend Mode", BlendMode::Multiply),
color(ids, "color", "Color", rgb(0, 0, 0)),
pct(ids, "opacity", "Opacity", 75.0),
check(ids, "useGlobalLight", "Use Global Light", true),
angle(ids, "angle", "Angle", DEFAULT_ANGLE),
ids.prop("distance", "Distance", Value::Scalar(5.0)).with_ui(slider2(0.0, 30000.0, 0.0, 100.0, 1)),
pct(ids, "spread", "Spread", 0.0),
px(ids, "size", "Size", 5.0, 250.0),
pct(ids, "noise", "Noise", 0.0),
check(ids, "knocksOut", "Layer Knocks Out Drop Shadow", true),
],
"innerShadow" => vec![
mode(ids, "blendMode", "Blend Mode", BlendMode::Multiply),
color(ids, "color", "Color", rgb(0, 0, 0)),
pct(ids, "opacity", "Opacity", 75.0),
check(ids, "useGlobalLight", "Use Global Light", true),
angle(ids, "angle", "Angle", DEFAULT_ANGLE),
ids.prop("distance", "Distance", Value::Scalar(5.0)).with_ui(slider2(0.0, 30000.0, 0.0, 100.0, 1)),
pct(ids, "choke", "Choke", 0.0),
px(ids, "size", "Size", 5.0, 250.0),
pct(ids, "noise", "Noise", 0.0),
],
"outerGlow" => vec![
mode(ids, "blendMode", "Blend Mode", BlendMode::Screen),
pct(ids, "opacity", "Opacity", 75.0),
pct(ids, "noise", "Noise", 0.0),
pop(ids, "colorType", "Color Type", 0, COLOR_TYPE),
color(ids, "color", "Color", rgb(255, 255, 190)),
gradient(ids, glow_gradient()),
pct(ids, "gradientSmoothness", "Gradient Smoothness", 100.0),
pop(ids, "technique", "Technique", 0, GLOW_TECHNIQUE),
pct(ids, "spread", "Spread", 0.0),
px(ids, "size", "Size", 5.0, 250.0),
ids.prop("range", "Range", Value::Scalar(50.0)).with_ui(slider(1.0, 100.0, 0)),
pct(ids, "jitter", "Jitter", 0.0),
],
"innerGlow" => vec![
mode(ids, "blendMode", "Blend Mode", BlendMode::Screen),
pct(ids, "opacity", "Opacity", 75.0),
pct(ids, "noise", "Noise", 0.0),
pop(ids, "colorType", "Color Type", 0, COLOR_TYPE),
color(ids, "color", "Color", rgb(255, 255, 190)),
gradient(ids, glow_gradient()),
pct(ids, "gradientSmoothness", "Gradient Smoothness", 100.0),
pop(ids, "technique", "Technique", 0, GLOW_TECHNIQUE),
pop(ids, "source", "Source", 1, GLOW_SOURCE),
pct(ids, "choke", "Choke", 0.0),
px(ids, "size", "Size", 5.0, 250.0),
ids.prop("range", "Range", Value::Scalar(50.0)).with_ui(slider(1.0, 100.0, 0)),
pct(ids, "jitter", "Jitter", 0.0),
],
"bevelEmboss" => vec![
pop(ids, "style", "Style", 1, BEVEL_STYLE),
pop(ids, "technique", "Technique", 0, BEVEL_TECHNIQUE),
ids.prop("depth", "Depth", Value::Scalar(100.0)).with_ui(slider2(1.0, 1000.0, 1.0, 1000.0, 0)),
pop(ids, "direction", "Direction", 0, BEVEL_DIRECTION),
px(ids, "size", "Size", 5.0, 250.0),
px(ids, "soften", "Soften", 0.0, 16.0),
check(ids, "useGlobalLight", "Use Global Light", true),
angle(ids, "angle", "Angle", DEFAULT_ANGLE),
ids.prop("altitude", "Altitude", Value::Scalar(DEFAULT_ALTITUDE)).with_ui(ParamUi::Angle),
mode(ids, "highlightMode", "Highlight Mode", BlendMode::Screen),
color(ids, "highlightColor", "Highlight Color", rgb(255, 255, 255)),
pct(ids, "highlightOpacity", "Highlight Opacity", 75.0),
mode(ids, "shadowMode", "Shadow Mode", BlendMode::Multiply),
color(ids, "shadowColor", "Shadow Color", rgb(0, 0, 0)),
pct(ids, "shadowOpacity", "Shadow Opacity", 75.0),
],
"satin" => vec![
mode(ids, "blendMode", "Blend Mode", BlendMode::Multiply),
color(ids, "color", "Color", rgb(0, 0, 0)),
pct(ids, "opacity", "Opacity", 50.0),
angle(ids, "angle", "Angle", 19.0),
px(ids, "distance", "Distance", 11.0, 250.0),
px(ids, "size", "Size", 14.0, 250.0),
check(ids, "invert", "Invert", true),
],
"colorOverlay" => {
vec![mode(ids, "blendMode", "Blend Mode", BlendMode::Normal), color(ids, "color", "Color", rgb(255, 0, 0)), pct(ids, "opacity", "Opacity", 100.0)]
}
"gradientOverlay" => vec![
mode(ids, "blendMode", "Blend Mode", BlendMode::Normal),
pct(ids, "opacity", "Opacity", 100.0),
gradient(ids, Gradient { colors: vec![(0.0, [0.0, 0.0, 0.0, 1.0]), (1.0, [1.0, 1.0, 1.0, 1.0])], opacities: vec![(0.0, 1.0), (1.0, 1.0)] }),
pct(ids, "gradientSmoothness", "Gradient Smoothness", 100.0),
angle(ids, "angle", "Angle", 90.0),
pop(ids, "style", "Style", 0, GRADIENT_STYLE),
check(ids, "reverse", "Reverse", false),
check(ids, "alignWithLayer", "Align with Layer", true),
ids.prop("scale", "Scale", Value::Scalar(100.0)).with_ui(slider(10.0, 150.0, 0)),
ids.prop("offset", "Offset", Value::Vec2([0.0, 0.0])).with_ui(ParamUi::Point),
],
"stroke" => vec![
color(ids, "color", "Color", rgb(255, 0, 0)),
mode(ids, "blendMode", "Blend Mode", BlendMode::Normal),
ids.prop("size", "Size", Value::Scalar(3.0)).with_ui(slider(1.0, 250.0, 1)),
pct(ids, "opacity", "Opacity", 100.0),
pop(ids, "position", "Position", 0, STROKE_POSITION),
],
_ => return None,
};
for p in props {
g.children.push(p.into());
}
Some(g)
}
impl Layer {
/// The Layer Styles group (present once a style was added).
pub fn layer_styles(&self) -> Option<&PropGroup> {
self.props.sub(GROUP)
}
/// Layer types that take layer styles (AV layers: footage, solids, precomps, text, shapes).
pub fn can_have_styles(&self) -> bool {
self.source.is_av() && !self.switches.adjustment
}
}
/// Add style `id` to `layer` (creating the Layer Styles group after Transform when missing).
/// Returns the style group's uid; an existing style of that kind is kept and returned.
pub fn add_style(layer: &mut Layer, ids: &mut Ids, id: &str, gl: &GlobalLight, enabled: bool) -> Option<u64> {
let order = STYLES.iter().position(|s| s.0 == id)?;
if layer.props.sub(GROUP).is_none() {
let g = layer_styles(ids, gl);
let at = layer.props.children.iter().position(|c| c.match_id() == "transform").map(|i| i + 1).unwrap_or(layer.props.children.len());
layer.props.children.insert(at, g.into());
}
let ls = layer.props.sub_mut(GROUP)?;
if let Some(g) = ls.sub(id) {
return Some(g.uid);
}
let mut g = style(ids, id)?;
g.enabled = enabled;
let uid = g.uid;
// Keep menu order: after Blending Options and every style that comes earlier.
let at = ls.children.iter().position(|c| STYLES.iter().position(|s| s.0 == c.match_id()).is_some_and(|o| o > order)).unwrap_or(ls.children.len());
ls.children.insert(at, g.into());
Some(uid)
}
fn same_anim(a: &Property, b: &Property) -> bool {
a.value == b.value && a.keys == b.keys && a.expr == b.expr
}
fn copy_anim(dst: &mut Property, src: &Property) {
dst.value = src.value.clone();
dst.keys = src.keys.clone();
dst.expr = src.expr.clone();
}
/// After an edit: if a layer's Global Light Angle/Altitude differs from its comp's setting, that
/// layer was edited — adopt its value for the comp and copy it to every other layer (`before` is
/// the project before the edit; comps that did not change are skipped).
pub fn sync_global_light(before: &Project, after: &mut Project) {
let ids: Vec<_> = after
.items
.iter()
.filter_map(|(id, it)| match (&it.kind, before.items.get(id).map(|b| &b.kind)) {
(crate::ItemKind::Comp(a), Some(crate::ItemKind::Comp(b))) if std::sync::Arc::ptr_eq(a, b) => None,
(crate::ItemKind::Comp(_), _) => Some(*id),
_ => None,
})
.collect();
for id in ids {
let Some(comp) = after.comp(id) else { continue };
if !needs_sync(comp) {
continue;
}
if let Some(c) = after.comp_mut(id) {
sync_comp(c);
}
}
}
fn needs_sync(comp: &Comp) -> bool {
comp.layers.iter().filter_map(|l| l.layer_styles()?.sub(BLENDING)).any(|bo| {
bo.get("globalLightAngle").is_some_and(|p| !same_anim(p, &comp.global_light.angle))
|| bo.get("globalLightAltitude").is_some_and(|p| !same_anim(p, &comp.global_light.altitude))
})
}
/// Make every layer of `comp` share one Global Light (the first layer that differs wins).
pub fn sync_comp(comp: &mut Comp) {
for (m, which) in [("globalLightAngle", 0), ("globalLightAltitude", 1)] {
let cur = if which == 0 { &comp.global_light.angle } else { &comp.global_light.altitude };
let changed = comp.layers.iter().filter_map(|l| l.layer_styles()?.sub(BLENDING)?.get(m)).find(|p| !same_anim(p, cur)).cloned();
let Some(src) = changed else { continue };
let target = if which == 0 { &mut comp.global_light.angle } else { &mut comp.global_light.altitude };
copy_anim(target, &src);
for l in &mut comp.layers {
if let Some(p) = l.props.sub_mut(GROUP).and_then(|g| g.sub_mut(BLENDING)).and_then(|g| g.get_mut(m)) {
copy_anim(p, &src);
}
}
}
}
/// Set the comp's Global Light statically (`None` keeps a value) and mirror it to all layers.
pub fn set_global_light(comp: &mut Comp, angle: Option<f64>, altitude: Option<f64>, t: effectcraft_time::Tick) {
if let Some(a) = angle {
comp.global_light.angle.set_value_at(t, Value::Scalar(a));
}
if let Some(a) = altitude {
comp.global_light.altitude.set_value_at(t, Value::Scalar(a.clamp(0.0, 90.0)));
}
let (a, b) = (comp.global_light.angle.clone(), comp.global_light.altitude.clone());
for l in &mut comp.layers {
if let Some(bo) = l.props.sub_mut(GROUP).and_then(|g| g.sub_mut(BLENDING)) {
if let Some(p) = bo.get_mut("globalLightAngle") {
copy_anim(p, &a);
}
if let Some(p) = bo.get_mut("globalLightAltitude") {
copy_anim(p, &b);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{LayerSource, build};
use effectcraft_time::{FrameRate, Tick};
fn setup() -> (Project, Comp, Layer) {
let mut p = Project::default();
let comp = Comp::new(100, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
let l = build::layer(&mut p, &comp, "Shape", LayerSource::Shape, (0, 0), None);
(p, comp, l)
}
#[test]
fn styles_keep_menu_order_and_follow_transform() {
let (mut p, comp, mut l) = setup();
let gl = comp.global_light.clone();
let mut ids = Ids(&mut p.next_id);
add_style(&mut l, &mut ids, "stroke", &gl, true).unwrap();
add_style(&mut l, &mut ids, "dropShadow", &gl, true).unwrap();
let again = add_style(&mut l, &mut ids, "stroke", &gl, true).unwrap();
add_style(&mut l, &mut ids, "bevelEmboss", &gl, true).unwrap();
let ls = l.layer_styles().unwrap();
let order: Vec<&str> = ls.children.iter().map(|c| c.match_id()).collect();
assert_eq!(order, ["blendingOptions", "dropShadow", "bevelEmboss", "stroke"]);
assert_eq!(ls.sub("stroke").unwrap().uid, again);
let top: Vec<&str> = l.props.children.iter().map(|c| c.match_id()).collect();
let t = top.iter().position(|m| *m == "transform").unwrap();
assert_eq!(top[t + 1], GROUP);
// Defaults from AE.
let ds = ls.sub("dropShadow").unwrap();
assert_eq!(ds.get("opacity").unwrap().value, Value::Scalar(75.0));
assert_eq!(style_blend_mode(ds.get("blendMode").unwrap().value.as_enum()), BlendMode::Multiply);
assert_eq!(ls.sub(BLENDING).unwrap().get("globalLightAngle").unwrap().value, Value::Scalar(120.0));
}
#[test]
fn every_style_builds() {
let mut next = 1;
let mut ids = Ids(&mut next);
for (id, name) in STYLES {
let g = style(&mut ids, id).unwrap();
assert_eq!(g.name, *name);
assert!(!g.children.is_empty());
}
assert_eq!(style_id("Bevel and Emboss"), Some("bevelEmboss"));
}
#[test]
fn global_light_syncs_across_layers() {
let (mut p, mut comp, mut a) = setup();
let mut b = build::layer(&mut p, &comp, "B", LayerSource::Shape, (0, 0), None);
let gl = comp.global_light.clone();
let mut ids = Ids(&mut p.next_id);
add_style(&mut a, &mut ids, "dropShadow", &gl, true);
add_style(&mut b, &mut ids, "bevelEmboss", &gl, true);
comp.layers = vec![a, b];
comp.layers[1].props.sub_mut(GROUP).unwrap().sub_mut(BLENDING).unwrap().get_mut("globalLightAngle").unwrap().value = Value::Scalar(45.0);
assert!(needs_sync(&comp));
sync_comp(&mut comp);
assert!(!needs_sync(&comp));
assert_eq!(comp.global_light.angle.value, Value::Scalar(45.0));
let la = comp.layers[0].layer_styles().unwrap().sub(BLENDING).unwrap().get("globalLightAngle").unwrap();
assert_eq!(la.value, Value::Scalar(45.0));
set_global_light(&mut comp, None, Some(60.0), Tick::ZERO);
let lb = comp.layers[1].layer_styles().unwrap().sub(BLENDING).unwrap().get("globalLightAltitude").unwrap();
assert_eq!(lb.value, Value::Scalar(60.0));
}
}
+21 -1
View File
@@ -231,8 +231,9 @@ pub fn layer_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool) -> Optio
hash_debug(&mut h, &layer.source);
hash_debug(&mut h, &layer.switches);
for c in &layer.props.children {
// Transform is applied later; Layer Styles are keyed separately (see [`styles_key`]).
if let Node::Group(g) = c
&& g.match_id == "transform"
&& (g.match_id == "transform" || g.match_id == effectcraft_project::styles::GROUP)
{
continue;
}
@@ -250,3 +251,22 @@ pub fn layer_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool) -> Optio
}
Some(h.finish())
}
/// Cache key for the styled layer (content key + the Layer Styles group: structure, eye
/// switches, values, keyframes, expressions and their values at the context time — Global Light
/// included, as every layer mirrors it in its Blending Options).
pub fn styles_key(ctx: &EvalCtx, layer: &Layer, content_key: u64) -> u64 {
let mut h = KeyHasher(content_key ^ 0x9e37_79b9_7f4a_7c15);
if let Some(g) = layer.layer_styles() {
hash_debug(&mut h, g);
hash_values(&mut h, ctx, layer, g);
}
h.finish()
}
/// A sub-key of `key` (styled layer passes, flattened buffer).
pub fn derive(key: u64, i: u64) -> u64 {
let mut h = KeyHasher(key);
i.hash(&mut h);
h.finish()
}
+136 -22
View File
@@ -11,6 +11,7 @@ pub mod cache;
pub mod eval;
pub mod masks;
pub mod shapes;
pub mod styles;
pub mod text;
pub mod three_d;
@@ -53,7 +54,7 @@ impl EffectHost for FxHost<'_, '_, '_> {
}
let sub = Renderer { depth: self.r.depth + 1, ..*self.r };
// The cached layer buffer is shared; effects get their own copy.
let buf = if masks_and_effects { (*sub.layer_buf(self.ctx, other)?).clone() } else { sub.source(self.ctx, other)? };
let buf = if masks_and_effects { (*sub.content_buf(self.ctx, other)?).clone() } else { sub.source(self.ctx, other)? };
let size = source_size(self.r.project, other);
let size = if size.0 == 0 { [self.ctx.comp.width as f64, self.ctx.comp.height as f64] } else { [size.0 as f64, size.1 as f64] };
Some(LayerPixels { buf, size })
@@ -262,13 +263,32 @@ impl<'a> Renderer<'a> {
}
}
/// Fully processed layer buffer (source → masks → effects).
/// Fully processed layer buffer (source → masks → effects → layer styles, flattened).
/// Served from the layer cache when the layer's content key matches.
pub fn layer_buf(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Arc<Buf>> {
self.layer_buf_timed(ctx, layer, None)
let st = self.styled(ctx, layer, None)?;
if st.passes.is_empty() {
return Some(st.body);
}
let key = st.key.map(|k| cache::derive(k, 0xf1a7));
if let (Some(c), Some(k)) = (self.cache, key)
&& let Some(b) = c.get(k)
{
return Some(b);
}
let flat = Arc::new(styles::Styled { passes: st.passes.iter().map(|(b, m)| ((**b).clone(), *m)).collect(), body: (*st.body).clone() }.flatten());
if let (Some(c), Some(k)) = (self.cache, key) {
c.insert(k, flat.clone());
}
Some(flat)
}
fn layer_buf_timed(&self, ctx: &EvalCtx, layer: &Layer, mut timing: Option<&mut LayerTiming>) -> Option<Arc<Buf>> {
/// Layer pixels after source → masks → effects (no layer styles).
pub fn content_buf(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Arc<Buf>> {
self.content_buf_timed(ctx, layer, None).map(|(b, _)| b)
}
fn content_buf_timed(&self, ctx: &EvalCtx, layer: &Layer, mut timing: Option<&mut LayerTiming>) -> Option<(Arc<Buf>, Option<u64>)> {
let key = self.cache.and_then(|_| cache::layer_key(ctx, layer, self.opts.scale, self.opts.draft));
if let (Some(c), Some(k)) = (self.cache, key)
&& let Some(b) = c.get(k)
@@ -276,7 +296,7 @@ impl<'a> Renderer<'a> {
if let Some(t) = timing.as_deref_mut() {
t.cached = true;
}
return Some(b);
return Some((b, key));
}
let mut buf = self.source(ctx, layer)?;
masks::apply(ctx, layer, &mut buf);
@@ -284,7 +304,42 @@ impl<'a> Renderer<'a> {
if let (Some(c), Some(k)) = (self.cache, key) {
c.insert(k, buf.clone());
}
Some(buf)
Some((buf, key))
}
/// The layer with its styles: exterior passes and body (cached per pass).
fn styled(&self, ctx: &EvalCtx, layer: &Layer, mut timing: Option<&mut LayerTiming>) -> Option<StyledLayer> {
let (content, ckey) = self.content_buf_timed(ctx, layer, timing.as_deref_mut())?;
if !styles::active(ctx, layer) {
return Some(StyledLayer { passes: vec![], body: content.clone(), content, key: None, plain: true });
}
let modes = styles::pass_modes(ctx, layer);
let key = ckey.map(|k| cache::styles_key(ctx, layer, k));
if let (Some(c), Some(k)) = (self.cache, key)
&& let Some(body) = c.get(k)
{
let passes: Option<Vec<_>> = modes.iter().enumerate().map(|(i, m)| c.get(cache::derive(k, i as u64 + 1)).map(|b| (b, *m))).collect();
if let Some(passes) = passes {
return Some(StyledLayer { passes, body, content, key, plain: false });
}
}
let t0 = std::time::Instant::now();
let size = source_size(self.project, layer);
let layer_size = if size.0 == 0 { [ctx.comp.width as f64, ctx.comp.height as f64] } else { [size.0 as f64, size.1 as f64] };
let st = styles::render(ctx, layer, &content, layer_size);
if let Some(t) = timing {
t.cached = false;
t.effects.push(("layerStyles".into(), t0.elapsed().as_secs_f64() * 1e3));
}
let body = Arc::new(st.body);
let passes: Vec<_> = st.passes.into_iter().map(|(b, m)| (Arc::new(b), m)).collect();
if let (Some(c), Some(k)) = (self.cache, key) {
c.insert(k, body.clone());
for (i, (b, _)) in passes.iter().enumerate() {
c.insert(cache::derive(k, i as u64 + 1), b.clone());
}
}
Some(StyledLayer { passes, body, content, key, plain: false })
}
fn sampling(&self, layer: &Layer) -> effectcraft_raster::Sampling {
@@ -341,34 +396,79 @@ impl<'a> Renderer<'a> {
self.draw_adjustment(ctx, layer, canvas, opacity);
return;
}
let Some(prof) = self.profile else {
if let Some(buf) = self.layer_buf(ctx, layer) {
self.composite_layer(ctx, layer, &buf, canvas, opacity);
}
return;
};
let mut timing = LayerTiming { depth: self.depth, layer: layer.name.clone(), ..Default::default() };
let mut timing = self.profile.map(|_| LayerTiming { depth: self.depth, layer: layer.name.clone(), ..Default::default() });
let t0 = std::time::Instant::now();
let Some(buf) = self.layer_buf_timed(ctx, layer, Some(&mut timing)) else { return };
let Some(st) = self.styled(ctx, layer, timing.as_mut()) else { return };
let t1 = std::time::Instant::now();
self.composite_layer(ctx, layer, &buf, canvas, opacity);
timing.process_ms = (t1 - t0).as_secs_f64() * 1e3;
timing.composite_ms = t1.elapsed().as_secs_f64() * 1e3;
if let Ok(mut v) = prof.lock() {
v.push(timing);
if st.plain {
self.composite_layer(ctx, layer, &st.body, canvas, opacity);
} else {
self.composite_styled(ctx, layer, &st, canvas, opacity);
}
if let (Some(prof), Some(mut timing)) = (self.profile, timing) {
timing.process_ms = (t1 - t0).as_secs_f64() * 1e3;
timing.composite_ms = t1.elapsed().as_secs_f64() * 1e3;
if let Ok(mut v) = prof.lock() {
v.push(timing);
}
}
}
/// Composite a styled layer: exterior passes with their own modes, then the body with the
/// layer's mode; the layer's opacity fades the whole stack. Knockout clears what is below the
/// layer's content; switched-off R/G/B channels keep the values below.
fn composite_styled(&self, ctx: &EvalCtx, layer: &Layer, st: &StyledLayer, canvas: &mut Image, opacity: f32) {
let bl = styles::blending(ctx, layer);
let matte = layer.track_matte.is_some_and(|tm| tm.layer != layer.id && ctx.comp.layer(tm.layer).is_some());
if matte || layer.preserve_transparency {
let mut iso = Image::new(canvas.width, canvas.height);
for (b, m) in &st.passes {
self.place(ctx, layer, b, &mut iso, *m, 1.0);
}
self.place(ctx, layer, &st.body, &mut iso, BlendMode::Normal, 1.0);
self.composite_iso(ctx, layer, iso, canvas, opacity);
return;
}
let mut tmp = canvas.clone();
if bl.knockout > 0 {
let mut k = Image::new(canvas.width, canvas.height);
self.place(ctx, layer, &st.content, &mut k, BlendMode::Normal, 1.0);
tmp.data.par_iter_mut().zip(k.data.par_iter()).for_each(|(p, q)| {
let keep = 1.0 - q[3].clamp(0.0, 1.0);
for c in p.iter_mut() {
*c *= keep;
}
});
}
for (b, m) in &st.passes {
self.place(ctx, layer, b, &mut tmp, *m, 1.0);
}
self.place(ctx, layer, &st.body, &mut tmp, layer.blend_mode, 1.0);
let ch = bl.channels;
canvas.data.par_iter_mut().zip(tmp.data.par_iter()).for_each(|(c, t)| {
for i in 0..4 {
let v = if i < 3 && !ch[i] { c[i] } else { t[i] };
c[i] += (v - c[i]) * opacity;
}
});
}
fn composite_layer(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf, canvas: &mut Image, opacity: f32) {
let matte = layer.track_matte.and_then(|tm| ctx.comp.layer(tm.layer).filter(|m| m.id != layer.id).map(|m| (m, tm.kind)));
let preserve = layer.preserve_transparency;
if matte.is_none() && !preserve {
let matte = layer.track_matte.is_some_and(|tm| tm.layer != layer.id && ctx.comp.layer(tm.layer).is_some());
if !matte && !layer.preserve_transparency {
self.place(ctx, layer, buf, canvas, layer.blend_mode, opacity);
return;
}
// Render in isolation, then matte / preserve transparency, then blend.
let mut iso = Image::new(canvas.width, canvas.height);
self.place(ctx, layer, buf, &mut iso, BlendMode::Normal, 1.0);
self.composite_iso(ctx, layer, iso, canvas, opacity);
}
/// Apply the track matte / preserve transparency to an isolated layer render, then blend.
fn composite_iso(&self, ctx: &EvalCtx, layer: &Layer, mut iso: Image, canvas: &mut Image, opacity: f32) {
let matte = layer.track_matte.and_then(|tm| ctx.comp.layer(tm.layer).filter(|m| m.id != layer.id).map(|m| (m, tm.kind)));
let preserve = layer.preserve_transparency;
if let Some((m, kind)) = matte {
let mut mimg = Image::new(canvas.width, canvas.height);
if m.is_active_at(ctx.time)
@@ -420,6 +520,18 @@ impl<'a> Renderer<'a> {
}
}
/// A layer ready to composite (see [`Renderer::styled`]).
struct StyledLayer {
passes: Vec<(Arc<Buf>, BlendMode)>,
body: Arc<Buf>,
/// Pre-style pixels (knockout shape).
content: Arc<Buf>,
/// Styled cache key.
key: Option<u64>,
/// No layer styles: composite `body` the usual way.
plain: bool,
}
/// Convenience: render one frame of a comp with no footage source.
pub fn render_frame(project: &Project, comp: ItemId, t: Tick, scale: f64) -> Image {
Renderer::new(project, &NoFootage, RenderOpts { scale, ..Default::default() }).comp_frame(comp, t)
@@ -429,6 +541,8 @@ pub fn render_frame(project: &Project, comp: ItemId, t: Tick, scale: f64) -> Ima
mod tests;
#[cfg(test)]
mod tests_remap;
#[cfg(test)]
mod tests_styles;
/// Layer-space bounds `[x0, y0, x1, y1]` of a layer's content at the context time (source size
/// for solids/footage/precomps, glyph bounds for text, painted bounds for shapes). Used for viewer
+741
View File
@@ -0,0 +1,741 @@
//! Layer Styles rendering (Photoshop-style layer effects, as After Effects renders them).
//!
//! Styles run in layer space after masks and effects and before the transform. The layer's
//! processed pixels (the *content*) become:
//!
//! * **exterior passes** — Drop Shadow, then Outer Glow — separate buffers composited onto the
//! comp *below* the layer, each with its own blend mode (so a Multiply shadow multiplies the
//! layers underneath, as in AE);
//! * the **body** — the content scaled by Fill Opacity, then the interior styles in Photoshop's
//! stacking order (Gradient Overlay, Color Overlay, Satin, Inner Glow, Inner Shadow), each
//! clipped to the content's alpha and blended with its mode, then Stroke and Bevel and Emboss.
//! With *Blend Interior Styles as Group* the interior styles are faded by Fill Opacity too.
//!
//! The compositor applies the layer's opacity to the whole stack, and Advanced Blending's
//! Knockout and R/G/B channel switches at composite time (see `Renderer::composite_styled`).
//! Every distance is in layer pixels and scales with the render resolution (Half, Quarter…).
//!
//! Shapes come from a signed distance field of the content alpha (exact Euclidean distance
//! transform, Felzenszwalb–Huttenlocher) for spread/choke, strokes, precise glows and bevel
//! profiles, and from the shared separable Gaussian (`effects::util::gauss_plane`) for soft
//! shadows and glows. All per-pixel work is parallel (rayon).
use effectcraft_color::{BlendMode, blend_pixel};
use effectcraft_effects::Buf;
use effectcraft_effects::util::{Plane, gauss_plane};
use effectcraft_keyframe::{Gradient, Value};
use effectcraft_project::styles::{self as st, style_blend_mode};
use effectcraft_project::{Layer, PropGroup};
use effectcraft_raster::{Image, hash_noise};
use rayon::prelude::*;
use crate::eval::EvalCtx;
/// Largest padding (buffer pixels) styles may add on each side.
const MAX_PAD: f64 = 2048.0;
/// A styled layer: exterior passes (bottom first, each with its blend mode) and the body.
#[derive(Clone, Debug)]
pub struct Styled {
pub passes: Vec<(Buf, BlendMode)>,
pub body: Buf,
}
impl Styled {
/// Everything composited into one buffer (for 3D layers, track mattes and layer references).
pub fn flatten(&self) -> Buf {
if self.passes.is_empty() {
return self.body.clone();
}
let mut img = Image::new(self.body.img.width, self.body.img.height);
for (p, m) in &self.passes {
img.blend_from(&p.img, *m, 1.0, 0);
}
img.blend_from(&self.body.img, BlendMode::Normal, 1.0, 0);
Buf { img, offset: self.body.offset, scale: self.body.scale }
}
}
/// Advanced Blending options evaluated at a time.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Blending {
pub fill: f32,
/// Red, green, blue channel switches.
pub channels: [bool; 3],
/// 0 None, 1 Shallow, 2 Deep.
pub knockout: u32,
pub interior_group: bool,
}
impl Default for Blending {
fn default() -> Self {
Blending { fill: 1.0, channels: [true; 3], knockout: 0, interior_group: false }
}
}
/// The layer's Layer Styles group when styles are switched on.
pub fn group(layer: &Layer) -> Option<&PropGroup> {
layer.layer_styles().filter(|g| g.enabled && layer.can_have_styles())
}
/// Evaluated Advanced Blending (defaults when the layer has no styles).
pub fn blending(ctx: &EvalCtx, layer: &Layer) -> Blending {
let Some(adv) = group(layer).and_then(|g| g.sub(st::BLENDING)).and_then(|b| b.sub("advancedBlending")) else { return Blending::default() };
Blending {
fill: (ctx.f(layer, adv, "fillOpacity", 100.0) / 100.0).clamp(0.0, 1.0) as f32,
channels: [ctx.b(layer, adv, "red"), ctx.b(layer, adv, "green"), ctx.b(layer, adv, "blue")],
knockout: ctx.e(layer, adv, "knockout"),
interior_group: ctx.b(layer, adv, "blendInteriorAsGroup"),
}
}
/// Global light (angle, altitude) in degrees for the layer.
fn global_light(ctx: &EvalCtx, layer: &Layer, g: &PropGroup) -> (f64, f64) {
match g.sub(st::BLENDING) {
Some(bo) => (ctx.f(layer, bo, "globalLightAngle", st::DEFAULT_ANGLE), ctx.f(layer, bo, "globalLightAltitude", st::DEFAULT_ALTITUDE)),
None => (st::DEFAULT_ANGLE, st::DEFAULT_ALTITUDE),
}
}
/// Does the layer need the styled path (any style on, or non-default blending)?
pub fn active(ctx: &EvalCtx, layer: &Layer) -> bool {
let Some(g) = group(layer) else { return false };
g.groups().any(|s| s.enabled && s.match_id != st::BLENDING) || blending(ctx, layer) != Blending::default()
}
/// Blend modes of the exterior passes [`render`] produces (Drop Shadow, then Outer Glow).
pub fn pass_modes(ctx: &EvalCtx, layer: &Layer) -> Vec<BlendMode> {
let Some(g) = group(layer) else { return vec![] };
["dropShadow", "outerGlow"].iter().filter_map(|id| g.sub(id).filter(|s| s.enabled)).map(|s| style_blend_mode(ctx.e(layer, s, "blendMode"))).collect()
}
// ------------------------------------------------------------------------------------------
// Evaluated styles
struct Shadow {
mode: BlendMode,
color: [f32; 3],
opacity: f32,
offset: [f64; 2],
spread: f64,
size: f64,
noise: f32,
knocks_out: bool,
}
struct Glow {
mode: BlendMode,
opacity: f32,
noise: f32,
gradient: Option<Gradient>,
color: [f32; 3],
precise: bool,
center: bool,
spread: f64,
size: f64,
range: f64,
jitter: f32,
}
struct Bevel {
style: u32,
technique: u32,
depth: f64,
down: bool,
size: f64,
soften: f64,
angle: f64,
altitude: f64,
hl: (BlendMode, [f32; 3], f32),
sh: (BlendMode, [f32; 3], f32),
}
struct Satin {
mode: BlendMode,
color: [f32; 3],
opacity: f32,
offset: [f64; 2],
size: f64,
invert: bool,
}
struct Overlay {
mode: BlendMode,
color: [f32; 3],
opacity: f32,
}
struct GradOverlay {
mode: BlendMode,
opacity: f32,
gradient: Gradient,
angle: f64,
style: u32,
reverse: bool,
align: bool,
scale: f64,
offset: [f64; 2],
}
struct Stroke {
mode: BlendMode,
color: [f32; 3],
size: f64,
opacity: f32,
position: u32,
}
#[derive(Default)]
struct Styles {
drop: Option<Shadow>,
inner_shadow: Option<Shadow>,
outer_glow: Option<Glow>,
inner_glow: Option<Glow>,
bevel: Option<Bevel>,
satin: Option<Satin>,
color: Option<Overlay>,
gradient: Option<GradOverlay>,
stroke: Option<Stroke>,
}
fn rgb(c: [f32; 4]) -> [f32; 3] {
[c[0], c[1], c[2]]
}
/// Light direction → shadow offset (the shadow falls away from the light).
fn offset(angle: f64, distance: f64) -> [f64; 2] {
let a = angle.to_radians();
[-distance * a.cos(), distance * a.sin()]
}
fn evaluate(ctx: &EvalCtx, layer: &Layer, g: &PropGroup) -> Styles {
let (ga, galt) = global_light(ctx, layer, g);
let mut s = Styles::default();
for sg in g.groups().filter(|x| x.enabled) {
let f = |m: &str, d: f64| ctx.f(layer, sg, m, d);
let pct = |m: &str, d: f64| (ctx.f(layer, sg, m, d) / 100.0).clamp(0.0, 1.0);
let mode = |m: &str| style_blend_mode(ctx.e(layer, sg, m));
let col = |m: &str| rgb(ctx.color(layer, sg, m));
let ang = || if ctx.b(layer, sg, "useGlobalLight") { ga } else { f("angle", st::DEFAULT_ANGLE) };
let grad = || match ctx.group_value(layer, sg, "colors") {
Some(Value::Gradient(g)) => g,
_ => Gradient::default(),
};
match sg.match_id.as_str() {
"dropShadow" | "innerShadow" => {
let inner = sg.match_id == "innerShadow";
let sh = Shadow {
mode: mode("blendMode"),
color: col("color"),
opacity: pct("opacity", 75.0) as f32,
offset: offset(ang(), f("distance", 5.0).max(0.0)),
spread: pct(if inner { "choke" } else { "spread" }, 0.0),
size: f("size", 5.0).clamp(0.0, 250.0),
noise: pct("noise", 0.0) as f32,
knocks_out: !inner && ctx.b(layer, sg, "knocksOut"),
};
if inner {
s.inner_shadow = Some(sh);
} else {
s.drop = Some(sh);
}
}
"outerGlow" | "innerGlow" => {
let inner = sg.match_id == "innerGlow";
let gl = Glow {
mode: mode("blendMode"),
opacity: pct("opacity", 75.0) as f32,
noise: pct("noise", 0.0) as f32,
gradient: (ctx.e(layer, sg, "colorType") == 1).then(grad),
color: col("color"),
precise: ctx.e(layer, sg, "technique") == 1,
center: inner && ctx.e(layer, sg, "source") == 0,
spread: pct(if inner { "choke" } else { "spread" }, 0.0),
size: f("size", 5.0).clamp(0.0, 250.0),
range: pct("range", 50.0).max(0.01),
jitter: pct("jitter", 0.0) as f32,
};
if inner {
s.inner_glow = Some(gl);
} else {
s.outer_glow = Some(gl);
}
}
"bevelEmboss" => {
let gl = ctx.b(layer, sg, "useGlobalLight");
s.bevel = Some(Bevel {
style: ctx.e(layer, sg, "style"),
technique: ctx.e(layer, sg, "technique"),
depth: f("depth", 100.0).clamp(1.0, 1000.0) / 100.0,
down: ctx.e(layer, sg, "direction") == 1,
size: f("size", 5.0).clamp(0.0, 250.0),
soften: f("soften", 0.0).clamp(0.0, 16.0),
angle: if gl { ga } else { f("angle", st::DEFAULT_ANGLE) },
altitude: if gl { galt } else { f("altitude", st::DEFAULT_ALTITUDE) }.clamp(0.0, 90.0),
hl: (mode("highlightMode"), col("highlightColor"), pct("highlightOpacity", 75.0) as f32),
sh: (mode("shadowMode"), col("shadowColor"), pct("shadowOpacity", 75.0) as f32),
});
}
"satin" => {
s.satin = Some(Satin {
mode: mode("blendMode"),
color: col("color"),
opacity: pct("opacity", 50.0) as f32,
offset: offset(f("angle", 19.0), f("distance", 11.0).clamp(0.0, 250.0)),
size: f("size", 14.0).clamp(0.0, 250.0),
invert: ctx.b(layer, sg, "invert"),
});
}
"colorOverlay" => s.color = Some(Overlay { mode: mode("blendMode"), color: col("color"), opacity: pct("opacity", 100.0) as f32 }),
"gradientOverlay" => {
s.gradient = Some(GradOverlay {
mode: mode("blendMode"),
opacity: pct("opacity", 100.0) as f32,
gradient: grad(),
angle: f("angle", 90.0),
style: ctx.e(layer, sg, "style"),
reverse: ctx.b(layer, sg, "reverse"),
align: ctx.b(layer, sg, "alignWithLayer"),
scale: f("scale", 100.0).clamp(10.0, 150.0) / 100.0,
offset: ctx.v2(layer, sg, "offset", [0.0; 2]),
});
}
"stroke" => {
s.stroke = Some(Stroke {
mode: mode("blendMode"),
color: col("color"),
size: f("size", 3.0).clamp(0.0, 250.0),
opacity: pct("opacity", 100.0) as f32,
position: ctx.e(layer, sg, "position"),
});
}
_ => {}
}
}
s
}
/// Layer-space margin the styles reach beyond the content.
fn reach(s: &Styles) -> f64 {
let soft = |size: f64| size * 1.6;
let mut r: f64 = 0.0;
if let Some(d) = &s.drop {
r = r.max(d.offset[0].abs().max(d.offset[1].abs()) + soft(d.size));
}
if let Some(d) = &s.inner_shadow {
r = r.max(d.offset[0].abs().max(d.offset[1].abs()) + soft(d.size));
}
if let Some(g) = &s.outer_glow {
r = r.max(soft(g.size));
}
if let Some(g) = &s.inner_glow {
r = r.max(soft(g.size));
}
if let Some(b) = &s.bevel {
r = r.max(b.size + b.soften * 2.0);
}
if let Some(st) = &s.stroke {
r = r.max(st.size);
}
if let Some(sa) = &s.satin {
r = r.max(sa.offset[0].abs().max(sa.offset[1].abs()) + soft(sa.size));
}
r
}
// ------------------------------------------------------------------------------------------
// Distance fields
/// "Infinite" squared distance (finite so parabola intersections stay exact in f64).
const FAR: f64 = 1e10;
/// 1D squared Euclidean distance transform (Felzenszwalb & Huttenlocher) of `f` into `d`.
fn dt1d(f: &[f32], d: &mut [f32]) {
let n = f.len();
if n == 0 {
return;
}
let fv = |i: usize| (f[i] as f64).min(FAR);
let mut v = vec![0usize; n];
let mut z = vec![0f64; n + 1];
let mut k = 0usize;
z[0] = f64::NEG_INFINITY;
z[1] = f64::INFINITY;
let cross = |q: usize, p: usize| ((fv(q) + (q * q) as f64) - (fv(p) + (p * p) as f64)) / (2.0 * (q as f64 - p as f64));
for q in 1..n {
let mut s = cross(q, v[k]);
while k > 0 && s <= z[k] {
k -= 1;
s = cross(q, v[k]);
}
k += 1;
v[k] = q;
z[k] = s;
z[k + 1] = f64::INFINITY;
}
k = 0;
for (q, dq) in d.iter_mut().enumerate() {
while z[k + 1] < q as f64 {
k += 1;
}
let dx = q as f64 - v[k] as f64;
*dq = (dx * dx + fv(v[k])) as f32;
}
}
/// Euclidean distance (pixels) from every pixel to the nearest pixel where `seed` is true.
fn edt(w: usize, h: usize, seed: impl Fn(usize) -> bool + Sync) -> Plane {
let f = Plane { w, h, data: (0..w * h).into_par_iter().map(|i| if seed(i) { 0.0 } else { FAR as f32 }).collect() };
let rows = f.map_rows(dt1d);
let cols = rows.transpose().map_rows(dt1d).transpose();
cols.map(|v| v.sqrt())
}
/// Signed distance to the content edge in pixels (negative inside), anti-aliased at the edge:
/// a partially covered pixel with alpha `a` sits at `0.5 - a`.
fn sdf(alpha: &Plane) -> Plane {
let (w, h) = (alpha.w, alpha.h);
let to_in = edt(w, h, |i| alpha.data[i] >= 0.5);
let to_out = edt(w, h, |i| alpha.data[i] < 0.5);
Plane {
w,
h,
data: (0..w * h)
.into_par_iter()
.map(|i| {
let a = alpha.data[i];
if a > 0.0 && a < 1.0 {
0.5 - a
} else if a >= 0.5 {
-(to_out.data[i] - 0.5)
} else {
to_in.data[i] - 0.5
}
})
.collect(),
}
}
/// Coverage of the content grown by `r` pixels (negative `r` shrinks).
#[inline]
fn grown(sdf: f32, r: f32) -> f32 {
(r + 0.5 - sdf).clamp(0.0, 1.0)
}
// ------------------------------------------------------------------------------------------
// Helpers
/// Soft shape: content grown by `spread` of `size`, blurred by the rest (`inverse` = the
/// outside of the content, for inner shadows/glows).
fn soft_shape(alpha: &Plane, d: &Lazy, size: f64, spread: f64, inverse: bool) -> Plane {
let hard = size * spread;
let base = if hard > 0.01 {
let sd = d.get(alpha);
let r = hard as f32;
if inverse { sd.map(|v| grown(-v, r)) } else { sd.map(|v| grown(v, r)) }
} else if inverse {
alpha.map(|a| 1.0 - a)
} else {
alpha.clone()
};
let sigma = (size - hard).max(0.0) / 2.0;
if sigma > 0.05 { gauss_plane(&base, sigma, sigma) } else { base }
}
/// Bilinear read of `p` displaced by `off` pixels (the plane moves by `off`).
#[inline]
fn shifted(p: &Plane, x: usize, y: usize, off: [f64; 2]) -> f32 {
p.sample(x as f64 + 0.5 - off[0], y as f64 + 0.5 - off[1])
}
struct Lazy(std::sync::OnceLock<Plane>);
impl Lazy {
fn get(&self, alpha: &Plane) -> &Plane {
self.0.get_or_init(|| sdf(alpha))
}
}
#[inline]
fn noisy(a: f32, noise: f32, x: usize, y: usize, seed: u32) -> f32 {
if noise <= 0.0 { a } else { a * (1.0 - noise * hash_noise(x as u32, y as u32, seed)) }
}
/// Blend a straight colour with coverage `a` onto every pixel of `img` (`f(x, y)` → (colour, a)).
fn blend_into(img: &mut Image, mode: BlendMode, seed: u32, f: impl Fn(usize, usize) -> ([f32; 3], f32) + Sync) {
let w = img.width as usize;
img.data.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| {
for (x, d) in row.iter_mut().enumerate() {
let (c, a) = f(x, y);
if a <= 0.0 {
continue;
}
let src = [c[0] * a, c[1] * a, c[2] * a, a];
let n = if mode == BlendMode::Dissolve { hash_noise(x as u32, y as u32, seed) } else { 0.5 };
*d = blend_pixel(mode, *d, src, n);
}
});
}
/// A pass image filled with a straight colour at coverage `f(x, y)`.
fn pass_image(w: usize, h: usize, f: impl Fn(usize, usize) -> ([f32; 3], f32) + Sync) -> Image {
let mut img = Image::new(w as u32, h as u32);
img.data.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| {
for (x, d) in row.iter_mut().enumerate() {
let (c, a) = f(x, y);
let a = a.clamp(0.0, 1.0);
*d = [c[0] * a, c[1] * a, c[2] * a, a];
}
});
img
}
/// Glow intensity → (colour, alpha) for colour or gradient glows.
fn glow_color(g: &Glow, v: f32, x: usize, y: usize) -> ([f32; 3], f32) {
let v = v.clamp(0.0, 1.0).powf((0.5 / g.range) as f32);
match &g.gradient {
None => (g.color, v),
Some(gr) => {
let j = if g.jitter > 0.0 { (hash_noise(x as u32, y as u32, 77) - 0.5) * g.jitter } else { 0.0 };
let c = gr.sample((1.0 - v + j) as f64);
([c[0], c[1], c[2]], c[3] * (v * 8.0).min(1.0))
}
}
}
/// Glow intensity (0..1) from the content (`inner` glows read the inverse).
fn glow_intensity(alpha: &Plane, d: &Lazy, g: &Glow, s: f64, inner: bool) -> Plane {
let size = g.size * s;
if g.precise {
let sd = d.get(alpha);
let hard = (size * g.spread) as f32;
let soft = (size as f32 - hard).max(0.5);
return sd.map(|v| {
let v = if inner { -v } else { v };
(1.0 - (v - hard).max(0.0) / soft).clamp(0.0, 1.0)
});
}
soft_shape(alpha, d, size, g.spread, inner).map(|v| (v * 2.0).min(1.0))
}
// ------------------------------------------------------------------------------------------
// Rendering
/// Render the layer's styles around its processed `content` (layer size `layer_size` in layer
/// pixels, for Gradient Overlay without Align with Layer).
pub fn render(ctx: &EvalCtx, layer: &Layer, content: &Buf, layer_size: [f64; 2]) -> Styled {
let Some(g) = group(layer) else { return Styled { passes: vec![], body: content.clone() } };
let styles = evaluate(ctx, layer, g);
let bl = blending(ctx, layer);
let s = content.scale.max(1e-6);
let pad = ((reach(&styles) * s).ceil() + 2.0).min(MAX_PAD) as u32;
let mut buf = content.clone();
if content.img.is_empty() {
return Styled { passes: vec![], body: buf };
}
buf.pad(pad);
let (w, h) = (buf.img.width as usize, buf.img.height as usize);
let alpha = Plane::alpha(&buf.img);
let d = Lazy(std::sync::OnceLock::new());
let seed = layer.id.0 as u32;
// ---- exterior passes
let mut passes = vec![];
if let Some(ds) = &styles.drop {
let shape = soft_shape(&alpha, &d, ds.size * s, ds.spread, false);
let off = [ds.offset[0] * s, ds.offset[1] * s];
let img = pass_image(w, h, |x, y| {
let mut a = shifted(&shape, x, y, off) * ds.opacity;
if ds.knocks_out {
a *= 1.0 - alpha.data[y * w + x];
}
(ds.color, noisy(a, ds.noise, x, y, seed))
});
passes.push((Buf { img, offset: buf.offset, scale: buf.scale }, ds.mode));
}
if let Some(gl) = &styles.outer_glow {
let v = glow_intensity(&alpha, &d, gl, s, false);
let img = pass_image(w, h, |x, y| {
let i = y * w + x;
let (c, a) = glow_color(gl, v.data[i], x, y);
// The glow sits outside the content (knocked out under it).
(c, noisy(a * gl.opacity * (1.0 - alpha.data[i]), gl.noise, x, y, seed ^ 0x5a5a))
});
passes.push((Buf { img, offset: buf.offset, scale: buf.scale }, gl.mode));
}
// ---- body
let mut body = buf.img.clone();
if !bl.interior_group && bl.fill < 1.0 {
body.scale_alpha(bl.fill);
}
let cov = |i: usize| alpha.data[i];
if let Some(go) = &styles.gradient {
let (cx, cy, hw, hh) = gradient_frame(go, &buf, &alpha, layer_size);
let (ux, uy) = (go.angle.to_radians().cos(), -go.angle.to_radians().sin());
let ext = ((hw * ux).abs() + (hh * uy).abs()).max(1e-3) * go.scale;
let radius = hw.max(hh).max(1e-3) * go.scale;
blend_into(&mut body, go.mode, seed, |x, y| {
let (px, py) = (x as f64 + 0.5 - cx, y as f64 + 0.5 - cy);
let along = px * ux + py * uy;
let across = -px * uy + py * ux;
let mut t = match go.style {
1 => (px * px + py * py).sqrt() / radius,
2 => {
let a = (-py).atan2(px) - go.angle.to_radians();
(a / std::f64::consts::TAU).rem_euclid(1.0)
}
3 => along.abs() / ext,
4 => (along.abs() + across.abs()) / ext,
_ => 0.5 + along / (2.0 * ext),
};
if go.reverse {
t = 1.0 - t;
}
let c = go.gradient.sample(t.clamp(0.0, 1.0));
([c[0], c[1], c[2]], c[3] * go.opacity * cov(y * w + x))
});
}
if let Some(co) = &styles.color {
blend_into(&mut body, co.mode, seed, |x, y| (co.color, co.opacity * cov(y * w + x)));
}
if let Some(sa) = &styles.satin {
let b = gauss_plane(&alpha, sa.size * s / 2.0, sa.size * s / 2.0);
let off = [sa.offset[0] * s, sa.offset[1] * s];
blend_into(&mut body, sa.mode, seed, |x, y| {
let v = (shifted(&b, x, y, off) - shifted(&b, x, y, [-off[0], -off[1]])).abs().min(1.0);
let v = if sa.invert { 1.0 - v } else { v };
(sa.color, v * sa.opacity * cov(y * w + x))
});
}
if let Some(gl) = &styles.inner_glow {
let v = glow_intensity(&alpha, &d, gl, s, true);
blend_into(&mut body, gl.mode, seed, |x, y| {
let i = y * w + x;
let e = if gl.center { 1.0 - v.data[i] } else { v.data[i] };
let (c, a) = glow_color(gl, e, x, y);
(c, noisy(a * gl.opacity * cov(i), gl.noise, x, y, seed ^ 0x3c3c))
});
}
if let Some(is) = &styles.inner_shadow {
let shape = soft_shape(&alpha, &d, is.size * s, is.spread, true);
let off = [is.offset[0] * s, is.offset[1] * s];
blend_into(&mut body, is.mode, seed, |x, y| {
let a = shifted(&shape, x, y, off).min(1.0) * is.opacity * cov(y * w + x);
(is.color, noisy(a, is.noise, x, y, seed ^ 0x1234))
});
}
if bl.interior_group && bl.fill < 1.0 {
body.scale_alpha(bl.fill);
}
if let Some(sk) = &styles.stroke {
let sd = d.get(&alpha);
let size = (sk.size * s) as f32;
let (r_out, r_in) = match sk.position {
1 => (0.0, size),
2 => (size / 2.0, size / 2.0),
_ => (size, 0.0),
};
// Inside part: blended over the body within the content.
if r_in > 0.0 {
blend_into(&mut body, sk.mode, seed, |x, y| {
let i = y * w + x;
let ring = (alpha.data[i] - grown(sd.data[i], -r_in)).max(0.0);
(sk.color, ring * sk.opacity)
});
}
// Outside part: covers exactly what the content leaves uncovered.
if r_out > 0.0 {
body.data.par_iter_mut().enumerate().for_each(|(i, p)| {
let ring = (grown(sd.data[i], r_out) - alpha.data[i]).max(0.0) * sk.opacity;
if ring > 0.0 {
let src = [sk.color[0] * ring, sk.color[1] * ring, sk.color[2] * ring, ring];
for c in 0..4 {
p[c] += src[c];
}
}
});
}
}
if let Some(bv) = &styles.bevel {
bevel(&mut body, &alpha, &d, bv, s, seed);
}
Styled { passes, body: Buf { img: body, offset: buf.offset, scale: buf.scale } }
}
/// Gradient Overlay frame in buffer pixels: centre and half extents.
fn gradient_frame(go: &GradOverlay, buf: &Buf, alpha: &Plane, layer_size: [f64; 2]) -> (f64, f64, f64, f64) {
let s = buf.scale;
let (x0, y0, x1, y1) = if go.align {
let img = Image { width: alpha.w as u32, height: alpha.h as u32, data: alpha.data.iter().map(|&a| [0.0, 0.0, 0.0, a]).collect() };
match img.alpha_bounds() {
Some((a, b, c, d)) => (a as f64, b as f64, c as f64, d as f64),
None => (0.0, 0.0, 1.0, 1.0),
}
} else {
let (a, b) = buf.to_px([0.0, 0.0]);
let (c, d) = buf.to_px(layer_size);
(a, b, c, d)
};
((x0 + x1) / 2.0 + go.offset[0] * s, (y0 + y1) / 2.0 + go.offset[1] * s, (x1 - x0) / 2.0, (y1 - y0) / 2.0)
}
/// Bevel and Emboss: a height field from the content edge, lit by a directional light.
fn bevel(body: &mut Image, alpha: &Plane, d: &Lazy, bv: &Bevel, s: f64, seed: u32) {
let size = (bv.size * s).max(0.5) as f32;
let sd = d.get(alpha);
let w = alpha.w;
// Height profile 0..1 and the region the bevel paints.
let style = bv.style;
let mut height = sd.map(|v| match style {
0 => (1.0 - v.max(0.0) / size).clamp(0.0, 1.0),
2 | 4 => (0.5 - v / (2.0 * size)).clamp(0.0, 1.0),
3 => (v.abs() / size).clamp(0.0, 1.0),
_ => (-v / size + 0.5 / size).clamp(0.0, 1.0),
});
// The distance field is exact at pixel centres only; smoothing the profile removes the
// stair-steps on diagonal edges that lighting would otherwise amplify. Smooth rounds the
// profile over the bevel size; Chisel Hard keeps it crisp, Chisel Soft slightly softer.
let sigma = match bv.technique {
0 => (size as f64 / 4.0).max(1.0),
2 => 1.5,
_ => 0.75,
};
height = gauss_plane(&height, sigma, sigma);
if bv.soften > 0.0 {
let sg = bv.soften * s / 2.0;
height = gauss_plane(&height, sg, sg);
}
let sign = if bv.down { -1.0 } else { 1.0 };
let k = (size as f64 * bv.depth) as f32 * sign;
let (th, al) = (bv.angle.to_radians(), bv.altitude.to_radians());
let light = [(al.cos() * th.cos()) as f32, (-al.cos() * th.sin()) as f32, al.sin() as f32];
let flat = light[2];
let region = |i: usize| -> f32 {
let a = alpha.data[i];
match style {
0 => (grown(sd.data[i], size) - a).max(0.0),
1 => a,
_ => grown(sd.data[i], size),
}
};
let shade = |x: usize, y: usize| -> (f32, f32) {
let hx = |xx: i64| height.get_clamped(xx, y as i64);
let hy = |yy: i64| height.get_clamped(x as i64, yy);
let gx = (hx(x as i64 + 1) - hx(x as i64 - 1)) * 0.5 * k;
let gy = (hy(y as i64 + 1) - hy(y as i64 - 1)) * 0.5 * k;
let n = [-gx, -gy, 1.0];
let len = (n[0] * n[0] + n[1] * n[1] + 1.0).sqrt();
let dot = (n[0] * light[0] + n[1] * light[1] + n[2] * light[2]) / len;
let hl = ((dot - flat) / (1.0 - flat).max(1e-4)).clamp(0.0, 1.0);
let shd = ((flat - dot) / flat.max(1e-4)).clamp(0.0, 1.0);
(hl, shd)
};
blend_into(body, bv.sh.0, seed, |x, y| {
let (_, shd) = shade(x, y);
(bv.sh.1, shd * bv.sh.2 * region(y * w + x))
});
blend_into(body, bv.hl.0, seed, |x, y| {
let (hl, _) = shade(x, y);
(bv.hl.1, hl * bv.hl.2 * region(y * w + x))
});
}
+416
View File
@@ -0,0 +1,416 @@
//! Layer Styles pixel tests.
use effectcraft_color::Label;
use effectcraft_keyframe::{Gradient, Keyframe, Value};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::styles::{self as st, GROUP};
use effectcraft_project::{Comp, ItemId, ItemKind, Layer, LayerSource, Project, Solid};
use effectcraft_time::{FrameRate, Tick};
use crate::{Image, NoFootage, RenderOpts, Renderer, render_frame};
const NORMAL: u32 = 0;
const MULTIPLY: u32 = 3;
fn setup() -> (Project, ItemId, Comp) {
let mut p = Project::default();
let comp = Comp::new(200, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
(p, cid, comp)
}
fn solid(p: &mut Project, comp: &Comp, color: [f32; 3], w: u32, h: u32) -> Layer {
let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color, width: w, height: h, pixel_aspect: 1.0 }));
build::layer(p, comp, "Solid", LayerSource::Solid { item: sid }, (w, h), None)
}
fn add(p: &mut Project, comp: &Comp, l: &mut Layer, style: &str) {
let mut next = p.next_id;
st::add_style(l, &mut Ids(&mut next), style, &comp.global_light, true).unwrap();
p.next_id = next;
}
fn set(l: &mut Layer, path: &str, v: Value) {
l.props.prop_mut(&format!("{GROUP}/{path}")).unwrap_or_else(|| panic!("no {path}")).value = v;
}
/// A 40×40 square of `color` centred in a 200×100 comp (x 80..120, y 30..70) over an optional
/// full-frame background.
fn scene(color: [f32; 3], bg: Option<[f32; 3]>, f: impl FnOnce(&mut Project, &Comp, &mut Layer)) -> (Project, ItemId) {
let (mut p, cid, comp) = setup();
let mut l = solid(&mut p, &comp, color, 40, 40);
f(&mut p, &comp, &mut l);
let mut layers = vec![l];
if let Some(bg) = bg {
layers.push(solid(&mut p, &comp, bg, 200, 100));
}
p.comp_mut(cid).unwrap().layers = layers;
(p, cid)
}
fn render(p: &Project, cid: ItemId) -> Image {
render_frame(p, cid, Tick::ZERO, 1.0)
}
fn near(a: f32, b: f32, tol: f32) -> bool {
(a - b).abs() <= tol
}
#[test]
fn drop_shadow_offset_colour_and_opacity() {
let (p, cid) = scene([1.0; 3], Some([0.5; 3]), |p, c, l| {
add(p, c, l, "dropShadow");
set(l, "dropShadow/size", Value::Scalar(0.0));
set(l, "dropShadow/distance", Value::Scalar(10.0));
set(l, "dropShadow/opacity", Value::Scalar(100.0));
set(l, "dropShadow/blendMode", Value::Enum(NORMAL));
});
let img = render(&p, cid);
// Global light 120° → the shadow falls down-right by (5, 8.66).
assert!(near(img.get(123, 75)[0], 0.0, 0.02), "shadow: {:?}", img.get(123, 75));
assert!(near(img.get(82, 75)[0], 0.5, 0.01), "left of the shadow stays grey");
assert!(near(img.get(100, 50)[0], 1.0, 0.01), "layer on top");
assert!(near(img.get(100, 20)[0], 0.5, 0.01), "above the layer");
// 75 % Multiply (the default mode) over grey: 0.5 × (1 − 0.75).
let (p, cid) = scene([1.0; 3], Some([0.5; 3]), |p, c, l| {
add(p, c, l, "dropShadow");
set(l, "dropShadow/size", Value::Scalar(0.0));
set(l, "dropShadow/distance", Value::Scalar(10.0));
set(l, "dropShadow/color", Value::Color([0.0, 0.0, 0.0, 1.0]));
});
let img = render(&p, cid);
assert!(near(img.get(123, 75)[0], 0.125, 0.02), "{:?}", img.get(123, 75));
// Coloured shadow on a transparent comp extends the layer's bounds.
let (p, cid) = scene([1.0; 3], None, |p, c, l| {
add(p, c, l, "dropShadow");
set(l, "dropShadow/size", Value::Scalar(0.0));
set(l, "dropShadow/distance", Value::Scalar(10.0));
set(l, "dropShadow/opacity", Value::Scalar(50.0));
set(l, "dropShadow/blendMode", Value::Enum(NORMAL));
set(l, "dropShadow/color", Value::Color([0.0, 0.0, 1.0, 1.0]));
});
let img = render(&p, cid);
let px = img.straight(123, 75);
assert!(near(px[3], 0.5, 0.02) && near(px[2], 1.0, 0.01) && px[0] < 0.01, "{px:?}");
}
#[test]
fn soft_shadow_falls_off() {
let (p, cid) = scene([1.0; 3], None, |p, c, l| {
add(p, c, l, "dropShadow");
set(l, "dropShadow/size", Value::Scalar(10.0));
set(l, "dropShadow/distance", Value::Scalar(0.0));
set(l, "dropShadow/opacity", Value::Scalar(100.0));
});
let img = render(&p, cid);
let a1 = img.get(121, 50)[3];
let a5 = img.get(125, 50)[3];
let a20 = img.get(140, 50)[3];
assert!(a1 > a5 && a5 > a20, "{a1} {a5} {a20}");
assert!(a20 < 0.01);
}
#[test]
fn stroke_positions() {
let render_stroke = |pos: u32| {
let (p, cid) = scene([1.0; 3], None, |p, c, l| {
add(p, c, l, "stroke");
set(l, "stroke/size", Value::Scalar(4.0));
set(l, "stroke/position", Value::Enum(pos));
});
render(&p, cid)
};
let red = |px: [f32; 4]| px[0] > 0.98 && px[1] < 0.02 && px[3] > 0.98;
let white = |px: [f32; 4]| px[0] > 0.98 && px[1] > 0.98 && px[3] > 0.98;
// Outside: 4 px ring beyond the right edge (x = 120).
let o = render_stroke(0);
assert!(red(o.get(122, 50)) && red(o.get(123, 50)), "{:?}", o.get(122, 50));
assert!(o.get(126, 50)[3] < 0.01);
assert!(white(o.get(118, 50)));
// Inside: the ring is within the layer.
let i = render_stroke(1);
assert!(red(i.get(118, 50)) && red(i.get(117, 50)));
assert!(white(i.get(110, 50)));
assert!(i.get(122, 50)[3] < 0.01);
// Centre: 2 px on each side.
let c = render_stroke(2);
assert!(red(c.get(119, 50)) && red(c.get(120, 50)) && red(c.get(121, 50)));
assert!(white(c.get(116, 50)));
assert!(c.get(124, 50)[3] < 0.01);
}
#[test]
fn colour_overlay_blend_modes() {
let (p, cid) = scene([1.0; 3], None, |p, c, l| {
add(p, c, l, "colorOverlay");
set(l, "colorOverlay/color", Value::Color([0.0, 0.0, 1.0, 1.0]));
set(l, "colorOverlay/opacity", Value::Scalar(50.0));
});
let px = render(&p, cid).get(100, 50);
assert!(near(px[0], 0.5, 0.01) && near(px[2], 1.0, 0.01), "{px:?}");
let (p, cid) = scene([0.5, 1.0, 1.0], None, |p, c, l| {
add(p, c, l, "colorOverlay");
set(l, "colorOverlay/color", Value::Color([1.0, 0.5, 0.0, 1.0]));
set(l, "colorOverlay/blendMode", Value::Enum(MULTIPLY));
});
let img = render(&p, cid);
let px = img.get(100, 50);
assert!(near(px[0], 0.5, 0.01) && near(px[1], 0.5, 0.01) && near(px[2], 0.0, 0.01), "{px:?}");
// Overlays stay inside the layer.
assert!(img.get(130, 50)[3] < 0.01);
}
#[test]
fn gradient_overlay_follows_angle() {
let (p, cid) = scene([1.0; 3], None, |p, c, l| {
add(p, c, l, "gradientOverlay");
});
let img = render(&p, cid);
// 90°: black at the bottom, white at the top.
let top = img.get(100, 31)[0];
let bottom = img.get(100, 68)[0];
assert!(top > 0.9 && bottom < 0.1, "{top} {bottom}");
let (p, cid) = scene([1.0; 3], None, |p, c, l| {
add(p, c, l, "gradientOverlay");
set(l, "gradientOverlay/angle", Value::Scalar(0.0));
set(l, "gradientOverlay/reverse", Value::Bool(true));
});
let img = render(&p, cid);
assert!(img.get(81, 50)[0] > 0.9 && img.get(118, 50)[0] < 0.1);
}
fn bevel_scene(angle: f64) -> Image {
let (p, cid) = scene([0.5; 3], None, |p, c, l| {
add(p, c, l, "bevelEmboss");
set(l, "bevelEmboss/size", Value::Scalar(6.0));
set(l, "blendingOptions/globalLightAngle", Value::Scalar(angle));
});
render(&p, cid)
}
#[test]
fn bevel_lights_the_side_facing_the_light() {
// Light from the upper left (120°): left and top edges brighten, right and bottom darken.
let img = bevel_scene(120.0);
let (l, r, t, b, mid) = (img.get(82, 50)[0], img.get(117, 50)[0], img.get(100, 32)[0], img.get(100, 67)[0], img.get(100, 50)[0]);
assert!(l > 0.6 && t > 0.6, "highlights {l} {t}");
assert!(r < 0.4 && b < 0.4, "shadows {r} {b}");
assert!(near(mid, 0.5, 0.02), "flat centre {mid}");
// Light from the lower right (−60°) swaps them.
let img = bevel_scene(-60.0);
assert!(img.get(82, 50)[0] < 0.4 && img.get(117, 50)[0] > 0.6);
}
#[test]
fn global_light_drives_linked_styles() {
let shadow_at = |use_global: bool| {
let (p, cid) = scene([1.0; 3], None, |p, c, l| {
add(p, c, l, "dropShadow");
set(l, "dropShadow/size", Value::Scalar(0.0));
set(l, "dropShadow/distance", Value::Scalar(10.0));
set(l, "dropShadow/opacity", Value::Scalar(100.0));
set(l, "dropShadow/useGlobalLight", Value::Bool(use_global));
set(l, "dropShadow/angle", Value::Scalar(90.0));
set(l, "blendingOptions/globalLightAngle", Value::Scalar(0.0));
});
render(&p, cid)
};
// Global light at 0° (from the right): shadow to the left.
let g = shadow_at(true);
assert!(g.get(75, 50)[3] > 0.98 && g.get(100, 75)[3] < 0.01);
// Own angle 90° (from above): shadow below.
let o = shadow_at(false);
assert!(o.get(100, 75)[3] > 0.98 && o.get(75, 50)[3] < 0.01);
}
#[test]
fn glows_and_inner_shadow() {
let (p, cid) = scene([0.0, 0.0, 0.0], None, |p, c, l| {
add(p, c, l, "outerGlow");
add(p, c, l, "innerGlow");
set(l, "outerGlow/opacity", Value::Scalar(100.0));
set(l, "outerGlow/size", Value::Scalar(8.0));
set(l, "innerGlow/opacity", Value::Scalar(100.0));
set(l, "innerGlow/size", Value::Scalar(8.0));
});
let img = render(&p, cid);
let out_near = img.straight(121, 50);
assert!(out_near[3] > 0.6 && out_near[0] > 0.9 && out_near[2] < 0.9, "glow colour {out_near:?}");
assert!(img.get(140, 50)[3] < 0.02, "glow fades");
let edge = img.get(119, 50);
let centre = img.get(100, 50);
assert!(edge[0] > 0.6 && centre[0] < 0.05, "inner glow from the edge {edge:?} {centre:?}");
// Inner glow from the centre instead.
let (p, cid) = scene([0.0; 3], None, |p, c, l| {
add(p, c, l, "innerGlow");
set(l, "innerGlow/source", Value::Enum(0));
set(l, "innerGlow/opacity", Value::Scalar(100.0));
});
let img = render(&p, cid);
assert!(img.get(100, 50)[0] > 0.9 && img.get(119, 50)[0] < 0.6);
let (p, cid) = scene([1.0; 3], None, |p, c, l| {
add(p, c, l, "innerShadow");
set(l, "innerShadow/size", Value::Scalar(0.0));
set(l, "innerShadow/opacity", Value::Scalar(100.0));
set(l, "innerShadow/blendMode", Value::Enum(NORMAL));
});
let img = render(&p, cid);
// Light from the upper left: the shadow lines the top-left inside edges.
assert!(img.get(81, 50)[0] < 0.05 && img.get(100, 32)[0] < 0.05, "{:?}", img.get(81, 50));
assert!(img.get(117, 50)[0] > 0.95 && img.get(100, 66)[0] > 0.95);
assert!(img.get(76, 50)[3] < 0.01, "inner shadow stays inside");
}
#[test]
fn satin_shades_inside() {
let (p, cid) = scene([1.0; 3], None, |p, c, l| add(p, c, l, "satin"));
let img = render(&p, cid);
let vals: Vec<f32> = (82..118).map(|x| img.get(x, 50)[0]).collect();
let min = vals.iter().cloned().fold(1.0, f32::min);
assert!(min < 0.9, "satin darkens somewhere: {min}");
assert!(img.get(130, 50)[3] < 0.01);
}
#[test]
fn fill_opacity_knockout_and_channels() {
// Fill 0: the content disappears but the stroke stays.
let (p, cid) = scene([1.0; 3], None, |p, c, l| {
add(p, c, l, "stroke");
set(l, "blendingOptions/advancedBlending/fillOpacity", Value::Scalar(0.0));
});
let img = render(&p, cid);
assert!(img.get(100, 50)[3] < 0.01 && img.get(122, 50)[0] > 0.98);
// Interior styles ignore Fill Opacity unless blended as a group.
let overlay = |group: bool| {
let (p, cid) = scene([1.0; 3], None, |p, c, l| {
add(p, c, l, "colorOverlay");
set(l, "blendingOptions/advancedBlending/fillOpacity", Value::Scalar(0.0));
set(l, "blendingOptions/advancedBlending/blendInteriorAsGroup", Value::Bool(group));
});
render(&p, cid).get(100, 50)
};
assert!(overlay(false)[3] > 0.98 && overlay(true)[3] < 0.01);
// Knockout with Fill 0 punches through the background.
let (p, cid) = scene([1.0; 3], Some([0.5; 3]), |p, c, l| {
add(p, c, l, "stroke");
set(l, "blendingOptions/advancedBlending/fillOpacity", Value::Scalar(0.0));
set(l, "blendingOptions/advancedBlending/knockout", Value::Enum(2));
});
let img = render(&p, cid);
assert!(img.get(100, 50)[3] < 0.01 && img.get(150, 50)[3] > 0.99);
// Red channel off: the white layer leaves red from below.
let (p, cid) = scene([1.0; 3], Some([0.0; 3]), |p, c, l| {
add(p, c, l, "stroke");
set(l, "blendingOptions/advancedBlending/red", Value::Bool(false));
});
let px = render(&p, cid).get(100, 50);
assert!(px[0] < 0.01 && px[1] > 0.99 && px[2] > 0.99, "{px:?}");
}
#[test]
fn eye_switch_and_group_switch() {
let (mut p, cid) = scene([1.0; 3], None, |p, c, l| {
add(p, c, l, "stroke");
});
assert!(render(&p, cid).get(122, 50)[3] > 0.98);
p.comp_mut(cid).unwrap().layers[0].props.group_mut(&format!("{GROUP}/stroke")).unwrap().enabled = false;
assert!(render(&p, cid).get(122, 50)[3] < 0.01);
p.comp_mut(cid).unwrap().layers[0].props.group_mut(&format!("{GROUP}/stroke")).unwrap().enabled = true;
p.comp_mut(cid).unwrap().layers[0].props.group_mut(GROUP).unwrap().enabled = false;
assert!(render(&p, cid).get(122, 50)[3] < 0.01);
}
#[test]
fn styles_respect_render_scale() {
let (p, cid) = scene([1.0; 3], None, |p, c, l| {
add(p, c, l, "dropShadow");
add(p, c, l, "stroke");
set(l, "dropShadow/size", Value::Scalar(0.0));
set(l, "dropShadow/distance", Value::Scalar(20.0));
set(l, "dropShadow/opacity", Value::Scalar(100.0));
set(l, "stroke/size", Value::Scalar(4.0));
});
let full = render_frame(&p, cid, Tick::ZERO, 1.0);
let half = render_frame(&p, cid, Tick::ZERO, 0.5);
// Shadow offset (10, 17.3): at half resolution the shadow sits at half the distance.
assert!(full.get(125, 85)[3] > 0.98);
assert!(half.get(62, 42)[3] > 0.98 && half.get(55, 25)[3] > 0.98);
// Stroke 4 px → 2 px at half.
assert!(half.get(61, 25)[0] > 0.98 && half.get(61, 25)[1] < 0.05);
assert!(half.get(64, 25)[3] < 0.01 || half.get(64, 25)[1] < 0.05);
}
#[test]
fn animated_style_properties() {
let (mut p, cid) = scene([1.0; 3], None, |p, c, l| add(p, c, l, "stroke"));
let l = &mut p.comp_mut(cid).unwrap().layers[0];
l.props.prop_mut(&format!("{GROUP}/stroke/size")).unwrap().keys =
vec![Keyframe::new(Tick::ZERO, Value::Scalar(2.0)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(10.0))];
let a = render_frame(&p, cid, Tick::ZERO, 1.0);
let b = render_frame(&p, cid, Tick::from_seconds_f64(1.0), 1.0);
assert!(a.get(126, 50)[3] < 0.01 && b.get(126, 50)[3] > 0.98);
}
fn render_cached(p: &Project, cid: ItemId, cache: &crate::LayerCache) -> Image {
let mut r = Renderer::new(p, &NoFootage, RenderOpts::default());
r.cache = Some(cache);
r.comp_frame(cid, Tick::ZERO)
}
#[test]
fn style_edits_invalidate_the_cache() {
let (mut p, cid) = scene([1.0; 3], Some([0.5; 3]), |p, c, l| {
add(p, c, l, "dropShadow");
add(p, c, l, "stroke");
});
let cache = crate::LayerCache::default();
let a = render_cached(&p, cid, &cache);
assert_eq!(a.data, render(&p, cid).data);
let again = render_cached(&p, cid, &cache);
assert_eq!(a.data, again.data);
// Change a style value: the styled layer re-renders, its content (and the background) come
// from the cache.
let hits0 = cache.stats().hits;
let l = &mut p.comp_mut(cid).unwrap().layers[0];
l.props.prop_mut(&format!("{GROUP}/stroke/color")).unwrap().value = Value::Color([0.0, 1.0, 0.0, 1.0]);
let b = render_cached(&p, cid, &cache);
assert_eq!(b.data, render(&p, cid).data);
assert_ne!(a.data, b.data);
assert!(cache.stats().hits >= hits0 + 2, "content + background reused");
// Global light change (linked) also invalidates.
let l = &mut p.comp_mut(cid).unwrap().layers[0];
l.props.prop_mut(&format!("{GROUP}/blendingOptions/globalLightAngle")).unwrap().value = Value::Scalar(0.0);
let c = render_cached(&p, cid, &cache);
assert_eq!(c.data, render(&p, cid).data);
assert_ne!(b.data, c.data);
// Eye switch off.
p.comp_mut(cid).unwrap().layers[0].props.group_mut(&format!("{GROUP}/dropShadow")).unwrap().enabled = false;
let d = render_cached(&p, cid, &cache);
assert_eq!(d.data, render(&p, cid).data);
assert_ne!(c.data, d.data);
}
#[test]
fn gradient_glow_uses_gradient() {
let (p, cid) = scene([0.0; 3], None, |p, c, l| {
add(p, c, l, "outerGlow");
set(l, "outerGlow/colorType", Value::Enum(1));
set(l, "outerGlow/opacity", Value::Scalar(100.0));
set(
l,
"outerGlow/colors",
Value::Gradient(Gradient { colors: vec![(0.0, [1.0, 0.0, 0.0, 1.0]), (1.0, [0.0, 0.0, 1.0, 1.0])], opacities: vec![(0.0, 1.0), (1.0, 0.0)] }),
);
});
let img = render(&p, cid);
let px = img.straight(121, 50);
assert!(px[0] > px[2], "near the edge the glow takes the gradient's start colour {px:?}");
}
+34 -4
View File
@@ -27,6 +27,8 @@ enum RowKind {
open: bool,
has_children: bool,
fx: Option<bool>,
/// Eye switch (layer styles).
eye: Option<bool>,
},
Prop {
uid: u64,
@@ -250,6 +252,7 @@ fn build_rows(app: &EffectcraftApp, comp: &Comp) -> Vec<Row> {
open: tl.open_groups.contains(&g.uid),
has_children: true,
fx: Some(g.enabled),
eye: None,
},
});
if tl.open_groups.contains(&g.uid) {
@@ -264,7 +267,14 @@ fn build_rows(app: &EffectcraftApp, comp: &Comp) -> Vec<Row> {
rows.push(Row {
layer: l.id,
depth: 1,
kind: RowKind::Group { uid: g.uid, name: g.name.clone(), open: tl.open_groups.contains(&g.uid), has_children: true, fx: None },
kind: RowKind::Group {
uid: g.uid,
name: g.name.clone(),
open: tl.open_groups.contains(&g.uid),
has_children: true,
fx: None,
eye: None,
},
});
if tl.open_groups.contains(&g.uid) {
push_group(&mut rows, l, g, 2, &tl.open_groups);
@@ -298,7 +308,14 @@ fn build_rows(app: &EffectcraftApp, comp: &Comp) -> Vec<Row> {
rows.push(Row {
layer: l.id,
depth: 1,
kind: RowKind::Group { uid: g.uid, name: g.name.clone(), open, has_children: !g.children.is_empty(), fx: None },
kind: RowKind::Group {
uid: g.uid,
name: g.name.clone(),
open,
has_children: !g.children.is_empty(),
fx: None,
eye: (g.match_id == effectcraft_engine::project::styles::GROUP).then_some(g.enabled),
},
});
if open {
push_group(&mut rows, l, g, 2, &tl.open_groups);
@@ -332,10 +349,13 @@ fn push_group(rows: &mut Vec<Row>, l: &Layer, g: &PropGroup, depth: usize, open:
Node::Group(sg) => {
let o = open.contains(&sg.uid);
let fx = matches!(sg.kind, GroupKind::Effect { .. }).then_some(sg.enabled);
// Layer style groups have eye switches (not Blending Options).
let eye = (g.match_id == effectcraft_engine::project::styles::GROUP && sg.match_id != effectcraft_engine::project::styles::BLENDING)
.then_some(sg.enabled);
rows.push(Row {
layer: l.id,
depth,
kind: RowKind::Group { uid: sg.uid, name: sg.name.clone(), open: o, has_children: !sg.children.is_empty(), fx },
kind: RowKind::Group { uid: sg.uid, name: sg.name.clone(), open: o, has_children: !sg.children.is_empty(), fx, eye },
});
if o {
push_group(rows, l, sg, depth + 1, open);
@@ -926,7 +946,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
}
RowKind::Group { uid, name, open, has_children, fx } => {
RowKind::Group { uid, name, open, has_children, fx, eye } => {
lp.rect_filled(left, 0.0, if ri % 2 == 0 { t.row } else { t.row_alt });
gp.rect_filled(Rect::from_min_max(pos2(graph_x0, r.min.y), r.max), 0.0, t.tl_bg);
let indent = cw.name + 6.0 + 14.0 * row.depth as f32;
@@ -942,6 +962,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if widgets::icon_toggle(ui, fr_, Icon::Fx, *en, &t, egui::Id::new(("gfx", uid)), None).clicked() {
actions.push(("effect.toggle".into(), json!({"layer": layer.id.0, "effect": uid})));
}
app.auto.add(&format!("timeline.group.{uid}.fx"), fr_, name);
}
if let Some(en) = eye {
// Layer style eye switch in the A/V column, like AE.
let er = Rect::from_center_size(pos2(cw.av + 10.0, cy), vec2(17.0, 17.0));
lp.rect_stroke(er.shrink(1.5), 2.0, Stroke::new(1.0, t.separator), StrokeKind::Inside);
if widgets::icon_toggle(ui, er, Icon::Eye, *en, &t, egui::Id::new(("geye", uid)), None).clicked() {
actions.push(("layer.style.toggle".into(), json!({"layer": layer.id.0, "style": uid})));
}
app.auto.add(&format!("timeline.group.{uid}.eye"), er, name);
}
lp.text(pos2(indent + 10.0, cy), Align2::LEFT_CENTER, name, Tokens::ui(12.0), t.text);
text_anim_popups(app, ui, &lp, layer, *uid, cw.switches, cy, &mut actions);
+9 -1
View File
@@ -61,7 +61,7 @@ text animators and shape contents are groups in the same tree.
`Renderer::comp_frame(comp, t)` walks the layers bottom to top. For each visible layer it renders
the **source** (solid, footage frame, text, shape contents, or a nested comp), applies **masks**,
then **effects** in order, then the **transform** into comp space (with motion blur sub-samples
then **effects** in order, then **layer styles**, then the **transform** into comp space (with motion blur sub-samples
when enabled), the **track matte**, and finally **blends** into the accumulator. Adjustment layers
apply their effects to the accumulator. Runs of 3D layers are composited per pixel through the
active camera, with lights, shadows and depth of field.
@@ -71,6 +71,14 @@ its evaluated inputs, excluding the transform. Static and transform-only layers
editing one layer re-renders only that layer. Effects that read the clock directly are declared in
`effects::TIME_DEPENDENT`, and a test checks every registered effect against that list.
**Layer styles** (Layer ▸ Layer Styles; `crates/render/src/styles.rs`) render in layer space and
may grow the layer's bounds. Drop Shadow and Outer Glow become separate passes composited below the
layer with their own blend modes; the interior styles, Stroke and Bevel and Emboss are baked into
the layer body. Layer opacity fades the whole stack; Knockout and the R/G/B channel switches apply
at composite time. Styled pixels are cached separately from the layer content, so editing a style
reuses the cached source/masks/effects. Global Light is one setting per comp, mirrored into every
layer's Blending Options and kept in step after each edit.
Half, Third and Quarter resolution render proportionally fewer pixels end to end.
## 5. Expressions
Binary file not shown.

After

Width:  |  Height:  |  Size: 658 KiB

@@ -0,0 +1,8 @@
asset: effectcraft-3d.png
title: EffectCraft Classic 3D
author: EffectCraft contributors
source: original work (headless render of the EffectCraft UI via crates/ui-egui/examples/snapshot.rs)
license: MIT OR Apache-2.0
license-file:
added: 2026-10-01 by Claude (agent) for Brandon Thomas
notes: Screenshot of EffectCraft only, showing procedurally generated demo content from crates/engine/src/demo.rs and built-in effects. EffectCraft Classic 3D: the demo "3D Showcase" composition in Custom View 1, showing intersecting 3D planes, spot-light shadows, the camera frustum and light wireframes.
Binary file not shown.

After

Width:  |  Height:  |  Size: 446 KiB

@@ -0,0 +1,8 @@
asset: effectcraft-effects.png
title: EffectCraft effects
author: EffectCraft contributors
source: original work (headless render of the EffectCraft UI via crates/ui-egui/examples/snapshot.rs)
license: MIT OR Apache-2.0
license-file:
added: 2026-10-01 by Claude (agent) for Brandon Thomas
notes: Screenshot of EffectCraft only, showing procedurally generated demo content from crates/engine/src/demo.rs and built-in effects. EffectCraft effects: a CC Particle World particle burst with Glow on a black solid, with the Effect Controls panel listing its parameters.
Binary file not shown.

After

Width:  |  Height:  |  Size: 740 KiB

@@ -0,0 +1,8 @@
asset: effectcraft-graph-editor.png
title: EffectCraft Graph Editor
author: EffectCraft contributors
source: original work (headless render of the EffectCraft UI via crates/ui-egui/examples/snapshot.rs)
license: MIT OR Apache-2.0
license-file:
added: 2026-10-01 by Claude (agent) for Brandon Thomas
notes: Screenshot of EffectCraft only, showing procedurally generated demo content from crates/engine/src/demo.rs and built-in effects. EffectCraft Graph Editor: the value graph of the EFFECTCRAFT text animator Range Start keyframes with easing, and the graph editor button bar.
Binary file not shown.

After

Width:  |  Height:  |  Size: 746 KiB

@@ -0,0 +1,8 @@
asset: effectcraft-hero.png
title: EffectCraft main window
author: EffectCraft contributors
source: original work (headless render of the EffectCraft UI via crates/ui-egui/examples/snapshot.rs)
license: MIT OR Apache-2.0
license-file:
added: 2026-10-01 by Claude (agent) for Brandon Thomas
notes: Screenshot of EffectCraft only, showing procedurally generated demo content from crates/engine/src/demo.rs and built-in effects. EffectCraft main window: the demo composition "EffectCraft Intro" at 0:00:03:00 with the EFFECTCRAFT text layer selected, Properties panel (Layer Transform, Text, Paragraph, Text Animation), Project panel and Timeline.
Binary file not shown.

After

Width:  |  Height:  |  Size: 611 KiB

@@ -0,0 +1,8 @@
asset: effectcraft-render-queue.png
title: EffectCraft Render Queue panel with the demo compositions queued for H.264 and ProRes export
author: EffectCraft contributors
source: original work (headless render of the EffectCraft UI via crates/ui-egui/examples/snapshot.rs)
license: MIT OR Apache-2.0
license-file:
added: 2026-10-01 by Claude (agent) for Brandon Thomas
notes: Screenshot of EffectCraft only, showing procedurally generated demo content from crates/engine/src/demo.rs and built-in effects. EffectCraft Render Queue panel with the demo compositions queued for H.264 and ProRes export.