diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 8e6e3533..97598349 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -17,6 +17,18 @@ iconography, images or other Adobe assets; open licences only; every asset attri | `assets/fonts/NotoSerif-Regular.ttf` | The Noto Project Authors | https://github.com/notofonts/latin-greek-cyrillic | OFL-1.1 (`assets/fonts/OFL-NotoSerif.txt`) | | `assets/fonts/JetBrainsMono-Regular.ttf` | The JetBrains Mono Project Authors | https://github.com/JetBrains/JetBrainsMono | OFL-1.1 (`assets/fonts/OFL-JetBrainsMono.txt`) | +## Screenshots + +Screenshots of EffectCraft itself, rendered headlessly, showing only procedurally generated demo content. + +| Asset | Author | Source | Licence | +|---|---|---|---| +| `docs/images/effectcraft-hero.png` | EffectCraft contributors | Original work: screenshot of EffectCraft (EffectCraft main window) | MIT OR Apache-2.0 | +| `docs/images/effectcraft-3d.png` | EffectCraft contributors | Original work: screenshot of EffectCraft (EffectCraft Classic 3D) | MIT OR Apache-2.0 | +| `docs/images/effectcraft-graph-editor.png` | EffectCraft contributors | Original work: screenshot of EffectCraft (EffectCraft Graph Editor) | MIT OR Apache-2.0 | +| `docs/images/effectcraft-effects.png` | EffectCraft contributors | Original work: screenshot of EffectCraft (EffectCraft effects) | MIT OR Apache-2.0 | +| `docs/images/effectcraft-render-queue.png` | EffectCraft contributors | Original work: screenshot of EffectCraft (EffectCraft Render Queue panel with the demo compositions queued for H.264 and ProRes export) | MIT OR Apache-2.0 | + ## Code-drawn assets (original work, MIT OR Apache-2.0) - UI icons drawn in code: `crates/ui-egui/src/icons.rs`. diff --git a/README.md b/README.md index 8f0ac588..f1f9d711 100644 --- a/README.md +++ b/README.md @@ -15,12 +15,12 @@

A free, open-source compositor in the spirit of After Effects: compositions, layers, - keyframes, effects and expressions, native on macOS, Windows and Linux, and in the browser later on. - It is in early development. The engine crates are being written now and there is no app to run yet. + keyframes, 241 effects, expressions, 3D cameras and lights, and a render queue, native on macOS, + Windows and Linux, and in the browser later on. Young, moving fast, and already usable.

- Status: early development + Status: young and moving fast Written in Rust License: MIT or Apache-2.0

@@ -35,6 +35,12 @@ All Crafting Apps

+
+ +

+ 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 +

+ > [!NOTE] > **ArtCraft is a community of artists from all walks of life.** Painters, photographers, > filmmakers, illustrators, designers, animators, hobbyists, and people who picked up a pencil @@ -42,9 +48,13 @@

What it is · - Where it stands · - The plan · - Building · + Animate · + Effects · + 3D · + Export · + Agents · + Get started · + Status · How it's made · The Crafting Apps · License @@ -71,84 +81,104 @@ then to go further in a few places where it matters to us: - **No FFmpeg.** Video and audio decoding and encoding come from FilmCraft's own pure-Rust codecs. -## Where it stands +## Animate -> [!IMPORTANT] -> **There is no app to run yet.** EffectCraft is in its first milestone. The planning is done and -> the low-level engine crates are being written. The windowed app, the renderer and the effects -> come next. If you want to follow along or help shape it, the -> [Discord](https://discord.gg/artcraft) is the place. +The panels, menus and shortcuts follow After Effects, so your muscle memory carries over: +Project, Composition, Timeline, Effect Controls, Properties, Effects & Presets, Character, +Paragraph, Align, Info, Preview, Audio and the Render Queue, docked the way you expect. -| | What | State | -|---|---|---| -| **Built** | The plan: an After Effects UI and feature reference, a full effects list, the architecture and a milestone plan (see [`plan/`](plan/)) | Done | -| **In progress** | Engine foundation crates: time, geometry, color and blend modes, keyframes, the project model, raster images, Bezier paths, text | Early code and first tests | -| **Next** | The compositor, a first set of effects, the editing engine and the After Effects style interface rendering an animated demo composition | Not started | -| **Later** | Precomps and preview, shapes and masks, 3D, expressions, text animators, export, GPU rendering, the web build | Planned | +- **Layers of every kind:** solids, shapes, text, footage, nested compositions, nulls, + adjustment layers, cameras and lights; parenting, track mattes, all 38 blend modes, motion blur. +- **Keyframes that behave the same:** linear, Bezier, hold, auto and continuous Bezier, roving + keys, Easy Ease (F9), Keyframe Velocity and Interpolation dialogs, copy and paste at the current + time, and a **Graph Editor** with value and speed graphs and draggable handles. +- **Time:** time remapping, time stretch, time-reverse, freeze frame, work area, markers, exact + frame-accurate timing at every frame rate including 29.97 drop-frame. +- **Shapes and masks:** shape layers with trim paths, repeaters, round corners, offset, zig zag, + twist, wiggle, merge paths and gradient strokes; masks drawn with the pen tool, with modes, + feather, expansion and vertex editing in the viewer. +- **Text:** point and paragraph text with real shaping, the Character and Paragraph panels, and + text animators with range selectors. +- **Expressions:** JavaScript with the After Effects object model (`wiggle`, `loopOut`, + `thisComp.layer("…")`, vector maths on arrays), an inline editor and the pick-whip. -The live checklist is [`plan/STATUS.md`](plan/STATUS.md). +

+ The 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 +

-### 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. +

+ A CC Particle World burst with Glow, and the Effect Controls panel listing the particle system's parameters +

-## The plan +## 3D -EffectCraft is built in milestones. The first one is about getting something you can see on -screen as early as possible, then filling in depth underneath. +Classic 3D the way After Effects does it: 3D layers with orientation and material options, +one- and two-node **cameras** with depth of field, **lights** (parallel, spot, point, ambient) +with soft ray-traced shadows, layers that intersect correctly, orbit, pan and dolly camera +tools, and Front, Top, Left and Custom views. -| | Milestone | What you'll get | -|---|---|---| -| **M0** | Skeleton and visual shell | The full panel layout over a real CPU compositor playing an animated demo composition, about 8 effects, and the control channel and MCP server | -| M1 | Keyframes | Complete temporal and spatial interpolation, Easy Ease, roving keys, velocity | -| M2 | Compositing | All 38 blend modes, track mattes, parenting, 8/16/32 bit per channel behaviour | -| M3 | Project operations | Composition and layer settings, layer commands, save and open `.ecproj`, undo, clipboard | -| M4 | Preview and precomps | Nested compositions, adjustment layers, motion blur, cached RAM preview with audio | -| M5 | Timeline depth | Keyframe editing, the graph editor, markers, trims, time remapping | -| M6 | Shapes, masks and footage | Shape layers and path operations, masks, the pen tool, video and image import | -| M7 | 3D | 3D layers, cameras and lights | -| M8 | Expressions | JavaScript expressions with the After Effects object model (`wiggle`, `loopOut`, `thisComp` and friends) | -| M9 | Text and effects | Text animators, text on a path, layer styles, the first 60 effects | -| M10 | Export | Render queue, image sequences, H.264 and ProRes, GIF, command-line rendering | -| M11 | Animation tools | Motion Sketch, Wiggler, presets, Lottie import and export, audio | -| M12 | Performance | GPU compositing and effects, disk cache, motion tracking | -| M13 to M16 | Beyond | More effects, puppet and paint tools, polish, the web app, a plugin API | +

+ The 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 +

-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: +

+ The Render Queue panel with two compositions queued +

+ +## Built for agents + +Everything you can do from a menu is a command with an id, and agents can reach every one of +them: + +- **MCP server:** `effectcraft-cli mcp` speaks the Model Context Protocol over stdio, headless + or bridged to the running app (`--bridge 9877`). Tools cover commands, the project and property + tree, keyframes and rendered frames. This repository ships a ready [`.mcp.json`](.mcp.json). +- **Command line:** one-shot calls with JSON output, for example + `effectcraft-cli set Main '#1' transform/position '[100,360]' --time 0 main.ecproj --save` + or `effectcraft-cli render --comp Main --out main.mp4`. +- **Control channel:** `effectcraft --control 9877` accepts JSON lines to run commands, inspect + and click any widget by its automation id, and take screenshots. + +See [docs/agents.md](docs/agents.md) and [docs/control-protocol.md](docs/control-protocol.md). + +## Get started + +You need [Rust](https://rustup.rs/) 1.95 or newer. ```sh git clone https://github.com/storytold/effectcraft cd effectcraft -cargo build -cargo test +cargo run --release -p effectcraft # the app, with the demo project open +cargo run --release -p effectcraft-cli -- render --out intro.mp4 # render the demo headless ``` -To work on one crate: +To work on it: `cargo test --workspace`, and `cargo xtask ci` before every commit (format, lints, +tests, layering, asset attribution and the WebAssembly build). See [CONTRIBUTING.md](CONTRIBUTING.md), +[docs/architecture.md](docs/architecture.md) and [docs/testing.md](docs/testing.md). -```sh -cargo test -p effectcraft-keyframe -``` +## Where it stands -The workspace picks up every crate under `crates/`, so while a new crate is half-written the -whole build can break for a while. The `cargo xtask` helpers named in the plan (layer checks, -asset checks, CI) are not written yet. +EffectCraft is young. The core of After Effects is in place and gets deeper every week: what is +done and what comes next is in the [ROADMAP](ROADMAP.md). Not there yet: layer styles, time and +audio effects, audio playback, motion tracking, puppet and paint tools, the web build and GPU +compositing. Projects are saved as `.ecproj`, readable versioned JSON; After Effects `.aep` +files cannot be opened. ## How it's made @@ -156,10 +186,10 @@ asset checks, CI) are not written yet. top and nothing underneath depends on it. - **Clean room.** We work from After Effects' public documentation and how it behaves, never from Adobe's files, icons, presets or code. No code is copied from GPL projects. The full rules - are in [`plan/README.md`](plan/README.md). + are in [AGENTS.md](AGENTS.md). - **Private by default.** No telemetry, and no network access unless you ask for it. - **One family.** EffectCraft shares its time model and text engine design with FilmCraft, and - gets its video and audio codecs from it (see [`plan/adr/`](plan/adr/)). + gets its video and audio codecs from it. ## The Crafting Apps diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index d5210b42..1c59d69f 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -20,6 +20,7 @@ mod prop; mod query; mod render_queue; mod stubs; +mod styles; mod three_d; mod time; mod view; @@ -81,6 +82,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(edit::specs()); v.extend(comp::specs()); v.extend(layer::specs()); + v.extend(styles::specs()); v.extend(three_d::specs()); v.extend(layer_time::specs()); v.extend(prop::specs()); diff --git a/crates/engine/src/commands/stubs.rs b/crates/engine/src/commands/stubs.rs index 5b58bdaa..b9229f74 100644 --- a/crates/engine/src/commands/stubs.rs +++ b/crates/engine/src/commands/stubs.rs @@ -52,7 +52,6 @@ pub fn specs() -> Vec { stub!("layer.alignVideoToData", "Align Video to Data", ["Layer", "Time"], "{}"), stub!("layer.environment", "Environment Layer", ["Layer"], "{}"), stub!("layer.updateMarkersFromSource", "Update Markers From Source", ["Layer", "Markers"], "{}"), - stub!("layer.styles", "Layer Styles", [], "{op?|style?}"), stub!("layer.create", "Create", [], "{op}"), stub!("layer.autoTrace", "Auto-trace...", ["Layer"], "{}"), stub!("layer.sceneEditDetection", "Scene Edit Detection...", ["Layer"], "{}"), diff --git a/crates/engine/src/commands/styles.rs b/crates/engine/src/commands/styles.rs new file mode 100644 index 00000000..82bbf5eb --- /dev/null +++ b/crates/engine/src/commands/styles.rs @@ -0,0 +1,244 @@ +//! Layer ▸ Layer Styles: add / remove / show styles and the comp's Global Light. +//! +//! Styles are property groups under the layer's `layerStyles` group (see +//! [`effectcraft_project::styles`]); their values are edited with `prop.set` like any other +//! property (`layerStyles/dropShadow/distance`). Global Light is shared by every layer of a comp: +//! editing one layer's `layerStyles/blendingOptions/globalLightAngle` updates them all. + +use effectcraft_project::build::Ids; +use effectcraft_project::styles::{self as st, GROUP, STYLES}; +use effectcraft_project::{ItemId, LayerId, Uid}; +use serde_json::{Value, json}; + +use super::{CommandSpec, b_p, bad, f_p, has_comp, has_layers, layer_mut, layer_p, layers_p, str_p, time_p}; +use crate::{EngineError, Result, Session, cmd}; + +/// Selected (or given) layers that take layer styles. +fn style_layers(s: &Session, p: &Value) -> Result<(ItemId, Vec)> { + let (cid, ids) = layers_p(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + Ok((cid, ids.into_iter().filter(|id| comp.layer(*id).is_some_and(|l| l.can_have_styles())).collect())) +} + +fn add_styles(s: &mut Session, p: &Value, cmd: &str, styles: &[&str], enabled: bool, label: &str) -> Result { + let (cid, ids) = style_layers(s, p)?; + if ids.is_empty() { + return Err(bad(cmd, "no layer that takes layer styles (footage, solid, precomp, text or shape layer)")); + } + let uids = s.edit(label, None, |proj, state| { + let gl = proj.comp(cid).ok_or(EngineError::NoComp)?.global_light.clone(); + let mut out = vec![]; + let mut sel = vec![]; + for lid in &ids { + let mut next = proj.next_id; + let l = layer_mut(proj, cid, *lid)?; + for id in styles { + let had = l.layer_styles().and_then(|g| g.sub(id)).is_some(); + let uid = st::add_style(l, &mut Ids(&mut next), id, &gl, enabled).ok_or_else(|| bad(cmd, format!("unknown style `{id}`")))?; + // Choosing a style that is already there (e.g. after Show All) switches it on. + if had + && enabled + && let Some(g) = l.props.find_group_mut(uid) + { + g.enabled = true; + } + if let Some(g) = l.props.sub_mut(GROUP) { + g.enabled = true; + } + out.push(json!({"layer": lid.0, "style": id, "uid": uid})); + sel.push((*lid, uid)); + } + proj.next_id = next; + } + state.selected_props = sel; + Ok(out) + })?; + Ok(json!({"styles": uids})) +} + +fn add(s: &mut Session, p: &Value) -> Result { + let name = str_p(p, "style").ok_or_else(|| bad("layer.style.add", "missing `style` (e.g. dropShadow or \"Drop Shadow\")"))?; + let id = st::style_id(name) + .ok_or_else(|| bad("layer.style.add", format!("unknown style `{name}`; one of {}", STYLES.iter().map(|s| s.0).collect::>().join(", "))))?; + let label = STYLES.iter().find(|s| s.0 == id).map(|s| s.1).unwrap_or(id); + add_styles(s, p, "layer.style.add", &[id], true, label) +} + +macro_rules! style_cmd { + ($fn:ident, $id:literal) => { + fn $fn(s: &mut Session, p: &Value) -> Result { + let label = STYLES.iter().find(|x| x.0 == $id).map(|x| x.1).unwrap_or($id); + add_styles(s, p, concat!("layer.style.", $id), &[$id], true, label) + } + }; +} +style_cmd!(drop_shadow, "dropShadow"); +style_cmd!(inner_shadow, "innerShadow"); +style_cmd!(outer_glow, "outerGlow"); +style_cmd!(inner_glow, "innerGlow"); +style_cmd!(bevel_emboss, "bevelEmboss"); +style_cmd!(satin, "satin"); +style_cmd!(color_overlay, "colorOverlay"); +style_cmd!(gradient_overlay, "gradientOverlay"); +style_cmd!(stroke, "stroke"); + +/// Show All: every style is added (new ones switched off) so they can be revealed and edited. +fn show_all(s: &mut Session, p: &Value) -> Result { + let all: Vec<&str> = STYLES.iter().map(|s| s.0).collect(); + add_styles(s, p, "layer.style.showAll", &all, false, "Show All Layer Styles") +} + +fn remove_all(s: &mut Session, p: &Value) -> Result { + let (cid, ids) = style_layers(s, p)?; + let n = s.edit("Remove All Layer Styles", None, |proj, state| { + let mut n = 0; + for lid in &ids { + let l = layer_mut(proj, cid, *lid)?; + if let Some(g) = l.layer_styles() { + let gone: Vec = std::iter::once(g.uid).chain(g.groups().map(|x| x.uid)).collect(); + l.props.children.retain(|c| c.match_id() != GROUP); + state.selected_props.retain(|(_, u)| !gone.contains(u)); + n += 1; + } + } + Ok(n) + })?; + Ok(json!({"layers": n})) +} + +/// A style group of a layer by match id, display name, uid or the selection. +fn find_style(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, LayerId, Uid)> { + let (cid, lid) = layer_p(s, p, cmd)?; + let layer = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?; + let g = layer.layer_styles().ok_or_else(|| bad(cmd, "the layer has no layer styles"))?; + let found = match p.get("style") { + Some(Value::Number(n)) => { + let n = n.as_u64().unwrap_or(0); + if n == g.uid { Some(g.uid) } else { g.groups().find(|x| x.uid == n).map(|x| x.uid) } + } + Some(Value::String(name)) if name == GROUP || name.eq_ignore_ascii_case("Layer Styles") => Some(g.uid), + Some(Value::String(name)) => { + let id = st::style_id(name).unwrap_or(name.as_str()); + g.groups().find(|x| x.match_id == id || x.name.eq_ignore_ascii_case(name)).map(|x| x.uid) + } + _ => s.state.selected_props.iter().find_map(|(l, u)| (*l == lid).then(|| g.groups().find(|x| x.uid == *u).map(|x| x.uid)).flatten()), + } + .ok_or_else(|| bad(cmd, "no such layer style"))?; + Ok((cid, lid, found)) +} + +fn remove(s: &mut Session, p: &Value) -> Result { + let (cid, lid, uid) = find_style(s, p, "layer.style.remove")?; + s.edit("Remove Layer Style", None, |proj, state| { + let l = layer_mut(proj, cid, lid)?; + let whole = l.layer_styles().is_some_and(|g| g.uid == uid); + if whole { + l.props.children.retain(|c| c.match_id() != GROUP); + } else if let Some(g) = l.props.sub_mut(GROUP) { + if g.sub(st::BLENDING).is_some_and(|b| b.uid == uid) { + return Err(bad("layer.style.remove", "Blending Options can't be removed")); + } + g.children.retain(|c| c.uid() != uid); + // The last style gone removes the Layer Styles group, as in AE. + if g.children.iter().all(|c| c.match_id() == st::BLENDING) { + l.props.children.retain(|c| c.match_id() != GROUP); + } + } + state.selected_props.retain(|(_, u)| *u != uid); + Ok(()) + })?; + Ok(Value::Null) +} + +/// Eye switch of a style (or of the whole Layer Styles group: `style: "layerStyles"`). +fn toggle(s: &mut Session, p: &Value) -> Result { + let (cid, lid, uid) = find_style(s, p, "layer.style.toggle")?; + let v = b_p(p, "value"); + let r = s.edit("Toggle Layer Style", None, |proj, _| { + let g = layer_mut(proj, cid, lid)?.props.find_group_mut(uid).ok_or_else(|| bad("layer.style.toggle", "gone"))?; + g.enabled = v.unwrap_or(!g.enabled); + Ok(g.enabled) + })?; + Ok(json!(r)) +} + +fn global_light(s: &mut Session, p: &Value) -> Result { + let cid = super::comp_id(s, p)?; + let (angle, altitude) = (f_p(p, "angle"), f_p(p, "altitude")); + if angle.is_none() && altitude.is_none() { + let c = s.project.comp(cid).ok_or(EngineError::NoComp)?; + return Ok(json!({"angle": c.global_light.angle.value.as_f64(), "altitude": c.global_light.altitude.value.as_f64()})); + } + let t = time_p(s, p, s.project.comp(cid)); + s.edit("Global Light", super::merge_p(p), |proj, _| { + let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + st::set_global_light(c, angle, altitude, t); + Ok(()) + })?; + let c = s.project.comp(cid).ok_or(EngineError::NoComp)?; + Ok(json!({"angle": c.global_light.angle.value_at(t).as_f64(), "altitude": c.global_light.altitude.value_at(t).as_f64()})) +} + +fn no_psd_styles(s: &Session) -> std::result::Result<(), String> { + has_layers(s)?; + Err("no Photoshop layer styles to convert (only layers imported from PSD files carry them)".into()) +} + +fn convert(_: &mut Session, _: &Value) -> Result { + Err(EngineError::Other("no Photoshop layer styles to convert".into())) +} + +fn list(s: &mut Session, p: &Value) -> Result { + let (cid, lid) = layer_p(s, p, "layer.style.list")?; + let l = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?; + let styles: Vec = l + .layer_styles() + .map(|g| { + g.groups() + .filter(|x| x.match_id != st::BLENDING) + .map(|x| json!({"style": x.match_id, "name": x.name, "uid": x.uid, "enabled": x.enabled})) + .collect() + }) + .unwrap_or_default(); + Ok( + json!({"group": l.layer_styles().map(|g| g.uid), "enabled": l.layer_styles().map(|g| g.enabled), "styles": styles, "available": STYLES.iter().map(|s| json!({"style": s.0, "name": s.1})).collect::>()}), + ) +} + +pub fn specs() -> Vec { + vec![ + cmd!("layer.style.convertToEditable", "Convert to Editable Styles", ["Layer", "Layer Styles"], None, "{layers?}", no_psd_styles, convert), + cmd!("layer.style.showAll", "Show All", ["Layer", "Layer Styles"], None, "{layers?}", has_layers, show_all), + cmd!("layer.style.removeAll", "Remove All", ["Layer", "Layer Styles"], None, "{layers?}", has_layers, remove_all), + cmd!("layer.style.dropShadow", "Drop Shadow", ["Layer", "Layer Styles"], None, "{layers?}", has_layers, drop_shadow), + cmd!("layer.style.innerShadow", "Inner Shadow", ["Layer", "Layer Styles"], None, "{layers?}", has_layers, inner_shadow), + cmd!("layer.style.outerGlow", "Outer Glow", ["Layer", "Layer Styles"], None, "{layers?}", has_layers, outer_glow), + cmd!("layer.style.innerGlow", "Inner Glow", ["Layer", "Layer Styles"], None, "{layers?}", has_layers, inner_glow), + cmd!("layer.style.bevelEmboss", "Bevel and Emboss", ["Layer", "Layer Styles"], None, "{layers?}", has_layers, bevel_emboss), + cmd!("layer.style.satin", "Satin", ["Layer", "Layer Styles"], None, "{layers?}", has_layers, satin), + cmd!("layer.style.colorOverlay", "Color Overlay", ["Layer", "Layer Styles"], None, "{layers?}", has_layers, color_overlay), + cmd!("layer.style.gradientOverlay", "Gradient Overlay", ["Layer", "Layer Styles"], None, "{layers?}", has_layers, gradient_overlay), + cmd!("layer.style.stroke", "Stroke", ["Layer", "Layer Styles"], None, "{layers?}", has_layers, stroke), + cmd!( + "layer.style.add", + "Add Layer Style", + [], + None, + "{style: dropShadow|innerShadow|outerGlow|innerGlow|bevelEmboss|satin|colorOverlay|gradientOverlay|stroke (or display name), layers?}", + has_layers, + add + ), + cmd!("layer.style.remove", "Remove Layer Style", [], None, "{layer?, style: id|name|uid|layerStyles}", has_layers, remove), + cmd!("layer.style.toggle", "Toggle Layer Style", [], None, "{layer?, style: id|name|uid|layerStyles, value?}", has_layers, toggle), + cmd!( + "layer.style.globalLight", + "Global Light", + [], + None, + "{comp?, angle? (deg), altitude? (deg, 0..90), time? (s)} — no values returns the current setting", + has_comp, + global_light + ), + crate::query!("layer.style.list", "List Layer Styles", "{layer?}", list), + ] +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 30d6bc6d..6770a5bc 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -276,6 +276,8 @@ impl Session { let mut p = (*self.project).clone(); let mut st = self.state.clone(); let r = f(&mut p, &mut st)?; + // Layer styles: one Global Light per comp, whichever layer edited it. + effectcraft_project::styles::sync_global_light(&before, &mut p); let same = merge.is_some() && merge.map(str::to_string) == self.history.merge_key; if !same { self.history.undo.push((label.to_string(), before)); @@ -454,6 +456,8 @@ mod tests_3d; #[cfg(test)] mod tests_menu_cmds; #[cfg(test)] +mod tests_styles; +#[cfg(test)] mod tests_timeline; /// Font families available to text layers (bundled + scanned system fonts). diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index 2c887db3..cac33580 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -641,19 +641,19 @@ Layer Matte with Layer Above | layer.trackMatte {"op":"above"} Matte with Layer Below | layer.trackMatte {"op":"below"} Layer Styles - Convert to Editable Styles | layer.styles {"op":"convert"} - Show All | layer.styles {"op":"showAll"} - Remove All | layer.styles {"op":"removeAll"} + Convert to Editable Styles | layer.style.convertToEditable + Show All | layer.style.showAll + Remove All | layer.style.removeAll --- - Drop Shadow | layer.styles {"style":"dropShadow"} - Inner Shadow | layer.styles {"style":"innerShadow"} - Outer Glow | layer.styles {"style":"outerGlow"} - Inner Glow | layer.styles {"style":"innerGlow"} - Bevel and Emboss | layer.styles {"style":"bevelEmboss"} - Satin | layer.styles {"style":"satin"} - Color Overlay | layer.styles {"style":"colorOverlay"} - Gradient Overlay | layer.styles {"style":"gradientOverlay"} - Stroke | layer.styles {"style":"stroke"} + Drop Shadow | layer.style.dropShadow + Inner Shadow | layer.style.innerShadow + Outer Glow | layer.style.outerGlow + Inner Glow | layer.style.innerGlow + Bevel and Emboss | layer.style.bevelEmboss + Satin | layer.style.satin + Color Overlay | layer.style.colorOverlay + Gradient Overlay | layer.style.gradientOverlay + Stroke | layer.style.stroke --- Arrange Bring Layer to Front | layer.arrange {"to":"front"} | Cmd+Shift+] diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index 169dddfb..095e55af 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -485,7 +485,7 @@ fn frontend_commands_emit_events_and_stubs_are_disabled() { let ev = frontend_events(&mut s); assert_eq!(ev[0].0, "view.zoomIn"); assert_eq!(ev[1], ("window.panel".to_string(), json!({"panel": "align"}))); - for id in ["render.addOutputModule", "path.rotoBezier", "camera.fromView", "view.3d.default", "layer.styles", "track.motion"] { + for id in ["render.addOutputModule", "path.rotoBezier", "camera.fromView", "view.3d.default", "layer.create", "track.motion"] { assert!(!s.is_enabled(id), "{id}"); assert!(matches!(s.execute(id, json!({})), Err(EngineError::Disabled(..))), "{id}"); } diff --git a/crates/engine/src/tests_styles.rs b/crates/engine/src/tests_styles.rs new file mode 100644 index 00000000..e589241f --- /dev/null +++ b/crates/engine/src/tests_styles.rs @@ -0,0 +1,119 @@ +//! Layer Styles commands: add/remove/show, eye switches, Global Light linkage, undo, serde. + +use serde_json::json; + +use crate::Session; + +fn session() -> (Session, u64, u64) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Styles", "width": 200, "height": 100, "frameRate": 30, "duration": 2})).unwrap(); + let a = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 40, "height": 40})).unwrap()["layer"].as_u64().unwrap(); + let b = s.execute("layer.newSolid", json!({"color": "#808080", "width": 40, "height": 40})).unwrap()["layer"].as_u64().unwrap(); + (s, a, b) +} + +fn styles_of(s: &Session, layer: u64) -> Vec<(String, bool)> { + let v = s.execute_query("layer.style.list", json!({"layer": layer})); + v["styles"].as_array().unwrap().iter().map(|x| (x["style"].as_str().unwrap().to_string(), x["enabled"].as_bool().unwrap())).collect() +} + +impl Session { + fn execute_query(&self, id: &str, p: serde_json::Value) -> serde_json::Value { + let mut c = Session { project: self.project.clone(), state: self.state.clone(), ..Default::default() }; + c.execute(id, p).unwrap() + } +} + +#[test] +fn add_remove_show_all_with_undo() { + let (mut s, a, _) = session(); + s.execute("layer.select", json!({"layers": [a]})).unwrap(); + s.execute("layer.style.stroke", json!({})).unwrap(); + s.execute("layer.style.dropShadow", json!({})).unwrap(); + s.execute("layer.style.add", json!({"style": "Bevel and Emboss"})).unwrap(); + assert_eq!(styles_of(&s, a), vec![("dropShadow".into(), true), ("bevelEmboss".into(), true), ("stroke".into(), true)]); + // Values are ordinary properties. + s.execute("prop.set", json!({"layer": a, "path": "layerStyles/dropShadow/distance", "value": 12})).unwrap(); + let v = s.execute("prop.get", json!({"layer": a, "path": "layerStyles/dropShadow/distance"})).unwrap(); + assert!(v.to_string().contains("12")); + // Eye switch. + s.execute("layer.style.toggle", json!({"layer": a, "style": "stroke"})).unwrap(); + assert!(styles_of(&s, a).contains(&("stroke".into(), false))); + s.execute("edit.undo", json!({})).unwrap(); + assert!(styles_of(&s, a).contains(&("stroke".into(), true))); + // Remove one, then everything. + s.execute("layer.style.remove", json!({"layer": a, "style": "bevelEmboss"})).unwrap(); + assert_eq!(styles_of(&s, a).len(), 2); + s.execute("layer.style.removeAll", json!({})).unwrap(); + assert!(styles_of(&s, a).is_empty()); + let layer = s.active_comp().unwrap().layer(effectcraft_project::LayerId(a)).unwrap().clone(); + assert!(layer.layer_styles().is_none()); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(styles_of(&s, a).len(), 2); + s.execute("edit.redo", json!({})).unwrap(); + assert!(styles_of(&s, a).is_empty()); + // Show All adds every style switched off; choosing one switches it on. + s.execute("layer.style.showAll", json!({})).unwrap(); + let all = styles_of(&s, a); + assert_eq!(all.len(), 9); + assert!(all.iter().all(|(_, on)| !on)); + s.execute("layer.style.satin", json!({})).unwrap(); + assert!(styles_of(&s, a).contains(&("satin".into(), true))); + // Removing the last style removes the group. + s.execute("layer.style.removeAll", json!({})).unwrap(); + s.execute("layer.style.colorOverlay", json!({})).unwrap(); + s.execute("layer.style.remove", json!({"layer": a, "style": "Color Overlay"})).unwrap(); + assert!(s.active_comp().unwrap().layer(effectcraft_project::LayerId(a)).unwrap().layer_styles().is_none()); + // Convert to Editable Styles is unavailable without PSD styles. + assert!(!s.is_enabled("layer.style.convertToEditable")); +} + +#[test] +fn global_light_is_shared_by_the_comp() { + let (mut s, a, b) = session(); + s.execute("layer.style.dropShadow", json!({"layers": [a]})).unwrap(); + s.execute("layer.style.bevelEmboss", json!({"layers": [b]})).unwrap(); + // Editing one layer's Global Light Angle updates the other layer and the comp. + s.execute("prop.set", json!({"layer": a, "path": "layerStyles/blendingOptions/globalLightAngle", "value": 45})).unwrap(); + let bv = s.execute("prop.get", json!({"layer": b, "path": "layerStyles/blendingOptions/globalLightAngle"})).unwrap(); + assert!(bv.to_string().contains("45"), "{bv}"); + let gl = s.execute("layer.style.globalLight", json!({})).unwrap(); + assert_eq!(gl["angle"].as_f64(), Some(45.0)); + // The command sets both and new style groups pick it up. + s.execute("layer.style.globalLight", json!({"angle": 10, "altitude": 60})).unwrap(); + let c = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 10, "height": 10})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.style.innerShadow", json!({"layers": [c]})).unwrap(); + for l in [a, b, c] { + let v = s.execute("prop.get", json!({"layer": l, "path": "layerStyles/blendingOptions/globalLightAltitude"})).unwrap(); + assert!(v.to_string().contains("60"), "{l}: {v}"); + } + // Undo restores the previous shared value on every layer. + s.execute("edit.undo", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + let v = s.execute("prop.get", json!({"layer": b, "path": "layerStyles/blendingOptions/globalLightAngle"})).unwrap(); + assert!(v.to_string().contains("45"), "{v}"); +} + +#[test] +fn styles_render_and_round_trip() { + let (mut s, a, _) = session(); + s.execute("layer.style.stroke", json!({"layers": [a]})).unwrap(); + s.execute("prop.set", json!({"layer": a, "path": "layerStyles/stroke/size", "value": 6})).unwrap(); + let cid = s.active_comp_id().unwrap(); + // Cached session render == uncached render, and the stroke shows outside the 40×40 square. + let img = s.render(cid, s.time(), Default::default()); + let r = effectcraft_render::render_frame(&s.project, cid, s.time(), 1.0); + assert_eq!(img.data, r.data); + assert!(img.get(123, 50)[0] > 0.98 && img.get(123, 50)[1] < 0.02, "{:?}", img.get(123, 50)); + // Change the colour through a command: the cache must not serve stale pixels. + s.execute("prop.set", json!({"layer": a, "path": "layerStyles/stroke/color", "value": [0, 0, 1, 1]})).unwrap(); + let img2 = s.render(cid, s.time(), Default::default()); + assert!(img2.get(123, 50)[2] > 0.98 && img2.get(123, 50)[0] < 0.02); + // Serde round trip keeps styles and the comp's Global Light. + s.execute("layer.style.globalLight", json!({"angle": 33})).unwrap(); + let json = serde_json::to_string(&*s.project).unwrap(); + let back: effectcraft_project::Project = serde_json::from_str(&json).unwrap(); + assert_eq!(back, *s.project); + assert_eq!(back.comp(cid).unwrap().global_light.angle.value.as_f64(), 33.0); +} diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 5a5cf523..9e8cf949 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -7,6 +7,7 @@ pub mod build; pub mod props; pub mod render_queue; +pub mod styles; use std::collections::BTreeMap; use std::sync::Arc; @@ -150,6 +151,9 @@ pub struct Comp { pub draft_3d: bool, #[serde(default)] pub poster_time: Tick, + /// Global Light for layer styles (Layer ▸ Layer Styles ▸ Blending Options). + #[serde(default)] + pub global_light: styles::GlobalLight, /// Viewer guides (View ▸ Add Guide…), in comp pixels. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub guides: Vec, @@ -189,6 +193,7 @@ impl Comp { draft_3d: false, poster_time: Tick::ZERO, guides: vec![], + global_light: styles::GlobalLight::default(), } } pub fn layer(&self, id: LayerId) -> Option<&Layer> { diff --git a/crates/project/src/styles.rs b/crates/project/src/styles.rs new file mode 100644 index 00000000..83db5e58 --- /dev/null +++ b/crates/project/src/styles.rs @@ -0,0 +1,485 @@ +//! Layer Styles (Layer ▸ Layer Styles): the `layerStyles` property group and its styles. +//! +//! After Effects' layer styles are Photoshop's: Blending Options (Global Light, Advanced +//! Blending), Drop Shadow, Inner Shadow, Outer Glow, Inner Glow, Bevel and Emboss, Satin, Color +//! Overlay, Gradient Overlay and Stroke. Every style is a group under the layer's `layerStyles` +//! group (which sits after Transform, as in the AE timeline), with AE's property names, order, +//! defaults and ranges; every property animates. A style group's `enabled` flag is its eye +//! switch. Styles appear in the group in menu order whatever order they were added in. +//! +//! Global Light (angle/altitude) is one setting per composition: [`Comp::global_light`] holds it, +//! each layer's Blending Options shows it, and [`sync_global_light`] keeps every layer of a comp +//! in step after any edit. + +use effectcraft_color::BlendMode; +use effectcraft_keyframe::{Gradient, Value}; +use serde::{Deserialize, Serialize}; + +use crate::build::Ids; +use crate::props::{GroupKind, ParamUi, PropGroup, Property}; +use crate::{Comp, Layer, Project}; + +/// Match id of the layer's Layer Styles group. +pub const GROUP: &str = "layerStyles"; +/// Match id of the Blending Options group. +pub const BLENDING: &str = "blendingOptions"; + +/// The styles in menu / timeline order: (match id, display name). +pub const STYLES: &[(&str, &str)] = &[ + ("dropShadow", "Drop Shadow"), + ("innerShadow", "Inner Shadow"), + ("outerGlow", "Outer Glow"), + ("innerGlow", "Inner Glow"), + ("bevelEmboss", "Bevel and Emboss"), + ("satin", "Satin"), + ("colorOverlay", "Color Overlay"), + ("gradientOverlay", "Gradient Overlay"), + ("stroke", "Stroke"), +]; + +/// Look a style up by match id or display name (case-insensitive). +pub fn style_id(s: &str) -> Option<&'static str> { + STYLES.iter().find(|(id, name)| id.eq_ignore_ascii_case(s) || name.eq_ignore_ascii_case(s)).map(|(id, _)| *id) +} + +/// The Blend Mode popup of layer styles (Photoshop's list) and the compositor mode for each. +pub const STYLE_BLEND_MODES: &[(&str, BlendMode)] = &[ + ("Normal", BlendMode::Normal), + ("Dissolve", BlendMode::Dissolve), + ("Darken", BlendMode::Darken), + ("Multiply", BlendMode::Multiply), + ("Color Burn", BlendMode::ColorBurn), + ("Linear Burn", BlendMode::LinearBurn), + ("Darker Color", BlendMode::DarkerColor), + ("Lighten", BlendMode::Lighten), + ("Screen", BlendMode::Screen), + ("Color Dodge", BlendMode::ColorDodge), + ("Linear Dodge (Add)", BlendMode::LinearDodge), + ("Lighter Color", BlendMode::LighterColor), + ("Overlay", BlendMode::Overlay), + ("Soft Light", BlendMode::SoftLight), + ("Hard Light", BlendMode::HardLight), + ("Vivid Light", BlendMode::VividLight), + ("Linear Light", BlendMode::LinearLight), + ("Pin Light", BlendMode::PinLight), + ("Hard Mix", BlendMode::HardMix), + ("Difference", BlendMode::Difference), + ("Exclusion", BlendMode::Exclusion), + ("Subtract", BlendMode::Subtract), + ("Divide", BlendMode::Divide), + ("Hue", BlendMode::Hue), + ("Saturation", BlendMode::Saturation), + ("Color", BlendMode::Color), + ("Luminosity", BlendMode::Luminosity), +]; + +/// Compositor blend mode for a style Blend Mode popup index. +pub fn style_blend_mode(i: u32) -> BlendMode { + STYLE_BLEND_MODES.get(i as usize).map(|m| m.1).unwrap_or(BlendMode::Normal) +} + +fn mode_index(m: BlendMode) -> u32 { + STYLE_BLEND_MODES.iter().position(|x| x.1 == m).unwrap_or(0) as u32 +} + +pub const KNOCKOUT: &[&str] = &["None", "Shallow", "Deep"]; +pub const COLOR_TYPE: &[&str] = &["Color", "Gradient"]; +pub const GLOW_TECHNIQUE: &[&str] = &["Softer", "Precise"]; +pub const GLOW_SOURCE: &[&str] = &["Center", "Edge"]; +pub const BEVEL_STYLE: &[&str] = &["Outer Bevel", "Inner Bevel", "Emboss", "Pillow Emboss", "Stroke Emboss"]; +pub const BEVEL_TECHNIQUE: &[&str] = &["Smooth", "Chisel Hard", "Chisel Soft"]; +pub const BEVEL_DIRECTION: &[&str] = &["Up", "Down"]; +pub const GRADIENT_STYLE: &[&str] = &["Linear", "Radial", "Angle", "Reflected", "Diamond"]; +pub const STROKE_POSITION: &[&str] = &["Outside", "Inside", "Center"]; + +/// Default Global Light (Photoshop / After Effects defaults). +pub const DEFAULT_ANGLE: f64 = 120.0; +pub const DEFAULT_ALTITUDE: f64 = 30.0; + +/// A composition's Global Light, mirrored into every layer's Blending Options. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct GlobalLight { + pub angle: Property, + pub altitude: Property, +} + +impl Default for GlobalLight { + fn default() -> Self { + GlobalLight { + angle: Property::new(0, "globalLightAngle", "Global Light Angle", Value::Scalar(DEFAULT_ANGLE)).with_ui(ParamUi::Angle), + altitude: Property::new(0, "globalLightAltitude", "Global Light Altitude", Value::Scalar(DEFAULT_ALTITUDE)).with_ui(ParamUi::Angle), + } + } +} + +fn slider(min: f64, max: f64, decimals: u8) -> ParamUi { + ParamUi::Slider { min, max, slider_min: min, slider_max: max, decimals } +} +fn slider2(min: f64, max: f64, smin: f64, smax: f64, decimals: u8) -> ParamUi { + ParamUi::Slider { min, max, slider_min: smin, slider_max: smax, decimals } +} +fn popup(opts: &[&str]) -> ParamUi { + ParamUi::Popup { options: opts.iter().map(|s| s.to_string()).collect() } +} +fn style_modes() -> ParamUi { + ParamUi::Popup { options: STYLE_BLEND_MODES.iter().map(|m| m.0.to_string()).collect() } +} +fn rgb(r: u8, g: u8, b: u8) -> Value { + Value::Color([r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0, 1.0]) +} + +fn mode(ids: &mut Ids, m: &str, name: &str, d: BlendMode) -> Property { + ids.prop(m, name, Value::Enum(mode_index(d))).with_ui(style_modes()) +} +fn color(ids: &mut Ids, m: &str, name: &str, v: Value) -> Property { + ids.prop(m, name, v).with_ui(ParamUi::Color) +} +fn pct(ids: &mut Ids, m: &str, name: &str, v: f64) -> Property { + ids.prop(m, name, Value::Scalar(v)).with_ui(slider(0.0, 100.0, 0)) +} +fn px(ids: &mut Ids, m: &str, name: &str, v: f64, max: f64) -> Property { + ids.prop(m, name, Value::Scalar(v)).with_ui(slider(0.0, max, 1)) +} +fn check(ids: &mut Ids, m: &str, name: &str, v: bool) -> Property { + ids.prop(m, name, Value::Bool(v)).with_ui(ParamUi::Checkbox) +} +fn pop(ids: &mut Ids, m: &str, name: &str, v: u32, opts: &[&str]) -> Property { + ids.prop(m, name, Value::Enum(v)).with_ui(popup(opts)) +} +fn angle(ids: &mut Ids, m: &str, name: &str, v: f64) -> Property { + ids.prop(m, name, Value::Scalar(v)).with_ui(ParamUi::Angle) +} +fn gradient(ids: &mut Ids, g: Gradient) -> Property { + ids.prop("colors", "Colors", Value::Gradient(g)).with_ui(ParamUi::Gradient) +} + +/// The default glow gradient: the glow colour fading to transparent. +fn glow_gradient() -> Gradient { + let c = [1.0, 1.0, 190.0 / 255.0, 1.0]; + Gradient { colors: vec![(0.0, c), (1.0, c)], opacities: vec![(0.0, 1.0), (1.0, 0.0)] } +} + +/// Global Light properties as they appear on a layer (copied from the comp's setting). +fn global_props(ids: &mut Ids, gl: &GlobalLight) -> (Property, Property) { + let mut a = gl.angle.clone(); + a.uid = ids.alloc(); + let mut b = gl.altitude.clone(); + b.uid = ids.alloc(); + (a, b) +} + +/// The Blending Options group. +pub fn blending_options(ids: &mut Ids, gl: &GlobalLight) -> PropGroup { + let (a, b) = global_props(ids, gl); + let adv = ids + .group("advancedBlending", "Advanced Blending") + .with(pct(ids, "fillOpacity", "Fill Opacity", 100.0)) + .with(check(ids, "red", "Red", true)) + .with(check(ids, "green", "Green", true)) + .with(check(ids, "blue", "Blue", true)) + .with(pop(ids, "knockout", "Knockout", 0, KNOCKOUT)) + .with(check(ids, "blendInteriorAsGroup", "Blend Interior Styles as Group", false)) + .with(check(ids, "useBlendRanges", "Use Blend Ranges from Source", true)); + ids.group(BLENDING, "Blending Options").with(a).with(b).with(adv) +} + +/// An empty Layer Styles group (Blending Options only). +pub fn layer_styles(ids: &mut Ids, gl: &GlobalLight) -> PropGroup { + let bo = blending_options(ids, gl); + ids.group(GROUP, "Layer Styles").with(bo) +} + +/// A style group with AE's defaults (`id` from [`STYLES`]). +pub fn style(ids: &mut Ids, id: &str) -> Option { + let (_, name) = STYLES.iter().find(|s| s.0 == id)?; + let mut g = ids.group(id, name); + g.kind = GroupKind::Plain; + let props: Vec = match id { + "dropShadow" => vec![ + mode(ids, "blendMode", "Blend Mode", BlendMode::Multiply), + color(ids, "color", "Color", rgb(0, 0, 0)), + pct(ids, "opacity", "Opacity", 75.0), + check(ids, "useGlobalLight", "Use Global Light", true), + angle(ids, "angle", "Angle", DEFAULT_ANGLE), + ids.prop("distance", "Distance", Value::Scalar(5.0)).with_ui(slider2(0.0, 30000.0, 0.0, 100.0, 1)), + pct(ids, "spread", "Spread", 0.0), + px(ids, "size", "Size", 5.0, 250.0), + pct(ids, "noise", "Noise", 0.0), + check(ids, "knocksOut", "Layer Knocks Out Drop Shadow", true), + ], + "innerShadow" => vec![ + mode(ids, "blendMode", "Blend Mode", BlendMode::Multiply), + color(ids, "color", "Color", rgb(0, 0, 0)), + pct(ids, "opacity", "Opacity", 75.0), + check(ids, "useGlobalLight", "Use Global Light", true), + angle(ids, "angle", "Angle", DEFAULT_ANGLE), + ids.prop("distance", "Distance", Value::Scalar(5.0)).with_ui(slider2(0.0, 30000.0, 0.0, 100.0, 1)), + pct(ids, "choke", "Choke", 0.0), + px(ids, "size", "Size", 5.0, 250.0), + pct(ids, "noise", "Noise", 0.0), + ], + "outerGlow" => vec![ + mode(ids, "blendMode", "Blend Mode", BlendMode::Screen), + pct(ids, "opacity", "Opacity", 75.0), + pct(ids, "noise", "Noise", 0.0), + pop(ids, "colorType", "Color Type", 0, COLOR_TYPE), + color(ids, "color", "Color", rgb(255, 255, 190)), + gradient(ids, glow_gradient()), + pct(ids, "gradientSmoothness", "Gradient Smoothness", 100.0), + pop(ids, "technique", "Technique", 0, GLOW_TECHNIQUE), + pct(ids, "spread", "Spread", 0.0), + px(ids, "size", "Size", 5.0, 250.0), + ids.prop("range", "Range", Value::Scalar(50.0)).with_ui(slider(1.0, 100.0, 0)), + pct(ids, "jitter", "Jitter", 0.0), + ], + "innerGlow" => vec![ + mode(ids, "blendMode", "Blend Mode", BlendMode::Screen), + pct(ids, "opacity", "Opacity", 75.0), + pct(ids, "noise", "Noise", 0.0), + pop(ids, "colorType", "Color Type", 0, COLOR_TYPE), + color(ids, "color", "Color", rgb(255, 255, 190)), + gradient(ids, glow_gradient()), + pct(ids, "gradientSmoothness", "Gradient Smoothness", 100.0), + pop(ids, "technique", "Technique", 0, GLOW_TECHNIQUE), + pop(ids, "source", "Source", 1, GLOW_SOURCE), + pct(ids, "choke", "Choke", 0.0), + px(ids, "size", "Size", 5.0, 250.0), + ids.prop("range", "Range", Value::Scalar(50.0)).with_ui(slider(1.0, 100.0, 0)), + pct(ids, "jitter", "Jitter", 0.0), + ], + "bevelEmboss" => vec![ + pop(ids, "style", "Style", 1, BEVEL_STYLE), + pop(ids, "technique", "Technique", 0, BEVEL_TECHNIQUE), + ids.prop("depth", "Depth", Value::Scalar(100.0)).with_ui(slider2(1.0, 1000.0, 1.0, 1000.0, 0)), + pop(ids, "direction", "Direction", 0, BEVEL_DIRECTION), + px(ids, "size", "Size", 5.0, 250.0), + px(ids, "soften", "Soften", 0.0, 16.0), + check(ids, "useGlobalLight", "Use Global Light", true), + angle(ids, "angle", "Angle", DEFAULT_ANGLE), + ids.prop("altitude", "Altitude", Value::Scalar(DEFAULT_ALTITUDE)).with_ui(ParamUi::Angle), + mode(ids, "highlightMode", "Highlight Mode", BlendMode::Screen), + color(ids, "highlightColor", "Highlight Color", rgb(255, 255, 255)), + pct(ids, "highlightOpacity", "Highlight Opacity", 75.0), + mode(ids, "shadowMode", "Shadow Mode", BlendMode::Multiply), + color(ids, "shadowColor", "Shadow Color", rgb(0, 0, 0)), + pct(ids, "shadowOpacity", "Shadow Opacity", 75.0), + ], + "satin" => vec![ + mode(ids, "blendMode", "Blend Mode", BlendMode::Multiply), + color(ids, "color", "Color", rgb(0, 0, 0)), + pct(ids, "opacity", "Opacity", 50.0), + angle(ids, "angle", "Angle", 19.0), + px(ids, "distance", "Distance", 11.0, 250.0), + px(ids, "size", "Size", 14.0, 250.0), + check(ids, "invert", "Invert", true), + ], + "colorOverlay" => { + vec![mode(ids, "blendMode", "Blend Mode", BlendMode::Normal), color(ids, "color", "Color", rgb(255, 0, 0)), pct(ids, "opacity", "Opacity", 100.0)] + } + "gradientOverlay" => vec![ + mode(ids, "blendMode", "Blend Mode", BlendMode::Normal), + pct(ids, "opacity", "Opacity", 100.0), + gradient(ids, Gradient { colors: vec![(0.0, [0.0, 0.0, 0.0, 1.0]), (1.0, [1.0, 1.0, 1.0, 1.0])], opacities: vec![(0.0, 1.0), (1.0, 1.0)] }), + pct(ids, "gradientSmoothness", "Gradient Smoothness", 100.0), + angle(ids, "angle", "Angle", 90.0), + pop(ids, "style", "Style", 0, GRADIENT_STYLE), + check(ids, "reverse", "Reverse", false), + check(ids, "alignWithLayer", "Align with Layer", true), + ids.prop("scale", "Scale", Value::Scalar(100.0)).with_ui(slider(10.0, 150.0, 0)), + ids.prop("offset", "Offset", Value::Vec2([0.0, 0.0])).with_ui(ParamUi::Point), + ], + "stroke" => vec![ + color(ids, "color", "Color", rgb(255, 0, 0)), + mode(ids, "blendMode", "Blend Mode", BlendMode::Normal), + ids.prop("size", "Size", Value::Scalar(3.0)).with_ui(slider(1.0, 250.0, 1)), + pct(ids, "opacity", "Opacity", 100.0), + pop(ids, "position", "Position", 0, STROKE_POSITION), + ], + _ => return None, + }; + for p in props { + g.children.push(p.into()); + } + Some(g) +} + +impl Layer { + /// The Layer Styles group (present once a style was added). + pub fn layer_styles(&self) -> Option<&PropGroup> { + self.props.sub(GROUP) + } + /// Layer types that take layer styles (AV layers: footage, solids, precomps, text, shapes). + pub fn can_have_styles(&self) -> bool { + self.source.is_av() && !self.switches.adjustment + } +} + +/// Add style `id` to `layer` (creating the Layer Styles group after Transform when missing). +/// Returns the style group's uid; an existing style of that kind is kept and returned. +pub fn add_style(layer: &mut Layer, ids: &mut Ids, id: &str, gl: &GlobalLight, enabled: bool) -> Option { + let order = STYLES.iter().position(|s| s.0 == id)?; + if layer.props.sub(GROUP).is_none() { + let g = layer_styles(ids, gl); + let at = layer.props.children.iter().position(|c| c.match_id() == "transform").map(|i| i + 1).unwrap_or(layer.props.children.len()); + layer.props.children.insert(at, g.into()); + } + let ls = layer.props.sub_mut(GROUP)?; + if let Some(g) = ls.sub(id) { + return Some(g.uid); + } + let mut g = style(ids, id)?; + g.enabled = enabled; + let uid = g.uid; + // Keep menu order: after Blending Options and every style that comes earlier. + let at = ls.children.iter().position(|c| STYLES.iter().position(|s| s.0 == c.match_id()).is_some_and(|o| o > order)).unwrap_or(ls.children.len()); + ls.children.insert(at, g.into()); + Some(uid) +} + +fn same_anim(a: &Property, b: &Property) -> bool { + a.value == b.value && a.keys == b.keys && a.expr == b.expr +} + +fn copy_anim(dst: &mut Property, src: &Property) { + dst.value = src.value.clone(); + dst.keys = src.keys.clone(); + dst.expr = src.expr.clone(); +} + +/// After an edit: if a layer's Global Light Angle/Altitude differs from its comp's setting, that +/// layer was edited — adopt its value for the comp and copy it to every other layer (`before` is +/// the project before the edit; comps that did not change are skipped). +pub fn sync_global_light(before: &Project, after: &mut Project) { + let ids: Vec<_> = after + .items + .iter() + .filter_map(|(id, it)| match (&it.kind, before.items.get(id).map(|b| &b.kind)) { + (crate::ItemKind::Comp(a), Some(crate::ItemKind::Comp(b))) if std::sync::Arc::ptr_eq(a, b) => None, + (crate::ItemKind::Comp(_), _) => Some(*id), + _ => None, + }) + .collect(); + for id in ids { + let Some(comp) = after.comp(id) else { continue }; + if !needs_sync(comp) { + continue; + } + if let Some(c) = after.comp_mut(id) { + sync_comp(c); + } + } +} + +fn needs_sync(comp: &Comp) -> bool { + comp.layers.iter().filter_map(|l| l.layer_styles()?.sub(BLENDING)).any(|bo| { + bo.get("globalLightAngle").is_some_and(|p| !same_anim(p, &comp.global_light.angle)) + || bo.get("globalLightAltitude").is_some_and(|p| !same_anim(p, &comp.global_light.altitude)) + }) +} + +/// Make every layer of `comp` share one Global Light (the first layer that differs wins). +pub fn sync_comp(comp: &mut Comp) { + for (m, which) in [("globalLightAngle", 0), ("globalLightAltitude", 1)] { + let cur = if which == 0 { &comp.global_light.angle } else { &comp.global_light.altitude }; + let changed = comp.layers.iter().filter_map(|l| l.layer_styles()?.sub(BLENDING)?.get(m)).find(|p| !same_anim(p, cur)).cloned(); + let Some(src) = changed else { continue }; + let target = if which == 0 { &mut comp.global_light.angle } else { &mut comp.global_light.altitude }; + copy_anim(target, &src); + for l in &mut comp.layers { + if let Some(p) = l.props.sub_mut(GROUP).and_then(|g| g.sub_mut(BLENDING)).and_then(|g| g.get_mut(m)) { + copy_anim(p, &src); + } + } + } +} + +/// Set the comp's Global Light statically (`None` keeps a value) and mirror it to all layers. +pub fn set_global_light(comp: &mut Comp, angle: Option, altitude: Option, t: effectcraft_time::Tick) { + if let Some(a) = angle { + comp.global_light.angle.set_value_at(t, Value::Scalar(a)); + } + if let Some(a) = altitude { + comp.global_light.altitude.set_value_at(t, Value::Scalar(a.clamp(0.0, 90.0))); + } + let (a, b) = (comp.global_light.angle.clone(), comp.global_light.altitude.clone()); + for l in &mut comp.layers { + if let Some(bo) = l.props.sub_mut(GROUP).and_then(|g| g.sub_mut(BLENDING)) { + if let Some(p) = bo.get_mut("globalLightAngle") { + copy_anim(p, &a); + } + if let Some(p) = bo.get_mut("globalLightAltitude") { + copy_anim(p, &b); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{LayerSource, build}; + use effectcraft_time::{FrameRate, Tick}; + + fn setup() -> (Project, Comp, Layer) { + let mut p = Project::default(); + let comp = Comp::new(100, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let l = build::layer(&mut p, &comp, "Shape", LayerSource::Shape, (0, 0), None); + (p, comp, l) + } + + #[test] + fn styles_keep_menu_order_and_follow_transform() { + let (mut p, comp, mut l) = setup(); + let gl = comp.global_light.clone(); + let mut ids = Ids(&mut p.next_id); + add_style(&mut l, &mut ids, "stroke", &gl, true).unwrap(); + add_style(&mut l, &mut ids, "dropShadow", &gl, true).unwrap(); + let again = add_style(&mut l, &mut ids, "stroke", &gl, true).unwrap(); + add_style(&mut l, &mut ids, "bevelEmboss", &gl, true).unwrap(); + let ls = l.layer_styles().unwrap(); + let order: Vec<&str> = ls.children.iter().map(|c| c.match_id()).collect(); + assert_eq!(order, ["blendingOptions", "dropShadow", "bevelEmboss", "stroke"]); + assert_eq!(ls.sub("stroke").unwrap().uid, again); + let top: Vec<&str> = l.props.children.iter().map(|c| c.match_id()).collect(); + let t = top.iter().position(|m| *m == "transform").unwrap(); + assert_eq!(top[t + 1], GROUP); + // Defaults from AE. + let ds = ls.sub("dropShadow").unwrap(); + assert_eq!(ds.get("opacity").unwrap().value, Value::Scalar(75.0)); + assert_eq!(style_blend_mode(ds.get("blendMode").unwrap().value.as_enum()), BlendMode::Multiply); + assert_eq!(ls.sub(BLENDING).unwrap().get("globalLightAngle").unwrap().value, Value::Scalar(120.0)); + } + + #[test] + fn every_style_builds() { + let mut next = 1; + let mut ids = Ids(&mut next); + for (id, name) in STYLES { + let g = style(&mut ids, id).unwrap(); + assert_eq!(g.name, *name); + assert!(!g.children.is_empty()); + } + assert_eq!(style_id("Bevel and Emboss"), Some("bevelEmboss")); + } + + #[test] + fn global_light_syncs_across_layers() { + let (mut p, mut comp, mut a) = setup(); + let mut b = build::layer(&mut p, &comp, "B", LayerSource::Shape, (0, 0), None); + let gl = comp.global_light.clone(); + let mut ids = Ids(&mut p.next_id); + add_style(&mut a, &mut ids, "dropShadow", &gl, true); + add_style(&mut b, &mut ids, "bevelEmboss", &gl, true); + comp.layers = vec![a, b]; + comp.layers[1].props.sub_mut(GROUP).unwrap().sub_mut(BLENDING).unwrap().get_mut("globalLightAngle").unwrap().value = Value::Scalar(45.0); + assert!(needs_sync(&comp)); + sync_comp(&mut comp); + assert!(!needs_sync(&comp)); + assert_eq!(comp.global_light.angle.value, Value::Scalar(45.0)); + let la = comp.layers[0].layer_styles().unwrap().sub(BLENDING).unwrap().get("globalLightAngle").unwrap(); + assert_eq!(la.value, Value::Scalar(45.0)); + set_global_light(&mut comp, None, Some(60.0), Tick::ZERO); + let lb = comp.layers[1].layer_styles().unwrap().sub(BLENDING).unwrap().get("globalLightAltitude").unwrap(); + assert_eq!(lb.value, Value::Scalar(60.0)); + } +} diff --git a/crates/render/src/cache.rs b/crates/render/src/cache.rs index 78ff39b5..f3c70d6a 100644 --- a/crates/render/src/cache.rs +++ b/crates/render/src/cache.rs @@ -224,8 +224,9 @@ pub fn layer_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool) -> Optio hash_debug(&mut h, &layer.source); hash_debug(&mut h, &layer.switches); for c in &layer.props.children { + // Transform is applied later; Layer Styles are keyed separately (see [`styles_key`]). if let Node::Group(g) = c - && g.match_id == "transform" + && (g.match_id == "transform" || g.match_id == effectcraft_project::styles::GROUP) { continue; } @@ -243,3 +244,22 @@ pub fn layer_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool) -> Optio } Some(h.finish()) } + +/// Cache key for the styled layer (content key + the Layer Styles group: structure, eye +/// switches, values, keyframes, expressions and their values at the context time — Global Light +/// included, as every layer mirrors it in its Blending Options). +pub fn styles_key(ctx: &EvalCtx, layer: &Layer, content_key: u64) -> u64 { + let mut h = KeyHasher(content_key ^ 0x9e37_79b9_7f4a_7c15); + if let Some(g) = layer.layer_styles() { + hash_debug(&mut h, g); + hash_values(&mut h, ctx, layer, g); + } + h.finish() +} + +/// A sub-key of `key` (styled layer passes, flattened buffer). +pub fn derive(key: u64, i: u64) -> u64 { + let mut h = KeyHasher(key); + i.hash(&mut h); + h.finish() +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 02f61df6..066fd752 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -11,6 +11,7 @@ pub mod cache; pub mod eval; pub mod masks; pub mod shapes; +pub mod styles; pub mod text; pub mod three_d; @@ -53,7 +54,7 @@ impl EffectHost for FxHost<'_, '_, '_> { } let sub = Renderer { depth: self.r.depth + 1, ..*self.r }; // The cached layer buffer is shared; effects get their own copy. - let buf = if masks_and_effects { (*sub.layer_buf(self.ctx, other)?).clone() } else { sub.source(self.ctx, other)? }; + let buf = if masks_and_effects { (*sub.content_buf(self.ctx, other)?).clone() } else { sub.source(self.ctx, other)? }; let size = source_size(self.r.project, other); let size = if size.0 == 0 { [self.ctx.comp.width as f64, self.ctx.comp.height as f64] } else { [size.0 as f64, size.1 as f64] }; Some(LayerPixels { buf, size }) @@ -262,13 +263,32 @@ impl<'a> Renderer<'a> { } } - /// Fully processed layer buffer (source → masks → effects). + /// Fully processed layer buffer (source → masks → effects → layer styles, flattened). /// Served from the layer cache when the layer's content key matches. pub fn layer_buf(&self, ctx: &EvalCtx, layer: &Layer) -> Option> { - self.layer_buf_timed(ctx, layer, None) + let st = self.styled(ctx, layer, None)?; + if st.passes.is_empty() { + return Some(st.body); + } + let key = st.key.map(|k| cache::derive(k, 0xf1a7)); + if let (Some(c), Some(k)) = (self.cache, key) + && let Some(b) = c.get(k) + { + return Some(b); + } + let flat = Arc::new(styles::Styled { passes: st.passes.iter().map(|(b, m)| ((**b).clone(), *m)).collect(), body: (*st.body).clone() }.flatten()); + if let (Some(c), Some(k)) = (self.cache, key) { + c.insert(k, flat.clone()); + } + Some(flat) } - fn layer_buf_timed(&self, ctx: &EvalCtx, layer: &Layer, mut timing: Option<&mut LayerTiming>) -> Option> { + /// Layer pixels after source → masks → effects (no layer styles). + pub fn content_buf(&self, ctx: &EvalCtx, layer: &Layer) -> Option> { + self.content_buf_timed(ctx, layer, None).map(|(b, _)| b) + } + + fn content_buf_timed(&self, ctx: &EvalCtx, layer: &Layer, mut timing: Option<&mut LayerTiming>) -> Option<(Arc, Option)> { let key = self.cache.and_then(|_| cache::layer_key(ctx, layer, self.opts.scale, self.opts.draft)); if let (Some(c), Some(k)) = (self.cache, key) && let Some(b) = c.get(k) @@ -276,7 +296,7 @@ impl<'a> Renderer<'a> { if let Some(t) = timing.as_deref_mut() { t.cached = true; } - return Some(b); + return Some((b, key)); } let mut buf = self.source(ctx, layer)?; masks::apply(ctx, layer, &mut buf); @@ -284,7 +304,42 @@ impl<'a> Renderer<'a> { if let (Some(c), Some(k)) = (self.cache, key) { c.insert(k, buf.clone()); } - Some(buf) + Some((buf, key)) + } + + /// The layer with its styles: exterior passes and body (cached per pass). + fn styled(&self, ctx: &EvalCtx, layer: &Layer, mut timing: Option<&mut LayerTiming>) -> Option { + let (content, ckey) = self.content_buf_timed(ctx, layer, timing.as_deref_mut())?; + if !styles::active(ctx, layer) { + return Some(StyledLayer { passes: vec![], body: content.clone(), content, key: None, plain: true }); + } + let modes = styles::pass_modes(ctx, layer); + let key = ckey.map(|k| cache::styles_key(ctx, layer, k)); + if let (Some(c), Some(k)) = (self.cache, key) + && let Some(body) = c.get(k) + { + let passes: Option> = modes.iter().enumerate().map(|(i, m)| c.get(cache::derive(k, i as u64 + 1)).map(|b| (b, *m))).collect(); + if let Some(passes) = passes { + return Some(StyledLayer { passes, body, content, key, plain: false }); + } + } + let t0 = std::time::Instant::now(); + let size = source_size(self.project, layer); + let layer_size = if size.0 == 0 { [ctx.comp.width as f64, ctx.comp.height as f64] } else { [size.0 as f64, size.1 as f64] }; + let st = styles::render(ctx, layer, &content, layer_size); + if let Some(t) = timing { + t.cached = false; + t.effects.push(("layerStyles".into(), t0.elapsed().as_secs_f64() * 1e3)); + } + let body = Arc::new(st.body); + let passes: Vec<_> = st.passes.into_iter().map(|(b, m)| (Arc::new(b), m)).collect(); + if let (Some(c), Some(k)) = (self.cache, key) { + c.insert(k, body.clone()); + for (i, (b, _)) in passes.iter().enumerate() { + c.insert(cache::derive(k, i as u64 + 1), b.clone()); + } + } + Some(StyledLayer { passes, body, content, key, plain: false }) } fn sampling(&self, layer: &Layer) -> effectcraft_raster::Sampling { @@ -341,34 +396,79 @@ impl<'a> Renderer<'a> { self.draw_adjustment(ctx, layer, canvas, opacity); return; } - let Some(prof) = self.profile else { - if let Some(buf) = self.layer_buf(ctx, layer) { - self.composite_layer(ctx, layer, &buf, canvas, opacity); - } - return; - }; - let mut timing = LayerTiming { depth: self.depth, layer: layer.name.clone(), ..Default::default() }; + let mut timing = self.profile.map(|_| LayerTiming { depth: self.depth, layer: layer.name.clone(), ..Default::default() }); let t0 = std::time::Instant::now(); - let Some(buf) = self.layer_buf_timed(ctx, layer, Some(&mut timing)) else { return }; + let Some(st) = self.styled(ctx, layer, timing.as_mut()) else { return }; let t1 = std::time::Instant::now(); - self.composite_layer(ctx, layer, &buf, canvas, opacity); - timing.process_ms = (t1 - t0).as_secs_f64() * 1e3; - timing.composite_ms = t1.elapsed().as_secs_f64() * 1e3; - if let Ok(mut v) = prof.lock() { - v.push(timing); + if st.plain { + self.composite_layer(ctx, layer, &st.body, canvas, opacity); + } else { + self.composite_styled(ctx, layer, &st, canvas, opacity); + } + if let (Some(prof), Some(mut timing)) = (self.profile, timing) { + timing.process_ms = (t1 - t0).as_secs_f64() * 1e3; + timing.composite_ms = t1.elapsed().as_secs_f64() * 1e3; + if let Ok(mut v) = prof.lock() { + v.push(timing); + } } } + /// Composite a styled layer: exterior passes with their own modes, then the body with the + /// layer's mode; the layer's opacity fades the whole stack. Knockout clears what is below the + /// layer's content; switched-off R/G/B channels keep the values below. + fn composite_styled(&self, ctx: &EvalCtx, layer: &Layer, st: &StyledLayer, canvas: &mut Image, opacity: f32) { + let bl = styles::blending(ctx, layer); + let matte = layer.track_matte.is_some_and(|tm| tm.layer != layer.id && ctx.comp.layer(tm.layer).is_some()); + if matte || layer.preserve_transparency { + let mut iso = Image::new(canvas.width, canvas.height); + for (b, m) in &st.passes { + self.place(ctx, layer, b, &mut iso, *m, 1.0); + } + self.place(ctx, layer, &st.body, &mut iso, BlendMode::Normal, 1.0); + self.composite_iso(ctx, layer, iso, canvas, opacity); + return; + } + let mut tmp = canvas.clone(); + if bl.knockout > 0 { + let mut k = Image::new(canvas.width, canvas.height); + self.place(ctx, layer, &st.content, &mut k, BlendMode::Normal, 1.0); + tmp.data.par_iter_mut().zip(k.data.par_iter()).for_each(|(p, q)| { + let keep = 1.0 - q[3].clamp(0.0, 1.0); + for c in p.iter_mut() { + *c *= keep; + } + }); + } + for (b, m) in &st.passes { + self.place(ctx, layer, b, &mut tmp, *m, 1.0); + } + self.place(ctx, layer, &st.body, &mut tmp, layer.blend_mode, 1.0); + let ch = bl.channels; + canvas.data.par_iter_mut().zip(tmp.data.par_iter()).for_each(|(c, t)| { + for i in 0..4 { + let v = if i < 3 && !ch[i] { c[i] } else { t[i] }; + c[i] += (v - c[i]) * opacity; + } + }); + } + fn composite_layer(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf, canvas: &mut Image, opacity: f32) { - let matte = layer.track_matte.and_then(|tm| ctx.comp.layer(tm.layer).filter(|m| m.id != layer.id).map(|m| (m, tm.kind))); - let preserve = layer.preserve_transparency; - if matte.is_none() && !preserve { + let matte = layer.track_matte.is_some_and(|tm| tm.layer != layer.id && ctx.comp.layer(tm.layer).is_some()); + if !matte && !layer.preserve_transparency { self.place(ctx, layer, buf, canvas, layer.blend_mode, opacity); return; } // Render in isolation, then matte / preserve transparency, then blend. let mut iso = Image::new(canvas.width, canvas.height); self.place(ctx, layer, buf, &mut iso, BlendMode::Normal, 1.0); + self.composite_iso(ctx, layer, iso, canvas, opacity); + } + + /// Apply the track matte / preserve transparency to an isolated layer render, then blend. + fn composite_iso(&self, ctx: &EvalCtx, layer: &Layer, mut iso: Image, canvas: &mut Image, opacity: f32) { + let matte = layer.track_matte.and_then(|tm| ctx.comp.layer(tm.layer).filter(|m| m.id != layer.id).map(|m| (m, tm.kind))); + let preserve = layer.preserve_transparency; if let Some((m, kind)) = matte { let mut mimg = Image::new(canvas.width, canvas.height); if m.is_active_at(ctx.time) @@ -420,6 +520,18 @@ impl<'a> Renderer<'a> { } } +/// A layer ready to composite (see [`Renderer::styled`]). +struct StyledLayer { + passes: Vec<(Arc, BlendMode)>, + body: Arc, + /// Pre-style pixels (knockout shape). + content: Arc, + /// Styled cache key. + key: Option, + /// No layer styles: composite `body` the usual way. + plain: bool, +} + /// Convenience: render one frame of a comp with no footage source. pub fn render_frame(project: &Project, comp: ItemId, t: Tick, scale: f64) -> Image { Renderer::new(project, &NoFootage, RenderOpts { scale, ..Default::default() }).comp_frame(comp, t) @@ -429,6 +541,8 @@ pub fn render_frame(project: &Project, comp: ItemId, t: Tick, scale: f64) -> Ima mod tests; #[cfg(test)] mod tests_remap; +#[cfg(test)] +mod tests_styles; /// Layer-space bounds `[x0, y0, x1, y1]` of a layer's content at the context time (source size /// for solids/footage/precomps, glyph bounds for text, painted bounds for shapes). Used for viewer diff --git a/crates/render/src/styles.rs b/crates/render/src/styles.rs new file mode 100644 index 00000000..8bc24498 --- /dev/null +++ b/crates/render/src/styles.rs @@ -0,0 +1,741 @@ +//! Layer Styles rendering (Photoshop-style layer effects, as After Effects renders them). +//! +//! Styles run in layer space after masks and effects and before the transform. The layer's +//! processed pixels (the *content*) become: +//! +//! * **exterior passes** — Drop Shadow, then Outer Glow — separate buffers composited onto the +//! comp *below* the layer, each with its own blend mode (so a Multiply shadow multiplies the +//! layers underneath, as in AE); +//! * the **body** — the content scaled by Fill Opacity, then the interior styles in Photoshop's +//! stacking order (Gradient Overlay, Color Overlay, Satin, Inner Glow, Inner Shadow), each +//! clipped to the content's alpha and blended with its mode, then Stroke and Bevel and Emboss. +//! With *Blend Interior Styles as Group* the interior styles are faded by Fill Opacity too. +//! +//! The compositor applies the layer's opacity to the whole stack, and Advanced Blending's +//! Knockout and R/G/B channel switches at composite time (see `Renderer::composite_styled`). +//! Every distance is in layer pixels and scales with the render resolution (Half, Quarter…). +//! +//! Shapes come from a signed distance field of the content alpha (exact Euclidean distance +//! transform, Felzenszwalb–Huttenlocher) for spread/choke, strokes, precise glows and bevel +//! profiles, and from the shared separable Gaussian (`effects::util::gauss_plane`) for soft +//! shadows and glows. All per-pixel work is parallel (rayon). + +use effectcraft_color::{BlendMode, blend_pixel}; +use effectcraft_effects::Buf; +use effectcraft_effects::util::{Plane, gauss_plane}; +use effectcraft_keyframe::{Gradient, Value}; +use effectcraft_project::styles::{self as st, style_blend_mode}; +use effectcraft_project::{Layer, PropGroup}; +use effectcraft_raster::{Image, hash_noise}; +use rayon::prelude::*; + +use crate::eval::EvalCtx; + +/// Largest padding (buffer pixels) styles may add on each side. +const MAX_PAD: f64 = 2048.0; + +/// A styled layer: exterior passes (bottom first, each with its blend mode) and the body. +#[derive(Clone, Debug)] +pub struct Styled { + pub passes: Vec<(Buf, BlendMode)>, + pub body: Buf, +} + +impl Styled { + /// Everything composited into one buffer (for 3D layers, track mattes and layer references). + pub fn flatten(&self) -> Buf { + if self.passes.is_empty() { + return self.body.clone(); + } + let mut img = Image::new(self.body.img.width, self.body.img.height); + for (p, m) in &self.passes { + img.blend_from(&p.img, *m, 1.0, 0); + } + img.blend_from(&self.body.img, BlendMode::Normal, 1.0, 0); + Buf { img, offset: self.body.offset, scale: self.body.scale } + } +} + +/// Advanced Blending options evaluated at a time. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Blending { + pub fill: f32, + /// Red, green, blue channel switches. + pub channels: [bool; 3], + /// 0 None, 1 Shallow, 2 Deep. + pub knockout: u32, + pub interior_group: bool, +} + +impl Default for Blending { + fn default() -> Self { + Blending { fill: 1.0, channels: [true; 3], knockout: 0, interior_group: false } + } +} + +/// The layer's Layer Styles group when styles are switched on. +pub fn group(layer: &Layer) -> Option<&PropGroup> { + layer.layer_styles().filter(|g| g.enabled && layer.can_have_styles()) +} + +/// Evaluated Advanced Blending (defaults when the layer has no styles). +pub fn blending(ctx: &EvalCtx, layer: &Layer) -> Blending { + let Some(adv) = group(layer).and_then(|g| g.sub(st::BLENDING)).and_then(|b| b.sub("advancedBlending")) else { return Blending::default() }; + Blending { + fill: (ctx.f(layer, adv, "fillOpacity", 100.0) / 100.0).clamp(0.0, 1.0) as f32, + channels: [ctx.b(layer, adv, "red"), ctx.b(layer, adv, "green"), ctx.b(layer, adv, "blue")], + knockout: ctx.e(layer, adv, "knockout"), + interior_group: ctx.b(layer, adv, "blendInteriorAsGroup"), + } +} + +/// Global light (angle, altitude) in degrees for the layer. +fn global_light(ctx: &EvalCtx, layer: &Layer, g: &PropGroup) -> (f64, f64) { + match g.sub(st::BLENDING) { + Some(bo) => (ctx.f(layer, bo, "globalLightAngle", st::DEFAULT_ANGLE), ctx.f(layer, bo, "globalLightAltitude", st::DEFAULT_ALTITUDE)), + None => (st::DEFAULT_ANGLE, st::DEFAULT_ALTITUDE), + } +} + +/// Does the layer need the styled path (any style on, or non-default blending)? +pub fn active(ctx: &EvalCtx, layer: &Layer) -> bool { + let Some(g) = group(layer) else { return false }; + g.groups().any(|s| s.enabled && s.match_id != st::BLENDING) || blending(ctx, layer) != Blending::default() +} + +/// Blend modes of the exterior passes [`render`] produces (Drop Shadow, then Outer Glow). +pub fn pass_modes(ctx: &EvalCtx, layer: &Layer) -> Vec { + let Some(g) = group(layer) else { return vec![] }; + ["dropShadow", "outerGlow"].iter().filter_map(|id| g.sub(id).filter(|s| s.enabled)).map(|s| style_blend_mode(ctx.e(layer, s, "blendMode"))).collect() +} + +// ------------------------------------------------------------------------------------------ +// Evaluated styles + +struct Shadow { + mode: BlendMode, + color: [f32; 3], + opacity: f32, + offset: [f64; 2], + spread: f64, + size: f64, + noise: f32, + knocks_out: bool, +} + +struct Glow { + mode: BlendMode, + opacity: f32, + noise: f32, + gradient: Option, + color: [f32; 3], + precise: bool, + center: bool, + spread: f64, + size: f64, + range: f64, + jitter: f32, +} + +struct Bevel { + style: u32, + technique: u32, + depth: f64, + down: bool, + size: f64, + soften: f64, + angle: f64, + altitude: f64, + hl: (BlendMode, [f32; 3], f32), + sh: (BlendMode, [f32; 3], f32), +} + +struct Satin { + mode: BlendMode, + color: [f32; 3], + opacity: f32, + offset: [f64; 2], + size: f64, + invert: bool, +} + +struct Overlay { + mode: BlendMode, + color: [f32; 3], + opacity: f32, +} + +struct GradOverlay { + mode: BlendMode, + opacity: f32, + gradient: Gradient, + angle: f64, + style: u32, + reverse: bool, + align: bool, + scale: f64, + offset: [f64; 2], +} + +struct Stroke { + mode: BlendMode, + color: [f32; 3], + size: f64, + opacity: f32, + position: u32, +} + +#[derive(Default)] +struct Styles { + drop: Option, + inner_shadow: Option, + outer_glow: Option, + inner_glow: Option, + bevel: Option, + satin: Option, + color: Option, + gradient: Option, + stroke: Option, +} + +fn rgb(c: [f32; 4]) -> [f32; 3] { + [c[0], c[1], c[2]] +} + +/// Light direction → shadow offset (the shadow falls away from the light). +fn offset(angle: f64, distance: f64) -> [f64; 2] { + let a = angle.to_radians(); + [-distance * a.cos(), distance * a.sin()] +} + +fn evaluate(ctx: &EvalCtx, layer: &Layer, g: &PropGroup) -> Styles { + let (ga, galt) = global_light(ctx, layer, g); + let mut s = Styles::default(); + for sg in g.groups().filter(|x| x.enabled) { + let f = |m: &str, d: f64| ctx.f(layer, sg, m, d); + let pct = |m: &str, d: f64| (ctx.f(layer, sg, m, d) / 100.0).clamp(0.0, 1.0); + let mode = |m: &str| style_blend_mode(ctx.e(layer, sg, m)); + let col = |m: &str| rgb(ctx.color(layer, sg, m)); + let ang = || if ctx.b(layer, sg, "useGlobalLight") { ga } else { f("angle", st::DEFAULT_ANGLE) }; + let grad = || match ctx.group_value(layer, sg, "colors") { + Some(Value::Gradient(g)) => g, + _ => Gradient::default(), + }; + match sg.match_id.as_str() { + "dropShadow" | "innerShadow" => { + let inner = sg.match_id == "innerShadow"; + let sh = Shadow { + mode: mode("blendMode"), + color: col("color"), + opacity: pct("opacity", 75.0) as f32, + offset: offset(ang(), f("distance", 5.0).max(0.0)), + spread: pct(if inner { "choke" } else { "spread" }, 0.0), + size: f("size", 5.0).clamp(0.0, 250.0), + noise: pct("noise", 0.0) as f32, + knocks_out: !inner && ctx.b(layer, sg, "knocksOut"), + }; + if inner { + s.inner_shadow = Some(sh); + } else { + s.drop = Some(sh); + } + } + "outerGlow" | "innerGlow" => { + let inner = sg.match_id == "innerGlow"; + let gl = Glow { + mode: mode("blendMode"), + opacity: pct("opacity", 75.0) as f32, + noise: pct("noise", 0.0) as f32, + gradient: (ctx.e(layer, sg, "colorType") == 1).then(grad), + color: col("color"), + precise: ctx.e(layer, sg, "technique") == 1, + center: inner && ctx.e(layer, sg, "source") == 0, + spread: pct(if inner { "choke" } else { "spread" }, 0.0), + size: f("size", 5.0).clamp(0.0, 250.0), + range: pct("range", 50.0).max(0.01), + jitter: pct("jitter", 0.0) as f32, + }; + if inner { + s.inner_glow = Some(gl); + } else { + s.outer_glow = Some(gl); + } + } + "bevelEmboss" => { + let gl = ctx.b(layer, sg, "useGlobalLight"); + s.bevel = Some(Bevel { + style: ctx.e(layer, sg, "style"), + technique: ctx.e(layer, sg, "technique"), + depth: f("depth", 100.0).clamp(1.0, 1000.0) / 100.0, + down: ctx.e(layer, sg, "direction") == 1, + size: f("size", 5.0).clamp(0.0, 250.0), + soften: f("soften", 0.0).clamp(0.0, 16.0), + angle: if gl { ga } else { f("angle", st::DEFAULT_ANGLE) }, + altitude: if gl { galt } else { f("altitude", st::DEFAULT_ALTITUDE) }.clamp(0.0, 90.0), + hl: (mode("highlightMode"), col("highlightColor"), pct("highlightOpacity", 75.0) as f32), + sh: (mode("shadowMode"), col("shadowColor"), pct("shadowOpacity", 75.0) as f32), + }); + } + "satin" => { + s.satin = Some(Satin { + mode: mode("blendMode"), + color: col("color"), + opacity: pct("opacity", 50.0) as f32, + offset: offset(f("angle", 19.0), f("distance", 11.0).clamp(0.0, 250.0)), + size: f("size", 14.0).clamp(0.0, 250.0), + invert: ctx.b(layer, sg, "invert"), + }); + } + "colorOverlay" => s.color = Some(Overlay { mode: mode("blendMode"), color: col("color"), opacity: pct("opacity", 100.0) as f32 }), + "gradientOverlay" => { + s.gradient = Some(GradOverlay { + mode: mode("blendMode"), + opacity: pct("opacity", 100.0) as f32, + gradient: grad(), + angle: f("angle", 90.0), + style: ctx.e(layer, sg, "style"), + reverse: ctx.b(layer, sg, "reverse"), + align: ctx.b(layer, sg, "alignWithLayer"), + scale: f("scale", 100.0).clamp(10.0, 150.0) / 100.0, + offset: ctx.v2(layer, sg, "offset", [0.0; 2]), + }); + } + "stroke" => { + s.stroke = Some(Stroke { + mode: mode("blendMode"), + color: col("color"), + size: f("size", 3.0).clamp(0.0, 250.0), + opacity: pct("opacity", 100.0) as f32, + position: ctx.e(layer, sg, "position"), + }); + } + _ => {} + } + } + s +} + +/// Layer-space margin the styles reach beyond the content. +fn reach(s: &Styles) -> f64 { + let soft = |size: f64| size * 1.6; + let mut r: f64 = 0.0; + if let Some(d) = &s.drop { + r = r.max(d.offset[0].abs().max(d.offset[1].abs()) + soft(d.size)); + } + if let Some(d) = &s.inner_shadow { + r = r.max(d.offset[0].abs().max(d.offset[1].abs()) + soft(d.size)); + } + if let Some(g) = &s.outer_glow { + r = r.max(soft(g.size)); + } + if let Some(g) = &s.inner_glow { + r = r.max(soft(g.size)); + } + if let Some(b) = &s.bevel { + r = r.max(b.size + b.soften * 2.0); + } + if let Some(st) = &s.stroke { + r = r.max(st.size); + } + if let Some(sa) = &s.satin { + r = r.max(sa.offset[0].abs().max(sa.offset[1].abs()) + soft(sa.size)); + } + r +} + +// ------------------------------------------------------------------------------------------ +// Distance fields + +/// "Infinite" squared distance (finite so parabola intersections stay exact in f64). +const FAR: f64 = 1e10; + +/// 1D squared Euclidean distance transform (Felzenszwalb & Huttenlocher) of `f` into `d`. +fn dt1d(f: &[f32], d: &mut [f32]) { + let n = f.len(); + if n == 0 { + return; + } + let fv = |i: usize| (f[i] as f64).min(FAR); + let mut v = vec![0usize; n]; + let mut z = vec![0f64; n + 1]; + let mut k = 0usize; + z[0] = f64::NEG_INFINITY; + z[1] = f64::INFINITY; + let cross = |q: usize, p: usize| ((fv(q) + (q * q) as f64) - (fv(p) + (p * p) as f64)) / (2.0 * (q as f64 - p as f64)); + for q in 1..n { + let mut s = cross(q, v[k]); + while k > 0 && s <= z[k] { + k -= 1; + s = cross(q, v[k]); + } + k += 1; + v[k] = q; + z[k] = s; + z[k + 1] = f64::INFINITY; + } + k = 0; + for (q, dq) in d.iter_mut().enumerate() { + while z[k + 1] < q as f64 { + k += 1; + } + let dx = q as f64 - v[k] as f64; + *dq = (dx * dx + fv(v[k])) as f32; + } +} + +/// Euclidean distance (pixels) from every pixel to the nearest pixel where `seed` is true. +fn edt(w: usize, h: usize, seed: impl Fn(usize) -> bool + Sync) -> Plane { + let f = Plane { w, h, data: (0..w * h).into_par_iter().map(|i| if seed(i) { 0.0 } else { FAR as f32 }).collect() }; + let rows = f.map_rows(dt1d); + let cols = rows.transpose().map_rows(dt1d).transpose(); + cols.map(|v| v.sqrt()) +} + +/// Signed distance to the content edge in pixels (negative inside), anti-aliased at the edge: +/// a partially covered pixel with alpha `a` sits at `0.5 - a`. +fn sdf(alpha: &Plane) -> Plane { + let (w, h) = (alpha.w, alpha.h); + let to_in = edt(w, h, |i| alpha.data[i] >= 0.5); + let to_out = edt(w, h, |i| alpha.data[i] < 0.5); + Plane { + w, + h, + data: (0..w * h) + .into_par_iter() + .map(|i| { + let a = alpha.data[i]; + if a > 0.0 && a < 1.0 { + 0.5 - a + } else if a >= 0.5 { + -(to_out.data[i] - 0.5) + } else { + to_in.data[i] - 0.5 + } + }) + .collect(), + } +} + +/// Coverage of the content grown by `r` pixels (negative `r` shrinks). +#[inline] +fn grown(sdf: f32, r: f32) -> f32 { + (r + 0.5 - sdf).clamp(0.0, 1.0) +} + +// ------------------------------------------------------------------------------------------ +// Helpers + +/// Soft shape: content grown by `spread` of `size`, blurred by the rest (`inverse` = the +/// outside of the content, for inner shadows/glows). +fn soft_shape(alpha: &Plane, d: &Lazy, size: f64, spread: f64, inverse: bool) -> Plane { + let hard = size * spread; + let base = if hard > 0.01 { + let sd = d.get(alpha); + let r = hard as f32; + if inverse { sd.map(|v| grown(-v, r)) } else { sd.map(|v| grown(v, r)) } + } else if inverse { + alpha.map(|a| 1.0 - a) + } else { + alpha.clone() + }; + let sigma = (size - hard).max(0.0) / 2.0; + if sigma > 0.05 { gauss_plane(&base, sigma, sigma) } else { base } +} + +/// Bilinear read of `p` displaced by `off` pixels (the plane moves by `off`). +#[inline] +fn shifted(p: &Plane, x: usize, y: usize, off: [f64; 2]) -> f32 { + p.sample(x as f64 + 0.5 - off[0], y as f64 + 0.5 - off[1]) +} + +struct Lazy(std::sync::OnceLock); +impl Lazy { + fn get(&self, alpha: &Plane) -> &Plane { + self.0.get_or_init(|| sdf(alpha)) + } +} + +#[inline] +fn noisy(a: f32, noise: f32, x: usize, y: usize, seed: u32) -> f32 { + if noise <= 0.0 { a } else { a * (1.0 - noise * hash_noise(x as u32, y as u32, seed)) } +} + +/// Blend a straight colour with coverage `a` onto every pixel of `img` (`f(x, y)` → (colour, a)). +fn blend_into(img: &mut Image, mode: BlendMode, seed: u32, f: impl Fn(usize, usize) -> ([f32; 3], f32) + Sync) { + let w = img.width as usize; + img.data.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| { + for (x, d) in row.iter_mut().enumerate() { + let (c, a) = f(x, y); + if a <= 0.0 { + continue; + } + let src = [c[0] * a, c[1] * a, c[2] * a, a]; + let n = if mode == BlendMode::Dissolve { hash_noise(x as u32, y as u32, seed) } else { 0.5 }; + *d = blend_pixel(mode, *d, src, n); + } + }); +} + +/// A pass image filled with a straight colour at coverage `f(x, y)`. +fn pass_image(w: usize, h: usize, f: impl Fn(usize, usize) -> ([f32; 3], f32) + Sync) -> Image { + let mut img = Image::new(w as u32, h as u32); + img.data.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| { + for (x, d) in row.iter_mut().enumerate() { + let (c, a) = f(x, y); + let a = a.clamp(0.0, 1.0); + *d = [c[0] * a, c[1] * a, c[2] * a, a]; + } + }); + img +} + +/// Glow intensity → (colour, alpha) for colour or gradient glows. +fn glow_color(g: &Glow, v: f32, x: usize, y: usize) -> ([f32; 3], f32) { + let v = v.clamp(0.0, 1.0).powf((0.5 / g.range) as f32); + match &g.gradient { + None => (g.color, v), + Some(gr) => { + let j = if g.jitter > 0.0 { (hash_noise(x as u32, y as u32, 77) - 0.5) * g.jitter } else { 0.0 }; + let c = gr.sample((1.0 - v + j) as f64); + ([c[0], c[1], c[2]], c[3] * (v * 8.0).min(1.0)) + } + } +} + +/// Glow intensity (0..1) from the content (`inner` glows read the inverse). +fn glow_intensity(alpha: &Plane, d: &Lazy, g: &Glow, s: f64, inner: bool) -> Plane { + let size = g.size * s; + if g.precise { + let sd = d.get(alpha); + let hard = (size * g.spread) as f32; + let soft = (size as f32 - hard).max(0.5); + return sd.map(|v| { + let v = if inner { -v } else { v }; + (1.0 - (v - hard).max(0.0) / soft).clamp(0.0, 1.0) + }); + } + soft_shape(alpha, d, size, g.spread, inner).map(|v| (v * 2.0).min(1.0)) +} + +// ------------------------------------------------------------------------------------------ +// Rendering + +/// Render the layer's styles around its processed `content` (layer size `layer_size` in layer +/// pixels, for Gradient Overlay without Align with Layer). +pub fn render(ctx: &EvalCtx, layer: &Layer, content: &Buf, layer_size: [f64; 2]) -> Styled { + let Some(g) = group(layer) else { return Styled { passes: vec![], body: content.clone() } }; + let styles = evaluate(ctx, layer, g); + let bl = blending(ctx, layer); + let s = content.scale.max(1e-6); + let pad = ((reach(&styles) * s).ceil() + 2.0).min(MAX_PAD) as u32; + let mut buf = content.clone(); + if content.img.is_empty() { + return Styled { passes: vec![], body: buf }; + } + buf.pad(pad); + let (w, h) = (buf.img.width as usize, buf.img.height as usize); + let alpha = Plane::alpha(&buf.img); + let d = Lazy(std::sync::OnceLock::new()); + let seed = layer.id.0 as u32; + + // ---- exterior passes + let mut passes = vec![]; + if let Some(ds) = &styles.drop { + let shape = soft_shape(&alpha, &d, ds.size * s, ds.spread, false); + let off = [ds.offset[0] * s, ds.offset[1] * s]; + let img = pass_image(w, h, |x, y| { + let mut a = shifted(&shape, x, y, off) * ds.opacity; + if ds.knocks_out { + a *= 1.0 - alpha.data[y * w + x]; + } + (ds.color, noisy(a, ds.noise, x, y, seed)) + }); + passes.push((Buf { img, offset: buf.offset, scale: buf.scale }, ds.mode)); + } + if let Some(gl) = &styles.outer_glow { + let v = glow_intensity(&alpha, &d, gl, s, false); + let img = pass_image(w, h, |x, y| { + let i = y * w + x; + let (c, a) = glow_color(gl, v.data[i], x, y); + // The glow sits outside the content (knocked out under it). + (c, noisy(a * gl.opacity * (1.0 - alpha.data[i]), gl.noise, x, y, seed ^ 0x5a5a)) + }); + passes.push((Buf { img, offset: buf.offset, scale: buf.scale }, gl.mode)); + } + + // ---- body + let mut body = buf.img.clone(); + if !bl.interior_group && bl.fill < 1.0 { + body.scale_alpha(bl.fill); + } + let cov = |i: usize| alpha.data[i]; + if let Some(go) = &styles.gradient { + let (cx, cy, hw, hh) = gradient_frame(go, &buf, &alpha, layer_size); + let (ux, uy) = (go.angle.to_radians().cos(), -go.angle.to_radians().sin()); + let ext = ((hw * ux).abs() + (hh * uy).abs()).max(1e-3) * go.scale; + let radius = hw.max(hh).max(1e-3) * go.scale; + blend_into(&mut body, go.mode, seed, |x, y| { + let (px, py) = (x as f64 + 0.5 - cx, y as f64 + 0.5 - cy); + let along = px * ux + py * uy; + let across = -px * uy + py * ux; + let mut t = match go.style { + 1 => (px * px + py * py).sqrt() / radius, + 2 => { + let a = (-py).atan2(px) - go.angle.to_radians(); + (a / std::f64::consts::TAU).rem_euclid(1.0) + } + 3 => along.abs() / ext, + 4 => (along.abs() + across.abs()) / ext, + _ => 0.5 + along / (2.0 * ext), + }; + if go.reverse { + t = 1.0 - t; + } + let c = go.gradient.sample(t.clamp(0.0, 1.0)); + ([c[0], c[1], c[2]], c[3] * go.opacity * cov(y * w + x)) + }); + } + if let Some(co) = &styles.color { + blend_into(&mut body, co.mode, seed, |x, y| (co.color, co.opacity * cov(y * w + x))); + } + if let Some(sa) = &styles.satin { + let b = gauss_plane(&alpha, sa.size * s / 2.0, sa.size * s / 2.0); + let off = [sa.offset[0] * s, sa.offset[1] * s]; + blend_into(&mut body, sa.mode, seed, |x, y| { + let v = (shifted(&b, x, y, off) - shifted(&b, x, y, [-off[0], -off[1]])).abs().min(1.0); + let v = if sa.invert { 1.0 - v } else { v }; + (sa.color, v * sa.opacity * cov(y * w + x)) + }); + } + if let Some(gl) = &styles.inner_glow { + let v = glow_intensity(&alpha, &d, gl, s, true); + blend_into(&mut body, gl.mode, seed, |x, y| { + let i = y * w + x; + let e = if gl.center { 1.0 - v.data[i] } else { v.data[i] }; + let (c, a) = glow_color(gl, e, x, y); + (c, noisy(a * gl.opacity * cov(i), gl.noise, x, y, seed ^ 0x3c3c)) + }); + } + if let Some(is) = &styles.inner_shadow { + let shape = soft_shape(&alpha, &d, is.size * s, is.spread, true); + let off = [is.offset[0] * s, is.offset[1] * s]; + blend_into(&mut body, is.mode, seed, |x, y| { + let a = shifted(&shape, x, y, off).min(1.0) * is.opacity * cov(y * w + x); + (is.color, noisy(a, is.noise, x, y, seed ^ 0x1234)) + }); + } + if bl.interior_group && bl.fill < 1.0 { + body.scale_alpha(bl.fill); + } + if let Some(sk) = &styles.stroke { + let sd = d.get(&alpha); + let size = (sk.size * s) as f32; + let (r_out, r_in) = match sk.position { + 1 => (0.0, size), + 2 => (size / 2.0, size / 2.0), + _ => (size, 0.0), + }; + // Inside part: blended over the body within the content. + if r_in > 0.0 { + blend_into(&mut body, sk.mode, seed, |x, y| { + let i = y * w + x; + let ring = (alpha.data[i] - grown(sd.data[i], -r_in)).max(0.0); + (sk.color, ring * sk.opacity) + }); + } + // Outside part: covers exactly what the content leaves uncovered. + if r_out > 0.0 { + body.data.par_iter_mut().enumerate().for_each(|(i, p)| { + let ring = (grown(sd.data[i], r_out) - alpha.data[i]).max(0.0) * sk.opacity; + if ring > 0.0 { + let src = [sk.color[0] * ring, sk.color[1] * ring, sk.color[2] * ring, ring]; + for c in 0..4 { + p[c] += src[c]; + } + } + }); + } + } + if let Some(bv) = &styles.bevel { + bevel(&mut body, &alpha, &d, bv, s, seed); + } + Styled { passes, body: Buf { img: body, offset: buf.offset, scale: buf.scale } } +} + +/// Gradient Overlay frame in buffer pixels: centre and half extents. +fn gradient_frame(go: &GradOverlay, buf: &Buf, alpha: &Plane, layer_size: [f64; 2]) -> (f64, f64, f64, f64) { + let s = buf.scale; + let (x0, y0, x1, y1) = if go.align { + let img = Image { width: alpha.w as u32, height: alpha.h as u32, data: alpha.data.iter().map(|&a| [0.0, 0.0, 0.0, a]).collect() }; + match img.alpha_bounds() { + Some((a, b, c, d)) => (a as f64, b as f64, c as f64, d as f64), + None => (0.0, 0.0, 1.0, 1.0), + } + } else { + let (a, b) = buf.to_px([0.0, 0.0]); + let (c, d) = buf.to_px(layer_size); + (a, b, c, d) + }; + ((x0 + x1) / 2.0 + go.offset[0] * s, (y0 + y1) / 2.0 + go.offset[1] * s, (x1 - x0) / 2.0, (y1 - y0) / 2.0) +} + +/// Bevel and Emboss: a height field from the content edge, lit by a directional light. +fn bevel(body: &mut Image, alpha: &Plane, d: &Lazy, bv: &Bevel, s: f64, seed: u32) { + let size = (bv.size * s).max(0.5) as f32; + let sd = d.get(alpha); + let w = alpha.w; + // Height profile 0..1 and the region the bevel paints. + let style = bv.style; + let mut height = sd.map(|v| match style { + 0 => (1.0 - v.max(0.0) / size).clamp(0.0, 1.0), + 2 | 4 => (0.5 - v / (2.0 * size)).clamp(0.0, 1.0), + 3 => (v.abs() / size).clamp(0.0, 1.0), + _ => (-v / size + 0.5 / size).clamp(0.0, 1.0), + }); + // The distance field is exact at pixel centres only; smoothing the profile removes the + // stair-steps on diagonal edges that lighting would otherwise amplify. Smooth rounds the + // profile over the bevel size; Chisel Hard keeps it crisp, Chisel Soft slightly softer. + let sigma = match bv.technique { + 0 => (size as f64 / 4.0).max(1.0), + 2 => 1.5, + _ => 0.75, + }; + height = gauss_plane(&height, sigma, sigma); + if bv.soften > 0.0 { + let sg = bv.soften * s / 2.0; + height = gauss_plane(&height, sg, sg); + } + let sign = if bv.down { -1.0 } else { 1.0 }; + let k = (size as f64 * bv.depth) as f32 * sign; + let (th, al) = (bv.angle.to_radians(), bv.altitude.to_radians()); + let light = [(al.cos() * th.cos()) as f32, (-al.cos() * th.sin()) as f32, al.sin() as f32]; + let flat = light[2]; + let region = |i: usize| -> f32 { + let a = alpha.data[i]; + match style { + 0 => (grown(sd.data[i], size) - a).max(0.0), + 1 => a, + _ => grown(sd.data[i], size), + } + }; + let shade = |x: usize, y: usize| -> (f32, f32) { + let hx = |xx: i64| height.get_clamped(xx, y as i64); + let hy = |yy: i64| height.get_clamped(x as i64, yy); + let gx = (hx(x as i64 + 1) - hx(x as i64 - 1)) * 0.5 * k; + let gy = (hy(y as i64 + 1) - hy(y as i64 - 1)) * 0.5 * k; + let n = [-gx, -gy, 1.0]; + let len = (n[0] * n[0] + n[1] * n[1] + 1.0).sqrt(); + let dot = (n[0] * light[0] + n[1] * light[1] + n[2] * light[2]) / len; + let hl = ((dot - flat) / (1.0 - flat).max(1e-4)).clamp(0.0, 1.0); + let shd = ((flat - dot) / flat.max(1e-4)).clamp(0.0, 1.0); + (hl, shd) + }; + blend_into(body, bv.sh.0, seed, |x, y| { + let (_, shd) = shade(x, y); + (bv.sh.1, shd * bv.sh.2 * region(y * w + x)) + }); + blend_into(body, bv.hl.0, seed, |x, y| { + let (hl, _) = shade(x, y); + (bv.hl.1, hl * bv.hl.2 * region(y * w + x)) + }); +} diff --git a/crates/render/src/tests_styles.rs b/crates/render/src/tests_styles.rs new file mode 100644 index 00000000..34bfe30c --- /dev/null +++ b/crates/render/src/tests_styles.rs @@ -0,0 +1,416 @@ +//! Layer Styles pixel tests. + +use effectcraft_color::Label; +use effectcraft_keyframe::{Gradient, Keyframe, Value}; +use effectcraft_project::build::{self, Ids}; +use effectcraft_project::styles::{self as st, GROUP}; +use effectcraft_project::{Comp, ItemId, ItemKind, Layer, LayerSource, Project, Solid}; +use effectcraft_time::{FrameRate, Tick}; + +use crate::{Image, NoFootage, RenderOpts, Renderer, render_frame}; + +const NORMAL: u32 = 0; +const MULTIPLY: u32 = 3; + +fn setup() -> (Project, ItemId, Comp) { + let mut p = Project::default(); + let comp = Comp::new(200, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + (p, cid, comp) +} + +fn solid(p: &mut Project, comp: &Comp, color: [f32; 3], w: u32, h: u32) -> Layer { + let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color, width: w, height: h, pixel_aspect: 1.0 })); + build::layer(p, comp, "Solid", LayerSource::Solid { item: sid }, (w, h), None) +} + +fn add(p: &mut Project, comp: &Comp, l: &mut Layer, style: &str) { + let mut next = p.next_id; + st::add_style(l, &mut Ids(&mut next), style, &comp.global_light, true).unwrap(); + p.next_id = next; +} + +fn set(l: &mut Layer, path: &str, v: Value) { + l.props.prop_mut(&format!("{GROUP}/{path}")).unwrap_or_else(|| panic!("no {path}")).value = v; +} + +/// A 40×40 square of `color` centred in a 200×100 comp (x 80..120, y 30..70) over an optional +/// full-frame background. +fn scene(color: [f32; 3], bg: Option<[f32; 3]>, f: impl FnOnce(&mut Project, &Comp, &mut Layer)) -> (Project, ItemId) { + let (mut p, cid, comp) = setup(); + let mut l = solid(&mut p, &comp, color, 40, 40); + f(&mut p, &comp, &mut l); + let mut layers = vec![l]; + if let Some(bg) = bg { + layers.push(solid(&mut p, &comp, bg, 200, 100)); + } + p.comp_mut(cid).unwrap().layers = layers; + (p, cid) +} + +fn render(p: &Project, cid: ItemId) -> Image { + render_frame(p, cid, Tick::ZERO, 1.0) +} + +fn near(a: f32, b: f32, tol: f32) -> bool { + (a - b).abs() <= tol +} + +#[test] +fn drop_shadow_offset_colour_and_opacity() { + let (p, cid) = scene([1.0; 3], Some([0.5; 3]), |p, c, l| { + add(p, c, l, "dropShadow"); + set(l, "dropShadow/size", Value::Scalar(0.0)); + set(l, "dropShadow/distance", Value::Scalar(10.0)); + set(l, "dropShadow/opacity", Value::Scalar(100.0)); + set(l, "dropShadow/blendMode", Value::Enum(NORMAL)); + }); + let img = render(&p, cid); + // Global light 120° → the shadow falls down-right by (5, 8.66). + assert!(near(img.get(123, 75)[0], 0.0, 0.02), "shadow: {:?}", img.get(123, 75)); + assert!(near(img.get(82, 75)[0], 0.5, 0.01), "left of the shadow stays grey"); + assert!(near(img.get(100, 50)[0], 1.0, 0.01), "layer on top"); + assert!(near(img.get(100, 20)[0], 0.5, 0.01), "above the layer"); + + // 75 % Multiply (the default mode) over grey: 0.5 × (1 − 0.75). + let (p, cid) = scene([1.0; 3], Some([0.5; 3]), |p, c, l| { + add(p, c, l, "dropShadow"); + set(l, "dropShadow/size", Value::Scalar(0.0)); + set(l, "dropShadow/distance", Value::Scalar(10.0)); + set(l, "dropShadow/color", Value::Color([0.0, 0.0, 0.0, 1.0])); + }); + let img = render(&p, cid); + assert!(near(img.get(123, 75)[0], 0.125, 0.02), "{:?}", img.get(123, 75)); + + // Coloured shadow on a transparent comp extends the layer's bounds. + let (p, cid) = scene([1.0; 3], None, |p, c, l| { + add(p, c, l, "dropShadow"); + set(l, "dropShadow/size", Value::Scalar(0.0)); + set(l, "dropShadow/distance", Value::Scalar(10.0)); + set(l, "dropShadow/opacity", Value::Scalar(50.0)); + set(l, "dropShadow/blendMode", Value::Enum(NORMAL)); + set(l, "dropShadow/color", Value::Color([0.0, 0.0, 1.0, 1.0])); + }); + let img = render(&p, cid); + let px = img.straight(123, 75); + assert!(near(px[3], 0.5, 0.02) && near(px[2], 1.0, 0.01) && px[0] < 0.01, "{px:?}"); +} + +#[test] +fn soft_shadow_falls_off() { + let (p, cid) = scene([1.0; 3], None, |p, c, l| { + add(p, c, l, "dropShadow"); + set(l, "dropShadow/size", Value::Scalar(10.0)); + set(l, "dropShadow/distance", Value::Scalar(0.0)); + set(l, "dropShadow/opacity", Value::Scalar(100.0)); + }); + let img = render(&p, cid); + let a1 = img.get(121, 50)[3]; + let a5 = img.get(125, 50)[3]; + let a20 = img.get(140, 50)[3]; + assert!(a1 > a5 && a5 > a20, "{a1} {a5} {a20}"); + assert!(a20 < 0.01); +} + +#[test] +fn stroke_positions() { + let render_stroke = |pos: u32| { + let (p, cid) = scene([1.0; 3], None, |p, c, l| { + add(p, c, l, "stroke"); + set(l, "stroke/size", Value::Scalar(4.0)); + set(l, "stroke/position", Value::Enum(pos)); + }); + render(&p, cid) + }; + let red = |px: [f32; 4]| px[0] > 0.98 && px[1] < 0.02 && px[3] > 0.98; + let white = |px: [f32; 4]| px[0] > 0.98 && px[1] > 0.98 && px[3] > 0.98; + // Outside: 4 px ring beyond the right edge (x = 120). + let o = render_stroke(0); + assert!(red(o.get(122, 50)) && red(o.get(123, 50)), "{:?}", o.get(122, 50)); + assert!(o.get(126, 50)[3] < 0.01); + assert!(white(o.get(118, 50))); + // Inside: the ring is within the layer. + let i = render_stroke(1); + assert!(red(i.get(118, 50)) && red(i.get(117, 50))); + assert!(white(i.get(110, 50))); + assert!(i.get(122, 50)[3] < 0.01); + // Centre: 2 px on each side. + let c = render_stroke(2); + assert!(red(c.get(119, 50)) && red(c.get(120, 50)) && red(c.get(121, 50))); + assert!(white(c.get(116, 50))); + assert!(c.get(124, 50)[3] < 0.01); +} + +#[test] +fn colour_overlay_blend_modes() { + let (p, cid) = scene([1.0; 3], None, |p, c, l| { + add(p, c, l, "colorOverlay"); + set(l, "colorOverlay/color", Value::Color([0.0, 0.0, 1.0, 1.0])); + set(l, "colorOverlay/opacity", Value::Scalar(50.0)); + }); + let px = render(&p, cid).get(100, 50); + assert!(near(px[0], 0.5, 0.01) && near(px[2], 1.0, 0.01), "{px:?}"); + + let (p, cid) = scene([0.5, 1.0, 1.0], None, |p, c, l| { + add(p, c, l, "colorOverlay"); + set(l, "colorOverlay/color", Value::Color([1.0, 0.5, 0.0, 1.0])); + set(l, "colorOverlay/blendMode", Value::Enum(MULTIPLY)); + }); + let img = render(&p, cid); + let px = img.get(100, 50); + assert!(near(px[0], 0.5, 0.01) && near(px[1], 0.5, 0.01) && near(px[2], 0.0, 0.01), "{px:?}"); + // Overlays stay inside the layer. + assert!(img.get(130, 50)[3] < 0.01); +} + +#[test] +fn gradient_overlay_follows_angle() { + let (p, cid) = scene([1.0; 3], None, |p, c, l| { + add(p, c, l, "gradientOverlay"); + }); + let img = render(&p, cid); + // 90°: black at the bottom, white at the top. + let top = img.get(100, 31)[0]; + let bottom = img.get(100, 68)[0]; + assert!(top > 0.9 && bottom < 0.1, "{top} {bottom}"); + let (p, cid) = scene([1.0; 3], None, |p, c, l| { + add(p, c, l, "gradientOverlay"); + set(l, "gradientOverlay/angle", Value::Scalar(0.0)); + set(l, "gradientOverlay/reverse", Value::Bool(true)); + }); + let img = render(&p, cid); + assert!(img.get(81, 50)[0] > 0.9 && img.get(118, 50)[0] < 0.1); +} + +fn bevel_scene(angle: f64) -> Image { + let (p, cid) = scene([0.5; 3], None, |p, c, l| { + add(p, c, l, "bevelEmboss"); + set(l, "bevelEmboss/size", Value::Scalar(6.0)); + set(l, "blendingOptions/globalLightAngle", Value::Scalar(angle)); + }); + render(&p, cid) +} + +#[test] +fn bevel_lights_the_side_facing_the_light() { + // Light from the upper left (120°): left and top edges brighten, right and bottom darken. + let img = bevel_scene(120.0); + let (l, r, t, b, mid) = (img.get(82, 50)[0], img.get(117, 50)[0], img.get(100, 32)[0], img.get(100, 67)[0], img.get(100, 50)[0]); + assert!(l > 0.6 && t > 0.6, "highlights {l} {t}"); + assert!(r < 0.4 && b < 0.4, "shadows {r} {b}"); + assert!(near(mid, 0.5, 0.02), "flat centre {mid}"); + // Light from the lower right (−60°) swaps them. + let img = bevel_scene(-60.0); + assert!(img.get(82, 50)[0] < 0.4 && img.get(117, 50)[0] > 0.6); +} + +#[test] +fn global_light_drives_linked_styles() { + let shadow_at = |use_global: bool| { + let (p, cid) = scene([1.0; 3], None, |p, c, l| { + add(p, c, l, "dropShadow"); + set(l, "dropShadow/size", Value::Scalar(0.0)); + set(l, "dropShadow/distance", Value::Scalar(10.0)); + set(l, "dropShadow/opacity", Value::Scalar(100.0)); + set(l, "dropShadow/useGlobalLight", Value::Bool(use_global)); + set(l, "dropShadow/angle", Value::Scalar(90.0)); + set(l, "blendingOptions/globalLightAngle", Value::Scalar(0.0)); + }); + render(&p, cid) + }; + // Global light at 0° (from the right): shadow to the left. + let g = shadow_at(true); + assert!(g.get(75, 50)[3] > 0.98 && g.get(100, 75)[3] < 0.01); + // Own angle 90° (from above): shadow below. + let o = shadow_at(false); + assert!(o.get(100, 75)[3] > 0.98 && o.get(75, 50)[3] < 0.01); +} + +#[test] +fn glows_and_inner_shadow() { + let (p, cid) = scene([0.0, 0.0, 0.0], None, |p, c, l| { + add(p, c, l, "outerGlow"); + add(p, c, l, "innerGlow"); + set(l, "outerGlow/opacity", Value::Scalar(100.0)); + set(l, "outerGlow/size", Value::Scalar(8.0)); + set(l, "innerGlow/opacity", Value::Scalar(100.0)); + set(l, "innerGlow/size", Value::Scalar(8.0)); + }); + let img = render(&p, cid); + let out_near = img.straight(121, 50); + assert!(out_near[3] > 0.6 && out_near[0] > 0.9 && out_near[2] < 0.9, "glow colour {out_near:?}"); + assert!(img.get(140, 50)[3] < 0.02, "glow fades"); + let edge = img.get(119, 50); + let centre = img.get(100, 50); + assert!(edge[0] > 0.6 && centre[0] < 0.05, "inner glow from the edge {edge:?} {centre:?}"); + + // Inner glow from the centre instead. + let (p, cid) = scene([0.0; 3], None, |p, c, l| { + add(p, c, l, "innerGlow"); + set(l, "innerGlow/source", Value::Enum(0)); + set(l, "innerGlow/opacity", Value::Scalar(100.0)); + }); + let img = render(&p, cid); + assert!(img.get(100, 50)[0] > 0.9 && img.get(119, 50)[0] < 0.6); + + let (p, cid) = scene([1.0; 3], None, |p, c, l| { + add(p, c, l, "innerShadow"); + set(l, "innerShadow/size", Value::Scalar(0.0)); + set(l, "innerShadow/opacity", Value::Scalar(100.0)); + set(l, "innerShadow/blendMode", Value::Enum(NORMAL)); + }); + let img = render(&p, cid); + // Light from the upper left: the shadow lines the top-left inside edges. + assert!(img.get(81, 50)[0] < 0.05 && img.get(100, 32)[0] < 0.05, "{:?}", img.get(81, 50)); + assert!(img.get(117, 50)[0] > 0.95 && img.get(100, 66)[0] > 0.95); + assert!(img.get(76, 50)[3] < 0.01, "inner shadow stays inside"); +} + +#[test] +fn satin_shades_inside() { + let (p, cid) = scene([1.0; 3], None, |p, c, l| add(p, c, l, "satin")); + let img = render(&p, cid); + let vals: Vec = (82..118).map(|x| img.get(x, 50)[0]).collect(); + let min = vals.iter().cloned().fold(1.0, f32::min); + assert!(min < 0.9, "satin darkens somewhere: {min}"); + assert!(img.get(130, 50)[3] < 0.01); +} + +#[test] +fn fill_opacity_knockout_and_channels() { + // Fill 0: the content disappears but the stroke stays. + let (p, cid) = scene([1.0; 3], None, |p, c, l| { + add(p, c, l, "stroke"); + set(l, "blendingOptions/advancedBlending/fillOpacity", Value::Scalar(0.0)); + }); + let img = render(&p, cid); + assert!(img.get(100, 50)[3] < 0.01 && img.get(122, 50)[0] > 0.98); + + // Interior styles ignore Fill Opacity unless blended as a group. + let overlay = |group: bool| { + let (p, cid) = scene([1.0; 3], None, |p, c, l| { + add(p, c, l, "colorOverlay"); + set(l, "blendingOptions/advancedBlending/fillOpacity", Value::Scalar(0.0)); + set(l, "blendingOptions/advancedBlending/blendInteriorAsGroup", Value::Bool(group)); + }); + render(&p, cid).get(100, 50) + }; + assert!(overlay(false)[3] > 0.98 && overlay(true)[3] < 0.01); + + // Knockout with Fill 0 punches through the background. + let (p, cid) = scene([1.0; 3], Some([0.5; 3]), |p, c, l| { + add(p, c, l, "stroke"); + set(l, "blendingOptions/advancedBlending/fillOpacity", Value::Scalar(0.0)); + set(l, "blendingOptions/advancedBlending/knockout", Value::Enum(2)); + }); + let img = render(&p, cid); + assert!(img.get(100, 50)[3] < 0.01 && img.get(150, 50)[3] > 0.99); + + // Red channel off: the white layer leaves red from below. + let (p, cid) = scene([1.0; 3], Some([0.0; 3]), |p, c, l| { + add(p, c, l, "stroke"); + set(l, "blendingOptions/advancedBlending/red", Value::Bool(false)); + }); + let px = render(&p, cid).get(100, 50); + assert!(px[0] < 0.01 && px[1] > 0.99 && px[2] > 0.99, "{px:?}"); +} + +#[test] +fn eye_switch_and_group_switch() { + let (mut p, cid) = scene([1.0; 3], None, |p, c, l| { + add(p, c, l, "stroke"); + }); + assert!(render(&p, cid).get(122, 50)[3] > 0.98); + p.comp_mut(cid).unwrap().layers[0].props.group_mut(&format!("{GROUP}/stroke")).unwrap().enabled = false; + assert!(render(&p, cid).get(122, 50)[3] < 0.01); + p.comp_mut(cid).unwrap().layers[0].props.group_mut(&format!("{GROUP}/stroke")).unwrap().enabled = true; + p.comp_mut(cid).unwrap().layers[0].props.group_mut(GROUP).unwrap().enabled = false; + assert!(render(&p, cid).get(122, 50)[3] < 0.01); +} + +#[test] +fn styles_respect_render_scale() { + let (p, cid) = scene([1.0; 3], None, |p, c, l| { + add(p, c, l, "dropShadow"); + add(p, c, l, "stroke"); + set(l, "dropShadow/size", Value::Scalar(0.0)); + set(l, "dropShadow/distance", Value::Scalar(20.0)); + set(l, "dropShadow/opacity", Value::Scalar(100.0)); + set(l, "stroke/size", Value::Scalar(4.0)); + }); + let full = render_frame(&p, cid, Tick::ZERO, 1.0); + let half = render_frame(&p, cid, Tick::ZERO, 0.5); + // Shadow offset (10, 17.3): at half resolution the shadow sits at half the distance. + assert!(full.get(125, 85)[3] > 0.98); + assert!(half.get(62, 42)[3] > 0.98 && half.get(55, 25)[3] > 0.98); + // Stroke 4 px → 2 px at half. + assert!(half.get(61, 25)[0] > 0.98 && half.get(61, 25)[1] < 0.05); + assert!(half.get(64, 25)[3] < 0.01 || half.get(64, 25)[1] < 0.05); +} + +#[test] +fn animated_style_properties() { + let (mut p, cid) = scene([1.0; 3], None, |p, c, l| add(p, c, l, "stroke")); + let l = &mut p.comp_mut(cid).unwrap().layers[0]; + l.props.prop_mut(&format!("{GROUP}/stroke/size")).unwrap().keys = + vec![Keyframe::new(Tick::ZERO, Value::Scalar(2.0)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(10.0))]; + let a = render_frame(&p, cid, Tick::ZERO, 1.0); + let b = render_frame(&p, cid, Tick::from_seconds_f64(1.0), 1.0); + assert!(a.get(126, 50)[3] < 0.01 && b.get(126, 50)[3] > 0.98); +} + +fn render_cached(p: &Project, cid: ItemId, cache: &crate::LayerCache) -> Image { + let mut r = Renderer::new(p, &NoFootage, RenderOpts::default()); + r.cache = Some(cache); + r.comp_frame(cid, Tick::ZERO) +} + +#[test] +fn style_edits_invalidate_the_cache() { + let (mut p, cid) = scene([1.0; 3], Some([0.5; 3]), |p, c, l| { + add(p, c, l, "dropShadow"); + add(p, c, l, "stroke"); + }); + let cache = crate::LayerCache::default(); + let a = render_cached(&p, cid, &cache); + assert_eq!(a.data, render(&p, cid).data); + let again = render_cached(&p, cid, &cache); + assert_eq!(a.data, again.data); + // Change a style value: the styled layer re-renders, its content (and the background) come + // from the cache. + let hits0 = cache.stats().hits; + let l = &mut p.comp_mut(cid).unwrap().layers[0]; + l.props.prop_mut(&format!("{GROUP}/stroke/color")).unwrap().value = Value::Color([0.0, 1.0, 0.0, 1.0]); + let b = render_cached(&p, cid, &cache); + assert_eq!(b.data, render(&p, cid).data); + assert_ne!(a.data, b.data); + assert!(cache.stats().hits >= hits0 + 2, "content + background reused"); + // Global light change (linked) also invalidates. + let l = &mut p.comp_mut(cid).unwrap().layers[0]; + l.props.prop_mut(&format!("{GROUP}/blendingOptions/globalLightAngle")).unwrap().value = Value::Scalar(0.0); + let c = render_cached(&p, cid, &cache); + assert_eq!(c.data, render(&p, cid).data); + assert_ne!(b.data, c.data); + // Eye switch off. + p.comp_mut(cid).unwrap().layers[0].props.group_mut(&format!("{GROUP}/dropShadow")).unwrap().enabled = false; + let d = render_cached(&p, cid, &cache); + assert_eq!(d.data, render(&p, cid).data); + assert_ne!(c.data, d.data); +} + +#[test] +fn gradient_glow_uses_gradient() { + let (p, cid) = scene([0.0; 3], None, |p, c, l| { + add(p, c, l, "outerGlow"); + set(l, "outerGlow/colorType", Value::Enum(1)); + set(l, "outerGlow/opacity", Value::Scalar(100.0)); + set( + l, + "outerGlow/colors", + Value::Gradient(Gradient { colors: vec![(0.0, [1.0, 0.0, 0.0, 1.0]), (1.0, [0.0, 0.0, 1.0, 1.0])], opacities: vec![(0.0, 1.0), (1.0, 0.0)] }), + ); + }); + let img = render(&p, cid); + let px = img.straight(121, 50); + assert!(px[0] > px[2], "near the edge the glow takes the gradient's start colour {px:?}"); +} diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 9acb4415..afaad9e5 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -27,6 +27,8 @@ enum RowKind { open: bool, has_children: bool, fx: Option, + /// Eye switch (layer styles). + eye: Option, }, Prop { uid: u64, @@ -242,6 +244,7 @@ fn build_rows(app: &EffectcraftApp, comp: &Comp) -> Vec { open: tl.open_groups.contains(&g.uid), has_children: true, fx: Some(g.enabled), + eye: None, }, }); if tl.open_groups.contains(&g.uid) { @@ -256,7 +259,14 @@ fn build_rows(app: &EffectcraftApp, comp: &Comp) -> Vec { rows.push(Row { layer: l.id, depth: 1, - kind: RowKind::Group { uid: g.uid, name: g.name.clone(), open: tl.open_groups.contains(&g.uid), has_children: true, fx: None }, + kind: RowKind::Group { + uid: g.uid, + name: g.name.clone(), + open: tl.open_groups.contains(&g.uid), + has_children: true, + fx: None, + eye: None, + }, }); if tl.open_groups.contains(&g.uid) { push_group(&mut rows, l, g, 2, &tl.open_groups); @@ -282,7 +292,7 @@ fn build_rows(app: &EffectcraftApp, comp: &Comp) -> Vec { rows.push(Row { layer: l.id, depth: 1, - kind: RowKind::Group { uid: g.uid, name: g.name.clone(), open, has_children: !g.children.is_empty(), fx: None }, + kind: RowKind::Group { uid: g.uid, name: g.name.clone(), open, has_children: !g.children.is_empty(), fx: None, eye: None }, }); if open { push_group(&mut rows, l, g, 2, &tl.open_groups); @@ -320,7 +330,14 @@ fn build_rows(app: &EffectcraftApp, comp: &Comp) -> Vec { rows.push(Row { layer: l.id, depth: 1, - kind: RowKind::Group { uid: g.uid, name: g.name.clone(), open, has_children: !g.children.is_empty(), fx: None }, + kind: RowKind::Group { + uid: g.uid, + name: g.name.clone(), + open, + has_children: !g.children.is_empty(), + fx: None, + eye: (g.match_id == effectcraft_engine::project::styles::GROUP).then_some(g.enabled), + }, }); if open { push_group(&mut rows, l, g, 2, &tl.open_groups); @@ -350,10 +367,13 @@ fn push_group(rows: &mut Vec, l: &Layer, g: &PropGroup, depth: usize, open: Node::Group(sg) => { let o = open.contains(&sg.uid); let fx = matches!(sg.kind, GroupKind::Effect { .. }).then_some(sg.enabled); + // Layer style groups have eye switches (not Blending Options). + let eye = (g.match_id == effectcraft_engine::project::styles::GROUP && sg.match_id != effectcraft_engine::project::styles::BLENDING) + .then_some(sg.enabled); rows.push(Row { layer: l.id, depth, - kind: RowKind::Group { uid: sg.uid, name: sg.name.clone(), open: o, has_children: !sg.children.is_empty(), fx }, + kind: RowKind::Group { uid: sg.uid, name: sg.name.clone(), open: o, has_children: !sg.children.is_empty(), fx, eye }, }); if o { push_group(rows, l, sg, depth + 1, open); @@ -944,7 +964,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } } - RowKind::Group { uid, name, open, has_children, fx } => { + RowKind::Group { uid, name, open, has_children, fx, eye } => { lp.rect_filled(left, 0.0, if ri % 2 == 0 { t.row } else { t.row_alt }); gp.rect_filled(Rect::from_min_max(pos2(graph_x0, r.min.y), r.max), 0.0, t.tl_bg); let indent = cw.name + 6.0 + 14.0 * row.depth as f32; @@ -960,6 +980,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if widgets::icon_toggle(ui, fr_, Icon::Fx, *en, &t, egui::Id::new(("gfx", uid)), None).clicked() { actions.push(("effect.toggle".into(), json!({"layer": layer.id.0, "effect": uid}))); } + app.auto.add(&format!("timeline.group.{uid}.fx"), fr_, name); + } + if let Some(en) = eye { + // Layer style eye switch in the A/V column, like AE. + let er = Rect::from_center_size(pos2(cw.av + 10.0, cy), vec2(17.0, 17.0)); + lp.rect_stroke(er.shrink(1.5), 2.0, Stroke::new(1.0, t.separator), StrokeKind::Inside); + if widgets::icon_toggle(ui, er, Icon::Eye, *en, &t, egui::Id::new(("geye", uid)), None).clicked() { + actions.push(("layer.style.toggle".into(), json!({"layer": layer.id.0, "style": uid}))); + } + app.auto.add(&format!("timeline.group.{uid}.eye"), er, name); } lp.text(pos2(indent + 10.0, cy), Align2::LEFT_CENTER, name, Tokens::ui(12.0), t.text); // Mask mode + inverted inline. diff --git a/docs/architecture.md b/docs/architecture.md index 4c8646b3..ee0b7876 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -61,7 +61,7 @@ text animators and shape contents are groups in the same tree. `Renderer::comp_frame(comp, t)` walks the layers bottom to top. For each visible layer it renders the **source** (solid, footage frame, text, shape contents, or a nested comp), applies **masks**, -then **effects** in order, then the **transform** into comp space (with motion blur sub-samples +then **effects** in order, then **layer styles**, then the **transform** into comp space (with motion blur sub-samples when enabled), the **track matte**, and finally **blends** into the accumulator. Adjustment layers apply their effects to the accumulator. Runs of 3D layers are composited per pixel through the active camera, with lights, shadows and depth of field. @@ -71,6 +71,14 @@ its evaluated inputs, excluding the transform. Static and transform-only layers editing one layer re-renders only that layer. Effects that read the clock directly are declared in `effects::TIME_DEPENDENT`, and a test checks every registered effect against that list. +**Layer styles** (Layer ▸ Layer Styles; `crates/render/src/styles.rs`) render in layer space and +may grow the layer's bounds. Drop Shadow and Outer Glow become separate passes composited below the +layer with their own blend modes; the interior styles, Stroke and Bevel and Emboss are baked into +the layer body. Layer opacity fades the whole stack; Knockout and the R/G/B channel switches apply +at composite time. Styled pixels are cached separately from the layer content, so editing a style +reuses the cached source/masks/effects. Global Light is one setting per comp, mirrored into every +layer's Blending Options and kept in step after each edit. + Half, Third and Quarter resolution render proportionally fewer pixels end to end. ## 5. Expressions diff --git a/docs/images/effectcraft-3d.png b/docs/images/effectcraft-3d.png new file mode 100644 index 00000000..4f0d003a Binary files /dev/null and b/docs/images/effectcraft-3d.png differ diff --git a/docs/images/effectcraft-3d.png.attribution b/docs/images/effectcraft-3d.png.attribution new file mode 100644 index 00000000..10fd52a0 --- /dev/null +++ b/docs/images/effectcraft-3d.png.attribution @@ -0,0 +1,8 @@ +asset: effectcraft-3d.png +title: EffectCraft Classic 3D +author: EffectCraft contributors +source: original work (headless render of the EffectCraft UI via crates/ui-egui/examples/snapshot.rs) +license: MIT OR Apache-2.0 +license-file: +added: 2026-10-01 by Claude (agent) for Brandon Thomas +notes: Screenshot of EffectCraft only, showing procedurally generated demo content from crates/engine/src/demo.rs and built-in effects. EffectCraft Classic 3D: the demo "3D Showcase" composition in Custom View 1, showing intersecting 3D planes, spot-light shadows, the camera frustum and light wireframes. diff --git a/docs/images/effectcraft-effects.png b/docs/images/effectcraft-effects.png new file mode 100644 index 00000000..02eadb0d Binary files /dev/null and b/docs/images/effectcraft-effects.png differ diff --git a/docs/images/effectcraft-effects.png.attribution b/docs/images/effectcraft-effects.png.attribution new file mode 100644 index 00000000..ed8548e4 --- /dev/null +++ b/docs/images/effectcraft-effects.png.attribution @@ -0,0 +1,8 @@ +asset: effectcraft-effects.png +title: EffectCraft effects +author: EffectCraft contributors +source: original work (headless render of the EffectCraft UI via crates/ui-egui/examples/snapshot.rs) +license: MIT OR Apache-2.0 +license-file: +added: 2026-10-01 by Claude (agent) for Brandon Thomas +notes: Screenshot of EffectCraft only, showing procedurally generated demo content from crates/engine/src/demo.rs and built-in effects. EffectCraft effects: a CC Particle World particle burst with Glow on a black solid, with the Effect Controls panel listing its parameters. diff --git a/docs/images/effectcraft-graph-editor.png b/docs/images/effectcraft-graph-editor.png new file mode 100644 index 00000000..769103af Binary files /dev/null and b/docs/images/effectcraft-graph-editor.png differ diff --git a/docs/images/effectcraft-graph-editor.png.attribution b/docs/images/effectcraft-graph-editor.png.attribution new file mode 100644 index 00000000..1f1f94f1 --- /dev/null +++ b/docs/images/effectcraft-graph-editor.png.attribution @@ -0,0 +1,8 @@ +asset: effectcraft-graph-editor.png +title: EffectCraft Graph Editor +author: EffectCraft contributors +source: original work (headless render of the EffectCraft UI via crates/ui-egui/examples/snapshot.rs) +license: MIT OR Apache-2.0 +license-file: +added: 2026-10-01 by Claude (agent) for Brandon Thomas +notes: Screenshot of EffectCraft only, showing procedurally generated demo content from crates/engine/src/demo.rs and built-in effects. EffectCraft Graph Editor: the value graph of the EFFECTCRAFT text animator Range Start keyframes with easing, and the graph editor button bar. diff --git a/docs/images/effectcraft-hero.png b/docs/images/effectcraft-hero.png new file mode 100644 index 00000000..b260bf93 Binary files /dev/null and b/docs/images/effectcraft-hero.png differ diff --git a/docs/images/effectcraft-hero.png.attribution b/docs/images/effectcraft-hero.png.attribution new file mode 100644 index 00000000..2cdca513 --- /dev/null +++ b/docs/images/effectcraft-hero.png.attribution @@ -0,0 +1,8 @@ +asset: effectcraft-hero.png +title: EffectCraft main window +author: EffectCraft contributors +source: original work (headless render of the EffectCraft UI via crates/ui-egui/examples/snapshot.rs) +license: MIT OR Apache-2.0 +license-file: +added: 2026-10-01 by Claude (agent) for Brandon Thomas +notes: Screenshot of EffectCraft only, showing procedurally generated demo content from crates/engine/src/demo.rs and built-in effects. EffectCraft main window: the demo composition "EffectCraft Intro" at 0:00:03:00 with the EFFECTCRAFT text layer selected, Properties panel (Layer Transform, Text, Paragraph, Text Animation), Project panel and Timeline. diff --git a/docs/images/effectcraft-render-queue.png b/docs/images/effectcraft-render-queue.png new file mode 100644 index 00000000..f77e782a Binary files /dev/null and b/docs/images/effectcraft-render-queue.png differ diff --git a/docs/images/effectcraft-render-queue.png.attribution b/docs/images/effectcraft-render-queue.png.attribution new file mode 100644 index 00000000..979571eb --- /dev/null +++ b/docs/images/effectcraft-render-queue.png.attribution @@ -0,0 +1,8 @@ +asset: effectcraft-render-queue.png +title: EffectCraft Render Queue panel with the demo compositions queued for H.264 and ProRes export +author: EffectCraft contributors +source: original work (headless render of the EffectCraft UI via crates/ui-egui/examples/snapshot.rs) +license: MIT OR Apache-2.0 +license-file: +added: 2026-10-01 by Claude (agent) for Brandon Thomas +notes: Screenshot of EffectCraft only, showing procedurally generated demo content from crates/engine/src/demo.rs and built-in effects. EffectCraft Render Queue panel with the demo compositions queued for H.264 and ProRes export.