diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md
index 8e6e3533..97598349 100644
--- a/ATTRIBUTION.md
+++ b/ATTRIBUTION.md
@@ -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`.
diff --git a/README.md b/README.md
index 8f0ac588..f1f9d711 100644
--- a/README.md
+++ b/README.md
@@ -15,12 +15,12 @@
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.
-
+
@@ -35,6 +35,12 @@
All Crafting Apps
+
+
+
+
+
+
> [!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 @@
What it is ·
- Where it stands ·
- The plan ·
- Building ·
+ Animate ·
+ Effects ·
+ 3D ·
+ Export ·
+ Agents ·
+ Get started ·
+ Status ·
How it's made ·
The Crafting Apps ·
License
@@ -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).
+
+
+
-### 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.
+
+
+
-## 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 |
+
+
+
-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:
+
+
+
+
+## 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
diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs
index d5210b42..1c59d69f 100644
--- a/crates/engine/src/commands/mod.rs
+++ b/crates/engine/src/commands/mod.rs
@@ -20,6 +20,7 @@ mod prop;
mod query;
mod render_queue;
mod stubs;
+mod styles;
mod three_d;
mod time;
mod view;
@@ -81,6 +82,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(layer_time::specs());
v.extend(prop::specs());
diff --git a/crates/engine/src/commands/stubs.rs b/crates/engine/src/commands/stubs.rs
index 5b58bdaa..b9229f74 100644
--- a/crates/engine/src/commands/stubs.rs
+++ b/crates/engine/src/commands/stubs.rs
@@ -52,7 +52,6 @@ pub fn specs() -> Vec {
stub!("layer.alignVideoToData", "Align Video to Data", ["Layer", "Time"], "{}"),
stub!("layer.environment", "Environment Layer", ["Layer"], "{}"),
stub!("layer.updateMarkersFromSource", "Update Markers From Source", ["Layer", "Markers"], "{}"),
- stub!("layer.styles", "Layer Styles", [], "{op?|style?}"),
stub!("layer.create", "Create", [], "{op}"),
stub!("layer.autoTrace", "Auto-trace...", ["Layer"], "{}"),
stub!("layer.sceneEditDetection", "Scene Edit Detection...", ["Layer"], "{}"),
diff --git a/crates/engine/src/commands/styles.rs b/crates/engine/src/commands/styles.rs
new file mode 100644
index 00000000..82bbf5eb
--- /dev/null
+++ b/crates/engine/src/commands/styles.rs
@@ -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)> {
+ 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 {
+ 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 {
+ 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::>().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 {
+ 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 {
+ 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 {
+ 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 = 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 {
+ 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 {
+ 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 {
+ 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 {
+ Err(EngineError::Other("no Photoshop layer styles to convert".into()))
+}
+
+fn list(s: &mut Session, p: &Value) -> Result {
+ 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 = 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::>()}),
+ )
+}
+
+pub fn specs() -> Vec {
+ 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),
+ ]
+}
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index 30d6bc6d..6770a5bc 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -276,6 +276,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));
@@ -454,6 +456,8 @@ mod tests_3d;
#[cfg(test)]
mod tests_menu_cmds;
#[cfg(test)]
+mod tests_styles;
+#[cfg(test)]
mod tests_timeline;
/// Font families available to text layers (bundled + scanned system fonts).
diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs
index 2c887db3..cac33580 100644
--- a/crates/engine/src/menus.rs
+++ b/crates/engine/src/menus.rs
@@ -641,19 +641,19 @@ Layer
Matte with Layer Above | layer.trackMatte {"op":"above"}
Matte with Layer Below | layer.trackMatte {"op":"below"}
Layer Styles
- Convert to Editable Styles | layer.styles {"op":"convert"}
- Show All | layer.styles {"op":"showAll"}
- Remove All | layer.styles {"op":"removeAll"}
+ Convert to Editable Styles | layer.style.convertToEditable
+ Show All | layer.style.showAll
+ Remove All | layer.style.removeAll
---
- Drop Shadow | layer.styles {"style":"dropShadow"}
- Inner Shadow | layer.styles {"style":"innerShadow"}
- Outer Glow | layer.styles {"style":"outerGlow"}
- Inner Glow | layer.styles {"style":"innerGlow"}
- Bevel and Emboss | layer.styles {"style":"bevelEmboss"}
- Satin | layer.styles {"style":"satin"}
- Color Overlay | layer.styles {"style":"colorOverlay"}
- Gradient Overlay | layer.styles {"style":"gradientOverlay"}
- Stroke | layer.styles {"style":"stroke"}
+ Drop Shadow | layer.style.dropShadow
+ Inner Shadow | layer.style.innerShadow
+ Outer Glow | layer.style.outerGlow
+ Inner Glow | layer.style.innerGlow
+ Bevel and Emboss | layer.style.bevelEmboss
+ Satin | layer.style.satin
+ Color Overlay | layer.style.colorOverlay
+ Gradient Overlay | layer.style.gradientOverlay
+ Stroke | layer.style.stroke
---
Arrange
Bring Layer to Front | layer.arrange {"to":"front"} | Cmd+Shift+]
diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs
index 169dddfb..095e55af 100644
--- a/crates/engine/src/tests_menu_cmds.rs
+++ b/crates/engine/src/tests_menu_cmds.rs
@@ -485,7 +485,7 @@ fn frontend_commands_emit_events_and_stubs_are_disabled() {
let ev = frontend_events(&mut s);
assert_eq!(ev[0].0, "view.zoomIn");
assert_eq!(ev[1], ("window.panel".to_string(), json!({"panel": "align"})));
- for id in ["render.addOutputModule", "path.rotoBezier", "camera.fromView", "view.3d.default", "layer.styles", "track.motion"] {
+ for id in ["render.addOutputModule", "path.rotoBezier", "camera.fromView", "view.3d.default", "layer.create", "track.motion"] {
assert!(!s.is_enabled(id), "{id}");
assert!(matches!(s.execute(id, json!({})), Err(EngineError::Disabled(..))), "{id}");
}
diff --git a/crates/engine/src/tests_styles.rs b/crates/engine/src/tests_styles.rs
new file mode 100644
index 00000000..e589241f
--- /dev/null
+++ b/crates/engine/src/tests_styles.rs
@@ -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);
+}
diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs
index 5a5cf523..9e8cf949 100644
--- a/crates/project/src/lib.rs
+++ b/crates/project/src/lib.rs
@@ -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,
/// Viewer guides (View ▸ Add Guide…), in comp pixels.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub guides: Vec,
@@ -189,6 +193,7 @@ impl Comp {
draft_3d: false,
poster_time: Tick::ZERO,
guides: vec![],
+ global_light: styles::GlobalLight::default(),
}
}
pub fn layer(&self, id: LayerId) -> Option<&Layer> {
diff --git a/crates/project/src/styles.rs b/crates/project/src/styles.rs
new file mode 100644
index 00000000..83db5e58
--- /dev/null
+++ b/crates/project/src/styles.rs
@@ -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 {
+ let (_, name) = STYLES.iter().find(|s| s.0 == id)?;
+ let mut g = ids.group(id, name);
+ g.kind = GroupKind::Plain;
+ let props: Vec = 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 {
+ 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, altitude: Option, 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));
+ }
+}
diff --git a/crates/render/src/cache.rs b/crates/render/src/cache.rs
index 78ff39b5..f3c70d6a 100644
--- a/crates/render/src/cache.rs
+++ b/crates/render/src/cache.rs
@@ -224,8 +224,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;
}
@@ -243,3 +244,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()
+}
diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs
index 02f61df6..066fd752 100644
--- a/crates/render/src/lib.rs
+++ b/crates/render/src/lib.rs
@@ -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> {
- 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> {
+ /// Layer pixels after source → masks → effects (no layer styles).
+ pub fn content_buf(&self, ctx: &EvalCtx, layer: &Layer) -> Option> {
+ 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, Option)> {
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 {
+ 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> = 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, BlendMode)>,
+ body: Arc,
+ /// Pre-style pixels (knockout shape).
+ content: Arc,
+ /// Styled cache key.
+ key: Option,
+ /// 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
diff --git a/crates/render/src/styles.rs b/crates/render/src/styles.rs
new file mode 100644
index 00000000..8bc24498
--- /dev/null
+++ b/crates/render/src/styles.rs
@@ -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 {
+ 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,
+ 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,
+ inner_shadow: Option,
+ outer_glow: Option,
+ inner_glow: Option,
+ bevel: Option,
+ satin: Option,
+ color: Option,
+ gradient: Option,
+ stroke: Option,
+}
+
+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);
+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))
+ });
+}
diff --git a/crates/render/src/tests_styles.rs b/crates/render/src/tests_styles.rs
new file mode 100644
index 00000000..34bfe30c
--- /dev/null
+++ b/crates/render/src/tests_styles.rs
@@ -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 = (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:?}");
+}
diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs
index 9acb4415..afaad9e5 100644
--- a/crates/ui-egui/src/panels/timeline.rs
+++ b/crates/ui-egui/src/panels/timeline.rs
@@ -27,6 +27,8 @@ enum RowKind {
open: bool,
has_children: bool,
fx: Option,
+ /// Eye switch (layer styles).
+ eye: Option,
},
Prop {
uid: u64,
@@ -242,6 +244,7 @@ fn build_rows(app: &EffectcraftApp, comp: &Comp) -> Vec {
open: tl.open_groups.contains(&g.uid),
has_children: true,
fx: Some(g.enabled),
+ eye: None,
},
});
if tl.open_groups.contains(&g.uid) {
@@ -256,7 +259,14 @@ fn build_rows(app: &EffectcraftApp, comp: &Comp) -> Vec {
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);
@@ -282,7 +292,7 @@ fn build_rows(app: &EffectcraftApp, comp: &Comp) -> Vec {
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: None },
});
if open {
push_group(&mut rows, l, g, 2, &tl.open_groups);
@@ -320,7 +330,14 @@ fn build_rows(app: &EffectcraftApp, comp: &Comp) -> Vec {
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);
@@ -350,10 +367,13 @@ fn push_group(rows: &mut Vec, 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);
@@ -944,7 +964,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;
@@ -960,6 +980,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);
// Mask mode + inverted inline.
diff --git a/docs/architecture.md b/docs/architecture.md
index 4c8646b3..ee0b7876 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -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
diff --git a/docs/images/effectcraft-3d.png b/docs/images/effectcraft-3d.png
new file mode 100644
index 00000000..4f0d003a
Binary files /dev/null and b/docs/images/effectcraft-3d.png differ
diff --git a/docs/images/effectcraft-3d.png.attribution b/docs/images/effectcraft-3d.png.attribution
new file mode 100644
index 00000000..10fd52a0
--- /dev/null
+++ b/docs/images/effectcraft-3d.png.attribution
@@ -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.
diff --git a/docs/images/effectcraft-effects.png b/docs/images/effectcraft-effects.png
new file mode 100644
index 00000000..02eadb0d
Binary files /dev/null and b/docs/images/effectcraft-effects.png differ
diff --git a/docs/images/effectcraft-effects.png.attribution b/docs/images/effectcraft-effects.png.attribution
new file mode 100644
index 00000000..ed8548e4
--- /dev/null
+++ b/docs/images/effectcraft-effects.png.attribution
@@ -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.
diff --git a/docs/images/effectcraft-graph-editor.png b/docs/images/effectcraft-graph-editor.png
new file mode 100644
index 00000000..769103af
Binary files /dev/null and b/docs/images/effectcraft-graph-editor.png differ
diff --git a/docs/images/effectcraft-graph-editor.png.attribution b/docs/images/effectcraft-graph-editor.png.attribution
new file mode 100644
index 00000000..1f1f94f1
--- /dev/null
+++ b/docs/images/effectcraft-graph-editor.png.attribution
@@ -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.
diff --git a/docs/images/effectcraft-hero.png b/docs/images/effectcraft-hero.png
new file mode 100644
index 00000000..b260bf93
Binary files /dev/null and b/docs/images/effectcraft-hero.png differ
diff --git a/docs/images/effectcraft-hero.png.attribution b/docs/images/effectcraft-hero.png.attribution
new file mode 100644
index 00000000..2cdca513
--- /dev/null
+++ b/docs/images/effectcraft-hero.png.attribution
@@ -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.
diff --git a/docs/images/effectcraft-render-queue.png b/docs/images/effectcraft-render-queue.png
new file mode 100644
index 00000000..f77e782a
Binary files /dev/null and b/docs/images/effectcraft-render-queue.png differ
diff --git a/docs/images/effectcraft-render-queue.png.attribution b/docs/images/effectcraft-render-queue.png.attribution
new file mode 100644
index 00000000..979571eb
--- /dev/null
+++ b/docs/images/effectcraft-render-queue.png.attribution
@@ -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.