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