M0.1–M0.7: workspace, time/geom/color/keyframe/project/raster/path/text/effects/render crates

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-01 14:05:20 -04:00
1 parent 9f19158a59
commit 808cd490cd
76 files changed
+12319 -3

No files matched your search

+5
View File
@@ -1,2 +1,7 @@
*~
plan/
/target
/dist
/log
.DS_Store
.claude/worktrees/
+76
View File
@@ -0,0 +1,76 @@
# EffectCraft — rules for agents and contributors
These rules apply to every human and AI contributor. `CLAUDE.md` holds the working instructions; this
file holds the rules that must never be broken. When the two disagree, this file wins.
## 1. Assets: no Adobe artwork, every asset licensed and attributed
This rule is absolute. Breaking it is the most serious mistake a contributor can make on this project.
**What counts as an asset:** any image, icon, cursor, logo, illustration, screenshot, font, colour LUT,
preset, template, sound, music, video, 3D model, or other non-code media. This covers files in the
repository, bytes embedded in code (`include_bytes!`, base64, data URIs), and data hard-coded to
reproduce an image (for example, point lists traced from someone else's icon).
1. **Never use Adobe iconography, images or other Adobe assets**, in any form:
- no icons, cursors, logos, splash screens, UI artwork or screenshots from any Adobe product;
- no Adobe fonts, LUTs, presets, templates, sound effects, stock media or sample projects;
- no traced, redrawn, recoloured or "inspired-by" copies of Adobe icons. Our icons may follow generic
conventions (a play triangle, a razor blade, a stopwatch), but each must be drawn from scratch
without reference to Adobe's artwork.
2. **Every asset must be open**: open-source licensed (MIT, Apache-2.0, BSD, ISC, zlib, SIL OFL…),
public domain / CC0, or Creative Commons (CC BY or CC BY-SA; no NC/ND licences), **or** created by a
contributor who owns it and licenses it to the project under the project licence (MIT OR Apache-2.0).
If the licence is unknown or unclear, the asset does not go in.
3. **Every asset needs an attribution sidecar.** Next to each asset file `X`, add `X.attribution` with:
```text
asset: <file name>
title: <what it is>
author: <creator / copyright holder>
source: <URL, or "original work" for contributor-made assets>
license: <SPDX id or licence name>
license-file: <path to the licence text, if the licence requires shipping it>
added: <YYYY-MM-DD> by <contributor>
notes: <how it was made or modified; for screenshots, what is shown>
```
Also add one line for the asset to [`ATTRIBUTION.md`](ATTRIBUTION.md). `cargo xtask assets` (part of
`cargo xtask ci`) fails if any asset lacks a sidecar or index entry.
4. **Screenshots** in the repo may show only EffectCraft (or other open projects), with media we generated
or media that is itself openly licensed. Never commit screenshots of Adobe products.
5. **Local reference material stays local.** After Effects reference screenshots and notes live only in
`plan/aftereffects/` (gitignored). They must never be committed, bundled, embedded, traced or shipped.
6. **Code-drawn assets** (icons in `crates/ui-egui/src/icons.rs`, procedural demo content in
`crates/engine/src/demo.rs`, generators in `crates/effects`) are original work under the project licence
and are listed in `ATTRIBUTION.md`.
7. **When in doubt, leave it out** and draw or generate it yourself.
## 2. Clean-room code
- Never read, disassemble or copy anything inside Adobe application bundles; file names and listings only.
Observe behaviour by using the app; never capture its Home screen, recent projects, account info or
file browsers.
- Never copy GPL/LGPL/AGPL code (FFmpeg, Natron, Blender, Glaxnimate, Synfig, Friction, Olive, MLT, frei0r, G'MIC…).
- Never read or copy Adobe expression/scripting implementations, `.ffx` presets or effect plug-ins; the public Expression Language Reference and Scripting Guide describe *behaviour* only.
- Implement codecs and formats from public specifications (ITU-T/ISO/IEC standards, IETF RFCs, the VP9
and AV1 bitstream specs, SMPTE documents, published container specs). Do not read the source of
reference or third-party decoders/encoders while implementing a format, even permissively licensed
ones (libvpx, libopus, libaom, dav1d, openh264…); spec text and conformance vectors only. Record the
spec edition used in the crate README.
- ffmpeg/ffprobe may be used only as external test oracles and fixture generators. They are never
linked, bundled or shipped.
- Dependencies must use permissive licences: MIT/Apache-2.0/BSD/ISC/Zlib/Unicode/CC0/BSL-1.0, or
MPL-2.0 used unmodified.
## 3. Engineering rules
See `CLAUDE.md`: pure Rust, dependency layering (`cargo xtask layers`), exact `Tick` time, everything is
a command, everything is agent-drivable, and the quality gates (`cargo xtask ci`) before every commit.
## See also
- [CONTRIBUTING.md](CONTRIBUTING.md) and [docs/contributing.md](docs/contributing.md): setup, gates, commits, how to add things
- [docs/architecture.md](docs/architecture.md): layers, data model, commands, pipeline
- [docs/testing.md](docs/testing.md): oracle tests, criteria, benchmarks
- [docs/agents.md](docs/agents.md): driving EffectCraft over MCP / the control channel, and the agent work loop
- [docs/control-protocol.md](docs/control-protocol.md): control-channel method reference
- [ATTRIBUTION.md](ATTRIBUTION.md): asset index
+23
View File
@@ -0,0 +1,23 @@
# Attribution
Every non-code asset in EffectCraft, with its author, source and licence. Each file listed here also has
a `<file>.attribution` sidecar with full details. The rules are in [AGENTS.md](AGENTS.md) §1: no Adobe
iconography, images or other Adobe assets; open licences only; every asset attributed.
`cargo xtask assets` checks that this index and the sidecars cover every asset in the repository.
## Asset files
| Asset | Author | Source | Licence |
|---|---|---|---|
| `assets/fonts/Inter-Regular.ttf` | The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) |
| `assets/fonts/Inter-Medium.ttf` | The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) |
| `assets/fonts/Inter-SemiBold.ttf` | The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) |
| `assets/fonts/Inter-Bold.ttf` | The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) |
| `assets/fonts/Inter-Italic.ttf` | The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) |
| `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`) |
## Code-drawn assets (original work, MIT OR Apache-2.0)
- UI icons drawn in code: `crates/ui-egui/src/icons.rs`.
- Procedural demo content (solids, shapes, gradients, text animations): `crates/engine/src/demo.rs`.
+27
View File
@@ -0,0 +1,27 @@
# EffectCraft — instructions for agents
EffectCraft is a clean-room, open-source, pure-Rust motion graphics and visual effects compositor targeting Adobe After Effects parity (and beyond). Native on macOS, Windows, Linux; web via WASM. Sibling of `../photocraft` (Photoshop), `../printcraft` (Acrobat), `../drawcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom) and `../designcraft` (InDesign), with the same conventions.
## Start every session here
`plan/` is maintainer-local (gitignored). Public equivalents: [`ROADMAP.md`](ROADMAP.md), [`docs/`](docs/).
1. Read `plan/STATUS.md` (current milestone, next task, running agents, blockers).
2. Read the task in `plan/execution-plan.md` §3, the relevant section of `plan/architecture.md`, and the crate README/docs you touch. Feature ids: `plan/aftereffects/feature-catalog.md`; effects: `plan/aftereffects/effects-list.md`; UI reference: `plan/aftereffects/README.md`.
3. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask.
## Non-negotiables
**Read [`AGENTS.md`](AGENTS.md) first; its rules override everything here.** No Adobe assets, every asset licensed + attributed (`cargo xtask assets`).
- **Clean-room.** Behaviour and public docs only. No GPL/LGPL/AGPL code. ffmpeg only as an external test oracle.
- **Pure Rust.** Media codecs come from FilmCraft crates (git deps behind `crates/media`, see `plan/adr/0001`).
- **Layering** (`cargo xtask layers`): nothing below L5 depends on egui/eframe/winit/rfd/cpal; L0–L4 build for wasm32.
- **Exact time:** `effectcraft_time::Tick` (254 016 000 000/s). Keyframe times are **layer time**.
- **Property tree:** everything animatable is a `Property` in the layer's `PropGroup` tree, addressed by paths (`transform/position`, `effects/#1/blurriness`, `@uid`).
- **Everything is a command** (`crates/engine`): id, label, menu path, shortcut, params, enabled(), run(). UI, CLI, control channel and MCP dispatch by id.
- **Everything is agent-drivable:** every interactive widget registers an automation id; UI state is serde.
- **Community links** (Help menu, About, header Discord button): https://discord.gg/artcraft, https://getartcraft.com, https://getartcraft.com/apps/effectcraft, https://github.com/storytold/effectcraft.
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, layers, assets, wasm).
- **Commits:** one task id per commit (`M6.3: trim paths`). Only green states.
## Running and looking at the app
- `cargo run -p effectcraft -- --control 9877` opens the desktop app with the JSON-lines control server (`docs/control-protocol.md`).
- For UI work, **look at the result**: drive via the control channel and take `ui.screenshot`; or headless: `cargo run -p effectcraft-cli -- snapshot out.png`.
- Parallel agents: separate git worktrees and `CARGO_TARGET_DIR=target/agent-<name>`; keep every `Cargo.toml` valid (the `crates/*` glob).
Generated
+753
View File
@@ -0,0 +1,753 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "arrayref"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
[[package]]
name = "arrayvec"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "bit-set"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
dependencies = [
"bit-vec",
]
[[package]]
name = "bit-vec"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "bytemuck"
version = "1.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
dependencies = [
"bytemuck_derive",
]
[[package]]
name = "bytemuck_derive"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "cfg-if"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "crossbeam-deque"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
[[package]]
name = "effectcraft-color"
version = "0.1.0"
dependencies = [
"proptest",
"serde",
]
[[package]]
name = "effectcraft-effects"
version = "0.1.0"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
"effectcraft-keyframe",
"effectcraft-project",
"effectcraft-raster",
"rayon",
]
[[package]]
name = "effectcraft-geom"
version = "0.1.0"
dependencies = [
"proptest",
"serde",
]
[[package]]
name = "effectcraft-keyframe"
version = "0.1.0"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
"effectcraft-time",
"proptest",
"serde",
"serde_json",
]
[[package]]
name = "effectcraft-path"
version = "0.1.0"
dependencies = [
"effectcraft-geom",
"effectcraft-keyframe",
"effectcraft-raster",
"kurbo",
"tiny-skia",
]
[[package]]
name = "effectcraft-project"
version = "0.1.0"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
"effectcraft-keyframe",
"effectcraft-time",
"serde",
"serde_json",
"thiserror",
]
[[package]]
name = "effectcraft-raster"
version = "0.1.0"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
"rayon",
]
[[package]]
name = "effectcraft-render"
version = "0.1.0"
dependencies = [
"effectcraft-color",
"effectcraft-effects",
"effectcraft-geom",
"effectcraft-keyframe",
"effectcraft-path",
"effectcraft-project",
"effectcraft-raster",
"effectcraft-text",
"effectcraft-time",
"kurbo",
"log",
"rayon",
]
[[package]]
name = "effectcraft-text"
version = "0.1.0"
dependencies = [
"effectcraft-keyframe",
"harfrust",
"kurbo",
"skrifa",
"unicode-bidi",
"unicode-linebreak",
]
[[package]]
name = "effectcraft-time"
version = "0.1.0"
dependencies = [
"proptest",
"serde",
]
[[package]]
name = "either"
version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "euclid"
version = "0.22.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06"
dependencies = [
"num-traits",
]
[[package]]
name = "fastrand"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "font-types"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8eb065f3251655b3c90e22e5e363f310fc5332fb3402e37bbc94752283248f6"
dependencies = [
"bytemuck",
]
[[package]]
name = "getrandom"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"libc",
"r-efi 5.3.0",
"wasip2",
]
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi 6.0.0",
]
[[package]]
name = "harfrust"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9"
dependencies = [
"bitflags",
"bytemuck",
"read-fonts",
"smallvec",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "kurbo"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2"
dependencies = [
"arrayvec",
"euclid",
"polycool",
"serde",
"smallvec",
]
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "log"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "polycool"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6"
dependencies = [
"arrayvec",
]
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "proptest"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
dependencies = [
"bit-set",
"bit-vec",
"bitflags",
"num-traits",
"rand",
"rand_chacha",
"rand_xorshift",
"regex-syntax",
"rusty-fork",
"tempfile",
"unarray",
]
[[package]]
name = "quick-error"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
dependencies = [
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rand_xorshift"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
dependencies = [
"rand_core",
]
[[package]]
name = "rayon"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]]
name = "read-fonts"
version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709"
dependencies = [
"bytemuck",
"font-types",
"once_cell",
]
[[package]]
name = "regex-syntax"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "rustix"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys",
]
[[package]]
name = "rusty-fork"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2"
dependencies = [
"fnv",
"quick-error",
"tempfile",
"wait-timeout",
]
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "serde_json"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "skrifa"
version = "0.44.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68"
dependencies = [
"bytemuck",
"read-fonts",
]
[[package]]
name = "smallvec"
version = "1.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
dependencies = [
"serde",
]
[[package]]
name = "strict-num"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys",
]
[[package]]
name = "thiserror"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "tiny-skia"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea"
dependencies = [
"arrayref",
"arrayvec",
"bytemuck",
"cfg-if",
"log",
"tiny-skia-path",
]
[[package]]
name = "tiny-skia-path"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9"
dependencies = [
"arrayref",
"bytemuck",
"strict-num",
]
[[package]]
name = "unarray"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
[[package]]
name = "unicode-bidi"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
[[package]]
name = "unicode-ident"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "unicode-linebreak"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
[[package]]
name = "wait-timeout"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
dependencies = [
"libc",
]
[[package]]
name = "wasip2"
version = "1.0.4+wasi-0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "zerocopy"
version = "0.8.59"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.59"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+81
View File
@@ -0,0 +1,81 @@
[workspace]
resolver = "3"
members = ["crates/*"]
default-members = ["crates/*"]
[workspace.package]
version = "0.1.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
repository = "https://github.com/storytold/effectcraft"
[workspace.dependencies]
# internal
effectcraft-time = { path = "crates/time" }
effectcraft-geom = { path = "crates/geom" }
effectcraft-color = { path = "crates/color" }
effectcraft-raster = { path = "crates/raster" }
effectcraft-path = { path = "crates/path" }
effectcraft-keyframe = { path = "crates/keyframe" }
effectcraft-project = { path = "crates/project" }
effectcraft-text = { path = "crates/text" }
effectcraft-effects = { path = "crates/effects" }
effectcraft-render = { path = "crates/render" }
effectcraft-engine = { path = "crates/engine" }
effectcraft-ui-egui = { path = "crates/ui-egui" }
effectcraft-automation = { path = "crates/automation" }
# foundation
thiserror = "2"
serde = { version = "1", features = ["derive", "rc"] }
serde_json = "1"
log = "0.4"
rayon = "1"
proptest = "1"
# geometry / fonts
kurbo = { version = "0.13", features = ["serde"] }
tiny-skia = { version = "0.12", default-features = false, features = ["std", "simd"] }
skrifa = { version = "0.44", default-features = false, features = ["std"] }
harfrust = { version = "0.12", default-features = false, features = ["std"] }
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
# ui
egui = "0.36"
eframe = { version = "0.36", default-features = false }
egui_kittest = "0.36"
rfd = "0.17"
[workspace.lints.rust]
unsafe_code = "deny"
[workspace.lints.clippy]
dbg_macro = "warn"
todo = "warn"
too_many_arguments = "allow"
needless_range_loop = "allow"
type_complexity = "allow"
[profile.dev.package."*"]
opt-level = 2
[profile.dev]
opt-level = 1
# Pixel hot loops: keep debug builds interactive.
[profile.dev.package.effectcraft-raster]
opt-level = 3
[profile.dev.package.effectcraft-render]
opt-level = 3
[profile.dev.package.effectcraft-effects]
opt-level = 3
[profile.dev.package.effectcraft-path]
opt-level = 3
[profile.release]
lto = "thin"
codegen-units = 1
+176
View File
@@ -0,0 +1,176 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 The EffectCraft contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+215 -3
View File
@@ -1,4 +1,216 @@
EffectCraft
===========
<p align="center">
<a href="https://getartcraft.com/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/brand/artcraft-logo-white.svg">
<img alt="ArtCraft" src="docs/brand/artcraft-logo.svg" width="200">
</picture>
</a>
</p>
By ArtCraft
<h1 align="center">EffectCraft</h1>
<p align="center">
<b>Motion graphics and visual effects, in pure Rust.</b>
</p>
<p align="center">
A free, open-source compositor in the spirit of After Effects: compositions, layers,
keyframes, effects and expressions, native on macOS, Windows and Linux, and in the browser later on.
It is in early development. The engine crates are being written now and there is no app to run yet.
</p>
<p align="center">
<img alt="Status: early development" src="https://img.shields.io/badge/status-early%20development-e0368f?style=flat-square">
<img alt="Written in Rust" src="https://img.shields.io/badge/rust-1.95%2B-b0206c?style=flat-square&logo=rust&logoColor=white">
<img alt="License: MIT or Apache-2.0" src="https://img.shields.io/badge/license-MIT%20%2F%20Apache--2.0-555?style=flat-square">
</p>
<p align="center">
<a href="https://discord.gg/artcraft"><img alt="Join the ArtCraft community on Discord" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
</p>
<p align="center">
<a href="https://getartcraft.com/apps/effectcraft"><b>EffectCraft on getartcraft.com</b></a> ·
<a href="https://getartcraft.com/">ArtCraft</a> ·
<a href="https://getartcraft.com/apps">All Crafting Apps</a>
</p>
> [!NOTE]
> **ArtCraft is a community of artists from all walks of life.** Painters, photographers,
> filmmakers, illustrators, designers, animators, hobbyists, and people who picked up a pencil
> last week. If you make things, you're one of us. **[Come say hi on Discord](https://discord.gg/artcraft).**
<p align="center">
<a href="#what-effectcraft-is">What it is</a> ·
<a href="#where-it-stands">Where it stands</a> ·
<a href="#the-plan">The plan</a> ·
<a href="#building-from-source">Building</a> ·
<a href="#how-its-made">How it's made</a> ·
<a href="#the-crafting-apps">The Crafting Apps</a> ·
<a href="#license-and-credits">License</a>
</p>
## What EffectCraft is
EffectCraft is for animated titles, motion graphics and compositing work: the kind of thing
people reach for After Effects to do. You build a composition out of layers (solids, shapes,
text, footage, other compositions), animate their properties with keyframes, stack effects on
them and render the result.
The aim is to feel familiar to anyone who has used After Effects, with the same panels (Project,
Composition, Timeline, Effect Controls, Effects & Presets) and the same keyframe behaviour, and
then to go further in a few places where it matters to us:
- **Lottie import and export built in**, so animations can go straight to the web and apps
without a plugin.
- **A project file you can read.** Projects are versioned JSON (`.ecproj`), so they diff cleanly
in version control.
- **Everything is scriptable.** Every menu item, timeline drag and property edit goes through one
command registry, so the same actions are reachable from a command line, a JSON control
channel and an MCP server for agents.
- **No FFmpeg.** Video and audio decoding and encoding come from FilmCraft's own pure-Rust
codecs.
## Where it stands
> [!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.
| | 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 |
The live checklist is [`plan/STATUS.md`](plan/STATUS.md).
### The crates so far
| 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 |
These are moving fast. Expect names and APIs to change.
## The plan
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.
| | Milestone | What you'll get |
|---|---|---|
| **M0** | Skeleton and visual shell | The full panel layout over a real CPU compositor playing an animated demo composition, about 8 effects, and the control channel and MCP server |
| M1 | Keyframes | Complete temporal and spatial interpolation, Easy Ease, roving keys, velocity |
| M2 | Compositing | All 38 blend modes, track mattes, parenting, 8/16/32 bit per channel behaviour |
| M3 | Project operations | Composition and layer settings, layer commands, save and open `.ecproj`, undo, clipboard |
| M4 | Preview and precomps | Nested compositions, adjustment layers, motion blur, cached RAM preview with audio |
| M5 | Timeline depth | Keyframe editing, the graph editor, markers, trims, time remapping |
| M6 | Shapes, masks and footage | Shape layers and path operations, masks, the pen tool, video and image import |
| M7 | 3D | 3D layers, cameras and lights |
| M8 | Expressions | JavaScript expressions with the After Effects object model (`wiggle`, `loopOut`, `thisComp` and friends) |
| M9 | Text and effects | Text animators, text on a path, layer styles, the first 60 effects |
| M10 | Export | Render queue, image sequences, H.264 and ProRes, GIF, command-line rendering |
| M11 | Animation tools | Motion Sketch, Wiggler, presets, Lottie import and export, audio |
| M12 | Performance | GPU compositing and effects, disk cache, motion tracking |
| M13 to M16 | Beyond | More effects, puppet and paint tools, polish, the web app, a plugin API |
The details, with an acceptance test for every task, are in
[`plan/execution-plan.md`](plan/execution-plan.md) and [`plan/architecture.md`](plan/architecture.md).
## Building from source
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:
```sh
git clone https://github.com/storytold/effectcraft
cd effectcraft
cargo build
cargo test
```
To work on one crate:
```sh
cargo test -p effectcraft-keyframe
```
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.
## How it's made
- **Engine first.** A stack of small crates with strict layering. The interface (egui) sits on
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).
- **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/)).
## The Crafting Apps
EffectCraft is one of the **Crafting Apps**: free, open-source creative tools from the
[ArtCraft](https://getartcraft.com/) team, each written from scratch in Rust and each able to
stand on its own.
| App | What it's for | Code | Learn more |
|---|---|---|---|
| <img src="https://img.shields.io/badge/PhotoCraft-2f7bf5?style=for-the-badge" alt="PhotoCraft" height="24"> | Image editing: layers, masks, type and real PSD files | [GitHub](https://github.com/storytold/photocraft) | [getartcraft.com](https://getartcraft.com/apps/photocraft) |
| <img src="https://img.shields.io/badge/VectorCraft-e8573f?style=for-the-badge" alt="VectorCraft" height="24"> | Vector illustration (formerly DrawCraft) | [GitHub](https://github.com/storytold/vectorcraft) | [getartcraft.com](https://getartcraft.com/apps/drawcraft) |
| <img src="https://img.shields.io/badge/FilmCraft-8b5cf6?style=for-the-badge" alt="FilmCraft" height="24"> | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [getartcraft.com](https://getartcraft.com/apps/filmcraft) |
| <img src="https://img.shields.io/badge/LightCraft-f2a516?style=for-the-badge" alt="LightCraft" height="24"> | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [getartcraft.com](https://getartcraft.com/apps/lightcraft) |
| <img src="https://img.shields.io/badge/PrintCraft-12a58a?style=for-the-badge" alt="PrintCraft" height="24"> | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/printcraft) | [getartcraft.com](https://getartcraft.com/apps/printcraft) |
| <img src="https://img.shields.io/badge/EffectCraft-e0368f?style=for-the-badge" alt="EffectCraft" height="24"> | **Motion graphics and visual effects** · **you are here** | [GitHub](https://github.com/storytold/effectcraft) | [getartcraft.com](https://getartcraft.com/apps/effectcraft) |
| <img src="https://img.shields.io/badge/DesignCraft-7bb51c?style=for-the-badge" alt="DesignCraft" height="24"> | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [getartcraft.com](https://getartcraft.com/apps/designcraft) |
And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
<br>
<p align="center">
<a href="https://discord.gg/artcraft"><img alt="Join the ArtCraft community on Discord" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
</p>
<h3 align="center">Come make things with us</h3>
<p align="center">
Our Discord is where artists of every kind hang out: people who paint, shoot, draw, cut film,
set type, and people still figuring out what they like to make. Share what you're working on,
ask for help, tell us what's broken, or tell us what you wish these tools could do.
Whatever your medium and however long you've been at it, you're welcome here.
</p>
<p align="center">
<a href="https://discord.gg/artcraft"><b>discord.gg/artcraft</b></a> ·
<a href="https://getartcraft.com/">getartcraft.com</a> ·
<a href="https://getartcraft.com/apps">The Crafting Apps</a> ·
<a href="https://getartcraft.com/apps/effectcraft">EffectCraft</a>
</p>
## License and credits
EffectCraft is dual-licensed under [MIT](LICENSE-MIT) or [Apache 2.0](LICENSE-APACHE), at your
option. Bundled fonts and other non-code assets are listed with their authors and licenses in
[ATTRIBUTION.md](ATTRIBUTION.md).
After Effects is a trademark of Adobe. EffectCraft is an independent project and is not
affiliated with or endorsed by Adobe.
<p align="center">
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
</p>
Binary file not shown.
+8
View File
@@ -0,0 +1,8 @@
asset: Inter-Bold.ttf
title: Inter Bold (text engine / graphics)
author: The Inter Project Authors (Rasmus Andersson et al.)
source: https://github.com/rsms/inter (release v4.1, extras/ttf)
license: OFL-1.1
license-file: assets/fonts/OFL-Inter.txt
added: 2026-10-01 by Claude (agent) for Brandon Thomas
notes: Unmodified font file, embedded via include_bytes! in the text engine (crates/text/src/fonts.rs), which also supplies it to the UI (crates/ui-egui/src/theme.rs).
Binary file not shown.
@@ -0,0 +1,8 @@
asset: Inter-Italic.ttf
title: Inter Italic (text engine / graphics)
author: The Inter Project Authors (Rasmus Andersson et al.)
source: https://github.com/rsms/inter (release v4.1, extras/ttf)
license: OFL-1.1
license-file: assets/fonts/OFL-Inter.txt
added: 2026-10-01 by Claude (agent) for Brandon Thomas
notes: Unmodified font file, embedded via include_bytes! in the text engine (crates/text/src/fonts.rs), which also supplies it to the UI (crates/ui-egui/src/theme.rs).
Binary file not shown.
@@ -0,0 +1,8 @@
asset: Inter-Medium.ttf
title: Inter Medium (UI typeface)
author: The Inter Project Authors (Rasmus Andersson et al.)
source: https://github.com/rsms/inter
license: OFL-1.1
license-file: assets/fonts/OFL-Inter.txt
added: 2026-10-01 by Claude (agent) for Brandon Thomas (copied from FilmCraft)
notes: Unmodified font file, embedded via include_bytes! in the text engine (crates/text/src/fonts.rs), which also supplies it to the UI (crates/ui-egui/src/theme.rs).
Binary file not shown.
@@ -0,0 +1,8 @@
asset: Inter-Regular.ttf
title: Inter Regular (UI typeface)
author: The Inter Project Authors (Rasmus Andersson et al.)
source: https://github.com/rsms/inter
license: OFL-1.1
license-file: assets/fonts/OFL-Inter.txt
added: 2026-10-01 by Claude (agent) for Brandon Thomas (copied from FilmCraft)
notes: Unmodified font file, embedded via include_bytes! in the text engine (crates/text/src/fonts.rs), which also supplies it to the UI (crates/ui-egui/src/theme.rs).
Binary file not shown.
@@ -0,0 +1,8 @@
asset: Inter-SemiBold.ttf
title: Inter SemiBold (UI typeface; caption burn-in font)
author: The Inter Project Authors (Rasmus Andersson et al.)
source: https://github.com/rsms/inter
license: OFL-1.1
license-file: assets/fonts/OFL-Inter.txt
added: 2026-10-01 by Claude (agent) for Brandon Thomas (copied from FilmCraft)
notes: Unmodified font file, embedded via include_bytes! in the text engine (crates/text/src/fonts.rs), which also supplies it to the UI (crates/ui-egui/src/theme.rs).
Binary file not shown.
@@ -0,0 +1,8 @@
asset: JetBrainsMono-Regular.ttf
title: JetBrains Mono Regular (monospace for timecode)
author: The JetBrains Mono Project Authors
source: https://github.com/JetBrains/JetBrainsMono
license: OFL-1.1
license-file: assets/fonts/OFL-JetBrainsMono.txt
added: 2026-10-01 by Claude (agent) for Brandon Thomas (copied from FilmCraft)
notes: Unmodified font file, embedded via include_bytes! in the text engine (crates/text/src/fonts.rs), which also supplies it to the UI (crates/ui-egui/src/theme.rs).
Binary file not shown.
@@ -0,0 +1,8 @@
asset: NotoSerif-Regular.ttf
title: Noto Serif Regular (serif face of the text engine / graphics)
author: The Noto Project Authors
source: https://github.com/notofonts/notofonts.github.io (fonts/NotoSerif/unhinted/ttf)
license: OFL-1.1
license-file: assets/fonts/OFL-NotoSerif.txt
added: 2026-10-01 by Claude (agent) for Brandon Thomas
notes: Unmodified font file, embedded via include_bytes! in the text engine (crates/text/src/fonts.rs), which also supplies it to the UI (crates/ui-egui/src/theme.rs).
+92
View File
@@ -0,0 +1,92 @@
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "effectcraft-color"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[lints]
workspace = true
[dependencies]
serde = { workspace = true }
[dev-dependencies]
proptest = { workspace = true }
+437
View File
@@ -0,0 +1,437 @@
//! Layer blend modes.
use serde::{Deserialize, Serialize};
use crate::luminance;
/// The blend modes of the Layer ▸ Blending Mode menu, in menu order.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum BlendMode {
#[default]
Normal,
Dissolve,
DancingDissolve,
Darken,
Multiply,
ColorBurn,
ClassicColorBurn,
LinearBurn,
DarkerColor,
Add,
Lighten,
Screen,
ColorDodge,
ClassicColorDodge,
LinearDodge,
LighterColor,
Overlay,
SoftLight,
HardLight,
LinearLight,
VividLight,
PinLight,
HardMix,
Difference,
ClassicDifference,
Exclusion,
Subtract,
Divide,
Hue,
Saturation,
Color,
Luminosity,
StencilAlpha,
StencilLuma,
SilhouetteAlpha,
SilhouetteLuma,
AlphaAdd,
LuminescentPremul,
}
impl BlendMode {
pub const ALL: [BlendMode; 38] = [
BlendMode::Normal,
BlendMode::Dissolve,
BlendMode::DancingDissolve,
BlendMode::Darken,
BlendMode::Multiply,
BlendMode::ColorBurn,
BlendMode::ClassicColorBurn,
BlendMode::LinearBurn,
BlendMode::DarkerColor,
BlendMode::Add,
BlendMode::Lighten,
BlendMode::Screen,
BlendMode::ColorDodge,
BlendMode::ClassicColorDodge,
BlendMode::LinearDodge,
BlendMode::LighterColor,
BlendMode::Overlay,
BlendMode::SoftLight,
BlendMode::HardLight,
BlendMode::LinearLight,
BlendMode::VividLight,
BlendMode::PinLight,
BlendMode::HardMix,
BlendMode::Difference,
BlendMode::ClassicDifference,
BlendMode::Exclusion,
BlendMode::Subtract,
BlendMode::Divide,
BlendMode::Hue,
BlendMode::Saturation,
BlendMode::Color,
BlendMode::Luminosity,
BlendMode::StencilAlpha,
BlendMode::StencilLuma,
BlendMode::SilhouetteAlpha,
BlendMode::SilhouetteLuma,
BlendMode::AlphaAdd,
BlendMode::LuminescentPremul,
];
pub fn label(self) -> &'static str {
match self {
BlendMode::Normal => "Normal",
BlendMode::Dissolve => "Dissolve",
BlendMode::DancingDissolve => "Dancing Dissolve",
BlendMode::Darken => "Darken",
BlendMode::Multiply => "Multiply",
BlendMode::ColorBurn => "Color Burn",
BlendMode::ClassicColorBurn => "Classic Color Burn",
BlendMode::LinearBurn => "Linear Burn",
BlendMode::DarkerColor => "Darker Color",
BlendMode::Add => "Add",
BlendMode::Lighten => "Lighten",
BlendMode::Screen => "Screen",
BlendMode::ColorDodge => "Color Dodge",
BlendMode::ClassicColorDodge => "Classic Color Dodge",
BlendMode::LinearDodge => "Linear Dodge",
BlendMode::LighterColor => "Lighter Color",
BlendMode::Overlay => "Overlay",
BlendMode::SoftLight => "Soft Light",
BlendMode::HardLight => "Hard Light",
BlendMode::LinearLight => "Linear Light",
BlendMode::VividLight => "Vivid Light",
BlendMode::PinLight => "Pin Light",
BlendMode::HardMix => "Hard Mix",
BlendMode::Difference => "Difference",
BlendMode::ClassicDifference => "Classic Difference",
BlendMode::Exclusion => "Exclusion",
BlendMode::Subtract => "Subtract",
BlendMode::Divide => "Divide",
BlendMode::Hue => "Hue",
BlendMode::Saturation => "Saturation",
BlendMode::Color => "Color",
BlendMode::Luminosity => "Luminosity",
BlendMode::StencilAlpha => "Stencil Alpha",
BlendMode::StencilLuma => "Stencil Luma",
BlendMode::SilhouetteAlpha => "Silhouette Alpha",
BlendMode::SilhouetteLuma => "Silhouette Luma",
BlendMode::AlphaAdd => "Alpha Add",
BlendMode::LuminescentPremul => "Luminescent Premul",
}
}
pub fn from_name(s: &str) -> Option<BlendMode> {
let k: String = s.chars().filter(|c| c.is_alphanumeric()).collect::<String>().to_ascii_lowercase();
BlendMode::ALL.into_iter().find(|m| m.label().chars().filter(|c| c.is_alphanumeric()).collect::<String>().to_ascii_lowercase() == k)
}
/// Menu groups (a separator follows the last mode of each group).
pub fn ends_group(self) -> bool {
matches!(
self,
BlendMode::DancingDissolve
| BlendMode::DarkerColor
| BlendMode::LighterColor
| BlendMode::HardMix
| BlendMode::Divide
| BlendMode::Luminosity
| BlendMode::SilhouetteLuma
)
}
/// Modes that act on everything below (the accumulated comp) rather than compositing on top.
pub fn is_stencil(self) -> bool {
matches!(self, BlendMode::StencilAlpha | BlendMode::StencilLuma | BlendMode::SilhouetteAlpha | BlendMode::SilhouetteLuma)
}
pub fn next(self) -> BlendMode {
let i = BlendMode::ALL.iter().position(|m| *m == self).unwrap_or(0);
BlendMode::ALL[(i + 1) % BlendMode::ALL.len()]
}
pub fn previous(self) -> BlendMode {
let i = BlendMode::ALL.iter().position(|m| *m == self).unwrap_or(0);
BlendMode::ALL[(i + BlendMode::ALL.len() - 1) % BlendMode::ALL.len()]
}
}
fn burn(cb: f32, cs: f32) -> f32 {
if cb >= 1.0 {
1.0
} else if cs <= 0.0 {
0.0
} else {
1.0 - ((1.0 - cb) / cs).min(1.0)
}
}
fn dodge(cb: f32, cs: f32) -> f32 {
if cb <= 0.0 {
0.0
} else if cs >= 1.0 {
1.0
} else {
(cb / (1.0 - cs)).min(1.0)
}
}
fn hard_light(cb: f32, cs: f32) -> f32 {
if cs <= 0.5 { cb * 2.0 * cs } else { let s = 2.0 * cs - 1.0; cb + s - cb * s }
}
fn soft_light(cb: f32, cs: f32) -> f32 {
if cs <= 0.5 {
cb - (1.0 - 2.0 * cs) * cb * (1.0 - cb)
} else {
let d = if cb <= 0.25 { ((16.0 * cb - 12.0) * cb + 4.0) * cb } else { cb.max(0.0).sqrt() };
cb + (2.0 * cs - 1.0) * (d - cb)
}
}
fn vivid(cb: f32, cs: f32) -> f32 {
if cs <= 0.5 { burn(cb, 2.0 * cs) } else { dodge(cb, 2.0 * (cs - 0.5)) }
}
fn separable(mode: BlendMode, cb: f32, cs: f32) -> f32 {
match mode {
BlendMode::Darken => cb.min(cs),
BlendMode::Multiply => cb * cs,
BlendMode::ColorBurn => burn(cb, cs),
BlendMode::ClassicColorBurn => {
if cs <= 0.0 {
0.0
} else {
(1.0 - (1.0 - cb) / cs).max(0.0)
}
}
BlendMode::LinearBurn => (cb + cs - 1.0).max(0.0),
BlendMode::Add | BlendMode::LinearDodge => cb + cs,
BlendMode::Lighten => cb.max(cs),
BlendMode::Screen => cb + cs - cb * cs,
BlendMode::ColorDodge => dodge(cb, cs),
BlendMode::ClassicColorDodge => {
if cs >= 1.0 {
1.0
} else {
(cb / (1.0 - cs)).min(1.0)
}
}
BlendMode::Overlay => hard_light(cs, cb),
BlendMode::SoftLight => soft_light(cb, cs),
BlendMode::HardLight => hard_light(cb, cs),
BlendMode::LinearLight => (cb + 2.0 * cs - 1.0).clamp(0.0, 1.0),
BlendMode::VividLight => vivid(cb, cs),
BlendMode::PinLight => {
if cs <= 0.5 {
cb.min(2.0 * cs)
} else {
cb.max(2.0 * cs - 1.0)
}
}
BlendMode::HardMix => {
if cb + cs >= 1.0 {
1.0
} else {
0.0
}
}
BlendMode::Difference | BlendMode::ClassicDifference => (cb - cs).abs(),
BlendMode::Exclusion => cb + cs - 2.0 * cb * cs,
BlendMode::Subtract => (cb - cs).max(0.0),
BlendMode::Divide => {
if cs <= 0.0 {
if cb > 0.0 { 1.0 } else { 0.0 }
} else {
cb / cs
}
}
_ => cs,
}
}
fn lum(c: [f32; 3]) -> f32 {
0.3 * c[0] + 0.59 * c[1] + 0.11 * c[2]
}
fn clip_color(c: [f32; 3]) -> [f32; 3] {
let l = lum(c);
let n = c[0].min(c[1]).min(c[2]);
let x = c[0].max(c[1]).max(c[2]);
let mut o = c;
if n < 0.0 {
for v in &mut o {
*v = l + (*v - l) * l / (l - n).max(1e-9);
}
}
if x > 1.0 {
for v in &mut o {
*v = l + (*v - l) * (1.0 - l) / (x - l).max(1e-9);
}
}
o
}
fn set_lum(c: [f32; 3], l: f32) -> [f32; 3] {
let d = l - lum(c);
clip_color([c[0] + d, c[1] + d, c[2] + d])
}
fn sat(c: [f32; 3]) -> f32 {
c[0].max(c[1]).max(c[2]) - c[0].min(c[1]).min(c[2])
}
fn set_sat(c: [f32; 3], s: f32) -> [f32; 3] {
let mx = c[0].max(c[1]).max(c[2]);
let mn = c[0].min(c[1]).min(c[2]);
if mx - mn <= 1e-9 {
return [0.0; 3];
}
let f = |v: f32| (v - mn) * s / (mx - mn);
[f(c[0]), f(c[1]), f(c[2])]
}
fn non_separable(mode: BlendMode, cb: [f32; 3], cs: [f32; 3]) -> [f32; 3] {
match mode {
BlendMode::Hue => set_lum(set_sat(cs, sat(cb)), lum(cb)),
BlendMode::Saturation => set_lum(set_sat(cb, sat(cs)), lum(cb)),
BlendMode::Color => set_lum(cs, lum(cb)),
BlendMode::Luminosity => set_lum(cb, lum(cs)),
BlendMode::DarkerColor => {
if luminance(cs[0], cs[1], cs[2]) < luminance(cb[0], cb[1], cb[2]) {
cs
} else {
cb
}
}
BlendMode::LighterColor => {
if luminance(cs[0], cs[1], cs[2]) > luminance(cb[0], cb[1], cb[2]) {
cs
} else {
cb
}
}
_ => [separable(mode, cb[0], cs[0]), separable(mode, cb[1], cs[1]), separable(mode, cb[2], cs[2])],
}
}
/// Composite one premultiplied source pixel over a premultiplied destination pixel.
///
/// `src` must already include layer opacity. `noise` (0..1) is a per-pixel random value used by
/// Dissolve modes. Stencil/Silhouette modes return the destination modified by the source.
#[inline]
pub fn blend_pixel(mode: BlendMode, dst: [f32; 4], src: [f32; 4], noise: f32) -> [f32; 4] {
let sa = src[3];
let da = dst[3];
match mode {
BlendMode::Normal => {
let k = 1.0 - sa;
return [src[0] + dst[0] * k, src[1] + dst[1] * k, src[2] + dst[2] * k, sa + da * k];
}
BlendMode::Dissolve | BlendMode::DancingDissolve => {
if sa <= 0.0 || noise >= sa {
return dst;
}
let inv = 1.0 / sa;
return [src[0] * inv, src[1] * inv, src[2] * inv, 1.0];
}
BlendMode::StencilAlpha => return dst.map(|v| v * sa),
BlendMode::SilhouetteAlpha => return dst.map(|v| v * (1.0 - sa)),
BlendMode::StencilLuma | BlendMode::SilhouetteLuma => {
let l = luminance(src[0], src[1], src[2]);
let k = if mode == BlendMode::StencilLuma { l } else { 1.0 - l };
return dst.map(|v| v * k);
}
BlendMode::AlphaAdd => {
return [src[0] + dst[0] * (1.0 - sa), src[1] + dst[1] * (1.0 - sa), src[2] + dst[2] * (1.0 - sa), (sa + da).min(1.0)];
}
BlendMode::LuminescentPremul => {
return [src[0] + dst[0] * (1.0 - sa), src[1] + dst[1] * (1.0 - sa), src[2] + dst[2] * (1.0 - sa), sa + da - sa * da];
}
_ => {}
}
if sa <= 0.0 {
return dst;
}
if da <= 0.0 {
return src;
}
let cs = [src[0] / sa, src[1] / sa, src[2] / sa];
let cb = [dst[0] / da, dst[1] / da, dst[2] / da];
let b = non_separable(mode, cb, cs);
let ao = sa + da - sa * da;
let mut o = [0.0f32; 4];
for i in 0..3 {
o[i] = (1.0 - da) * src[i] + (1.0 - sa) * dst[i] + sa * da * b[i];
}
o[3] = ao;
o
}
#[cfg(test)]
mod tests {
use super::*;
fn opaque(v: f32) -> [f32; 4] {
[v, v, v, 1.0]
}
#[test]
fn opaque_formulas() {
let d = opaque(0.6);
let s = opaque(0.3);
let r = |m| blend_pixel(m, d, s, 0.5)[0];
assert!((r(BlendMode::Normal) - 0.3).abs() < 1e-6);
assert!((r(BlendMode::Multiply) - 0.18).abs() < 1e-6);
assert!((r(BlendMode::Screen) - (0.6 + 0.3 - 0.18)).abs() < 1e-6);
assert!((r(BlendMode::Add) - 0.9).abs() < 1e-6);
assert!((r(BlendMode::Difference) - 0.3).abs() < 1e-6);
assert!((r(BlendMode::Darken) - 0.3).abs() < 1e-6);
assert!((r(BlendMode::Lighten) - 0.6).abs() < 1e-6);
assert!((r(BlendMode::Subtract) - 0.3).abs() < 1e-6);
assert!((r(BlendMode::Divide) - 1.0).abs() < 1e-6 || r(BlendMode::Divide) > 1.0);
assert!((r(BlendMode::LinearBurn) - 0.0).abs() < 1e-6);
assert!((r(BlendMode::Exclusion) - (0.9 - 0.36)).abs() < 1e-6);
// Overlay with base 0.6 (>0.5): screen(0.3, 2*0.6-1)
assert!((r(BlendMode::Overlay) - (0.3 + 0.2 - 0.06)).abs() < 1e-6);
}
#[test]
fn transparent_source_keeps_destination() {
let d = [0.2, 0.3, 0.4, 0.8];
for m in BlendMode::ALL {
if m.is_stencil() {
continue;
}
let o = blend_pixel(m, d, [0.0; 4], 0.5);
assert!((0..4).all(|i| (o[i] - d[i]).abs() < 1e-6), "{m:?} {o:?}");
}
}
#[test]
fn source_over_transparent_destination() {
let s = [0.25, 0.1, 0.05, 0.5];
for m in [BlendMode::Normal, BlendMode::Multiply, BlendMode::Screen, BlendMode::Hue, BlendMode::Overlay] {
let o = blend_pixel(m, [0.0; 4], s, 0.9);
assert!((0..4).all(|i| (o[i] - s[i]).abs() < 1e-6), "{m:?}");
}
}
#[test]
fn stencil() {
let d = [0.5, 0.5, 0.5, 1.0];
assert_eq!(blend_pixel(BlendMode::StencilAlpha, d, [0.0, 0.0, 0.0, 0.0], 0.0), [0.0; 4]);
assert_eq!(blend_pixel(BlendMode::SilhouetteAlpha, d, [0.0, 0.0, 0.0, 0.0], 0.0), d);
}
#[test]
fn names_roundtrip() {
for m in BlendMode::ALL {
assert_eq!(BlendMode::from_name(m.label()), Some(m));
}
assert_eq!(BlendMode::from_name("soft-light"), Some(BlendMode::SoftLight));
}
}
+276
View File
@@ -0,0 +1,276 @@
//! Colour for EffectCraft: RGBA values, sRGB transfer, HSL/HSV, luminance, the label palette and
//! the 38 After-Effects-compatible blend modes.
//!
//! Blend-mode formulas come from the W3C *Compositing and Blending Level 1* specification and the
//! standard definitions of the "classic" photographic modes; they are applied to un-premultiplied
//! colour and composited with Porter-Duff source-over on premultiplied pixels.
pub mod blend;
pub use blend::{BlendMode, blend_pixel};
use serde::{Deserialize, Serialize};
/// Straight (un-premultiplied) RGBA, 0..1 nominal (32 bpc may exceed).
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Rgba {
pub r: f32,
pub g: f32,
pub b: f32,
pub a: f32,
}
pub const fn rgba(r: f32, g: f32, b: f32, a: f32) -> Rgba {
Rgba { r, g, b, a }
}
impl Rgba {
pub const BLACK: Rgba = rgba(0.0, 0.0, 0.0, 1.0);
pub const WHITE: Rgba = rgba(1.0, 1.0, 1.0, 1.0);
pub const TRANSPARENT: Rgba = rgba(0.0, 0.0, 0.0, 0.0);
pub fn from_u8(r: u8, g: u8, b: u8) -> Rgba {
rgba(r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0)
}
/// `#rrggbb` or `#rrggbbaa`.
pub fn from_hex(s: &str) -> Option<Rgba> {
let s = s.trim().trim_start_matches('#');
let p = |i: usize| u8::from_str_radix(s.get(i..i + 2)?, 16).ok();
match s.len() {
6 => Some(Rgba::from_u8(p(0)?, p(2)?, p(4)?)),
8 => Some(Rgba { a: p(6)? as f32 / 255.0, ..Rgba::from_u8(p(0)?, p(2)?, p(4)?) }),
_ => None,
}
}
pub fn to_hex(&self) -> String {
let c = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
format!("#{:02X}{:02X}{:02X}", c(self.r), c(self.g), c(self.b))
}
pub fn premultiplied(&self) -> [f32; 4] {
[self.r * self.a, self.g * self.a, self.b * self.a, self.a]
}
pub fn to_array(&self) -> [f32; 4] {
[self.r, self.g, self.b, self.a]
}
pub fn from_array(a: [f32; 4]) -> Rgba {
rgba(a[0], a[1], a[2], a[3])
}
pub fn lerp(&self, o: &Rgba, t: f32) -> Rgba {
rgba(self.r + (o.r - self.r) * t, self.g + (o.g - self.g) * t, self.b + (o.b - self.b) * t, self.a + (o.a - self.a) * t)
}
pub fn to_u8(&self) -> [u8; 4] {
let c = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
[c(self.r), c(self.g), c(self.b), c(self.a)]
}
}
/// sRGB electro-optical transfer: encoded → linear.
pub fn srgb_to_linear(v: f32) -> f32 {
if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
}
/// Linear → sRGB encoded.
pub fn linear_to_srgb(v: f32) -> f32 {
if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.max(0.0).powf(1.0 / 2.4) - 0.055 }
}
/// Rec. 709 luma weights (AE's luminance for luma mattes/keys).
pub fn luminance(r: f32, g: f32, b: f32) -> f32 {
0.2126 * r + 0.7152 * g + 0.0722 * b
}
/// RGB (0..1) → HSL with h in 0..1.
pub fn rgb_to_hsl(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let l = (max + min) * 0.5;
if (max - min).abs() < 1e-7 {
return (0.0, 0.0, l);
}
let d = max - min;
let s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) };
let h = if max == r {
((g - b) / d + if g < b { 6.0 } else { 0.0 }) / 6.0
} else if max == g {
((b - r) / d + 2.0) / 6.0
} else {
((r - g) / d + 4.0) / 6.0
};
(h, s, l)
}
pub fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (f32, f32, f32) {
if s <= 0.0 {
return (l, l, l);
}
let q = if l < 0.5 { l * (1.0 + s) } else { l + s - l * s };
let p = 2.0 * l - q;
let f = |mut t: f32| {
t = t.rem_euclid(1.0);
if t < 1.0 / 6.0 {
p + (q - p) * 6.0 * t
} else if t < 0.5 {
q
} else if t < 2.0 / 3.0 {
p + (q - p) * (2.0 / 3.0 - t) * 6.0
} else {
p
}
};
(f(h + 1.0 / 3.0), f(h), f(h - 1.0 / 3.0))
}
pub fn rgb_to_hsv(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let d = max - min;
let h = if d < 1e-7 {
0.0
} else if max == r {
((g - b) / d).rem_euclid(6.0) / 6.0
} else if max == g {
((b - r) / d + 2.0) / 6.0
} else {
((r - g) / d + 4.0) / 6.0
};
let s = if max > 0.0 { d / max } else { 0.0 };
(h, s, max)
}
pub fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (f32, f32, f32) {
let h6 = h.rem_euclid(1.0) * 6.0;
let i = h6.floor();
let f = h6 - i;
let p = v * (1.0 - s);
let q = v * (1.0 - s * f);
let t = v * (1.0 - s * (1.0 - f));
match i as i32 {
0 => (v, t, p),
1 => (q, v, p),
2 => (p, v, t),
3 => (p, q, v),
4 => (t, p, v),
_ => (v, p, q),
}
}
/// Item/layer label colours (16 slots + None), defaults as in the public Labels preferences.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Label {
None,
#[default]
Red,
Yellow,
Aqua,
Pink,
Lavender,
Peach,
SeaFoam,
Blue,
Green,
Purple,
Orange,
Brown,
Fuchsia,
Cyan,
Sandstone,
DarkGreen,
}
impl Label {
pub const ALL: [Label; 17] = [
Label::None,
Label::Red,
Label::Yellow,
Label::Aqua,
Label::Pink,
Label::Lavender,
Label::Peach,
Label::SeaFoam,
Label::Blue,
Label::Green,
Label::Purple,
Label::Orange,
Label::Brown,
Label::Fuchsia,
Label::Cyan,
Label::Sandstone,
Label::DarkGreen,
];
pub fn name(self) -> &'static str {
match self {
Label::None => "None",
Label::Red => "Red",
Label::Yellow => "Yellow",
Label::Aqua => "Aqua",
Label::Pink => "Pink",
Label::Lavender => "Lavender",
Label::Peach => "Peach",
Label::SeaFoam => "Sea Foam",
Label::Blue => "Blue",
Label::Green => "Green",
Label::Purple => "Purple",
Label::Orange => "Orange",
Label::Brown => "Brown",
Label::Fuchsia => "Fuchsia",
Label::Cyan => "Cyan",
Label::Sandstone => "Sandstone",
Label::DarkGreen => "Dark Green",
}
}
pub fn from_name(s: &str) -> Option<Label> {
Label::ALL.into_iter().find(|l| l.name().eq_ignore_ascii_case(s) || format!("{l:?}").eq_ignore_ascii_case(s))
}
/// sRGB 8-bit colour.
pub fn rgb(self) -> [u8; 3] {
match self {
Label::None => [0x50, 0x50, 0x50],
Label::Red => [0xb5, 0x38, 0x38],
Label::Yellow => [0xe4, 0xd8, 0x4c],
Label::Aqua => [0xa9, 0xcb, 0xc7],
Label::Pink => [0xe5, 0xbc, 0xc9],
Label::Lavender => [0xa9, 0xa9, 0xca],
Label::Peach => [0xe7, 0xc1, 0x9e],
Label::SeaFoam => [0xb3, 0xc7, 0xb3],
Label::Blue => [0x67, 0x7d, 0xe0],
Label::Green => [0x4a, 0xa4, 0x4c],
Label::Purple => [0x8e, 0x2c, 0x9a],
Label::Orange => [0xe8, 0x92, 0x0d],
Label::Brown => [0x7f, 0x45, 0x2a],
Label::Fuchsia => [0xf4, 0x6d, 0xd6],
Label::Cyan => [0x3d, 0xa2, 0xa5],
Label::Sandstone => [0xa8, 0x96, 0x77],
Label::DarkGreen => [0x1e, 0x40, 0x1e],
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn srgb_roundtrip() {
for i in 0..=100 {
let v = i as f32 / 100.0;
assert!((linear_to_srgb(srgb_to_linear(v)) - v).abs() < 1e-5);
}
}
#[test]
fn hsl_roundtrip() {
for (r, g, b) in [(0.2, 0.4, 0.9), (1.0, 0.0, 0.0), (0.5, 0.5, 0.5), (0.9, 0.8, 0.1)] {
let (h, s, l) = rgb_to_hsl(r, g, b);
let (r2, g2, b2) = hsl_to_rgb(h, s, l);
assert!((r - r2).abs() < 1e-5 && (g - g2).abs() < 1e-5 && (b - b2).abs() < 1e-5);
let (h, s, v) = rgb_to_hsv(r, g, b);
let (r3, g3, b3) = hsv_to_rgb(h, s, v);
assert!((r - r3).abs() < 1e-5 && (g - g3).abs() < 1e-5 && (b - b3).abs() < 1e-5);
}
}
#[test]
fn hex() {
let c = Rgba::from_hex("#2D8CEB").unwrap();
assert_eq!(c.to_hex(), "#2D8CEB");
assert!(Rgba::from_hex("zz").is_none());
}
}
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "effectcraft-effects"
description = "Effect registry (specs, parameters, categories) and CPU implementations of the effects library"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[lints]
workspace = true
[dependencies]
effectcraft-geom = { workspace = true }
effectcraft-color = { workspace = true }
effectcraft-keyframe = { workspace = true }
effectcraft-project = { workspace = true }
effectcraft-raster = { workspace = true }
rayon = { workspace = true }
+455
View File
@@ -0,0 +1,455 @@
//! Color Correction effects.
use effectcraft_color::{hsl_to_rgb, luminance, rgb_to_hsl};
use effectcraft_keyframe::Value;
use effectcraft_project::ParamUi;
use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider};
fn spec(id: &'static str, name: &'static str, params: Vec<crate::ParamSpec>, render: crate::RenderFn) -> EffectSpec {
EffectSpec { id, name, category: "Color Correction", params, render, gpu: false, float: true }
}
fn mix(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] {
[a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]
}
fn tint(ctx: &EffectCtx, mut b: Buf) -> Buf {
let black = ctx.params.color("black");
let white = ctx.params.color("white");
let amt = ctx.params.f("amount") as f32 / 100.0;
b.img.map_straight(|c| {
let l = luminance(c[0], c[1], c[2]).clamp(0.0, 1.0);
let t = [black[0] + (white[0] - black[0]) * l, black[1] + (white[1] - black[1]) * l, black[2] + (white[2] - black[2]) * l];
mix(c, t, amt)
});
b
}
fn tritone(ctx: &EffectCtx, mut b: Buf) -> Buf {
let hi = ctx.params.color("highlights");
let mid = ctx.params.color("midtones");
let sh = ctx.params.color("shadows");
let blend = 1.0 - ctx.params.f("blend") as f32 / 100.0;
b.img.map_straight(|c| {
let l = luminance(c[0], c[1], c[2]).clamp(0.0, 1.0);
let t = if l < 0.5 { mix([sh[0], sh[1], sh[2]], [mid[0], mid[1], mid[2]], l * 2.0) } else { mix([mid[0], mid[1], mid[2]], [hi[0], hi[1], hi[2]], (l - 0.5) * 2.0) };
mix(c, t, blend)
});
b
}
fn brightness_contrast(ctx: &EffectCtx, mut b: Buf) -> Buf {
let br = ctx.params.f("brightness") as f32 / 100.0;
let ct = ctx.params.f("contrast") as f32 / 100.0;
let k = if ct >= 0.0 { 1.0 / (1.0 - ct * 0.99) } else { 1.0 + ct };
b.img.map_straight(|c| c.map(|v| ((v + br - 0.5) * k + 0.5).max(0.0)));
b
}
fn hue_saturation(ctx: &EffectCtx, mut b: Buf) -> Buf {
let hue = ctx.params.f("hue") as f32 / 360.0;
let sat = ctx.params.f("saturation") as f32 / 100.0;
let light = ctx.params.f("lightness") as f32 / 100.0;
let colorize = ctx.params.b("colorize");
let ch = ctx.params.f("colorizeHue") as f32 / 360.0;
let cs = ctx.params.f("colorizeSaturation") as f32 / 100.0;
let cl = ctx.params.f("colorizeLightness") as f32 / 100.0;
b.img.map_straight(|c| {
let (h, s, l) = rgb_to_hsl(c[0], c[1], c[2]);
let (h, s, l) = if colorize {
(ch, cs, (l + cl * if cl > 0.0 { 1.0 - l } else { l }).clamp(0.0, 1.0))
} else {
let s2 = if sat >= 0.0 { s + (1.0 - s) * sat * s.min(1.0) } else { s * (1.0 + sat) };
let l2 = if light >= 0.0 { l + (1.0 - l) * light } else { l * (1.0 + light) };
((h + hue).rem_euclid(1.0), s2.clamp(0.0, 1.0), l2)
};
let (r, g, bl) = hsl_to_rgb(h, s, l);
[r, g, bl]
});
b
}
fn levels(ctx: &EffectCtx, mut b: Buf) -> Buf {
let ib = ctx.params.f("inBlack") as f32;
let iw = ctx.params.f("inWhite") as f32;
let g = ctx.params.f("gamma").max(0.01) as f32;
let ob = ctx.params.f("outBlack") as f32;
let ow = ctx.params.f("outWhite") as f32;
let clip = !ctx.params.b("noClip");
b.img.map_straight(|c| {
c.map(|v| {
let mut t = (v - ib) / (iw - ib).max(1e-6);
if clip {
t = t.clamp(0.0, 1.0);
}
let t = t.max(0.0).powf(1.0 / g);
ob + (ow - ob) * t
})
});
b
}
fn exposure(ctx: &EffectCtx, mut b: Buf) -> Buf {
let e = 2f32.powf(ctx.params.f("exposure") as f32);
let off = ctx.params.f("offset") as f32;
let g = ctx.params.f("gamma").max(0.01) as f32;
b.img.map_straight(|c| {
c.map(|v| {
let lin = effectcraft_color::srgb_to_linear(v.max(0.0));
let o = (lin * e + off).max(0.0).powf(1.0 / g);
effectcraft_color::linear_to_srgb(o)
})
});
b
}
fn black_white(ctx: &EffectCtx, mut b: Buf) -> Buf {
let w = [ctx.params.f("reds"), ctx.params.f("yellows"), ctx.params.f("greens"), ctx.params.f("cyans"), ctx.params.f("blues"), ctx.params.f("magentas")].map(|v| v as f32 / 100.0);
let tint_on = ctx.params.b("tint");
let tc = ctx.params.color("tintColor");
b.img.map_straight(|c| {
let (h, s, _) = rgb_to_hsl(c[0], c[1], c[2]);
// Interpolate the six hue weights around the colour wheel.
let hp = h * 6.0;
let i = hp.floor() as usize % 6;
let f = hp - hp.floor();
let wt = w[i] + (w[(i + 1) % 6] - w[i]) * f;
let base = luminance(c[0], c[1], c[2]);
let gray = (base + (wt - 0.5) * s * 0.6).max(0.0);
if tint_on {
let (th, ts, _) = rgb_to_hsl(tc[0], tc[1], tc[2]);
let (r, g, bl) = hsl_to_rgb(th, ts, gray.min(1.0));
[r, g, bl]
} else {
[gray, gray, gray]
}
});
b
}
fn fill(ctx: &EffectCtx, mut b: Buf) -> Buf {
let c = ctx.params.color("color");
let invert = ctx.params.b("invert");
let opacity = ctx.params.f("opacity") as f32 / 100.0;
b.img.data.iter_mut().for_each(|px| {
let a = if invert { 1.0 - px[3] } else { px[3] };
let f = [c[0] * a, c[1] * a, c[2] * a];
for i in 0..3 {
px[i] += (f[i] - px[i]) * opacity;
}
if invert {
px[3] += (a - px[3]) * opacity;
}
});
b
}
fn change_to_color(ctx: &EffectCtx, mut b: Buf) -> Buf {
let from = ctx.params.color("from");
let to = ctx.params.color("to");
let tol = ctx.params.f("tolerance") as f32 / 100.0;
let soft = (ctx.params.f("softness") as f32 / 100.0).max(1e-3);
let (fh, fs, fl) = rgb_to_hsl(from[0], from[1], from[2]);
let (th, ts, tl) = rgb_to_hsl(to[0], to[1], to[2]);
b.img.map_straight(|c| {
let (h, s, l) = rgb_to_hsl(c[0], c[1], c[2]);
let dh = ((h - fh + 0.5).rem_euclid(1.0) - 0.5).abs() * 2.0;
let d = dh.max((s - fs).abs() * 0.5).max((l - fl).abs() * 0.5);
let k = (1.0 - ((d - tol) / soft).clamp(0.0, 1.0)).clamp(0.0, 1.0);
let (r, g, bl) = hsl_to_rgb((h + (th - fh)).rem_euclid(1.0), (s + (ts - fs)).clamp(0.0, 1.0), (l + (tl - fl)).clamp(0.0, 1.0));
mix(c, [r, g, bl], k)
});
b
}
fn leave_color(ctx: &EffectCtx, mut b: Buf) -> Buf {
let keep = ctx.params.color("color");
let amt = ctx.params.f("amount") as f32 / 100.0;
let tol = ctx.params.f("tolerance") as f32 / 100.0;
let (kh, _, _) = rgb_to_hsl(keep[0], keep[1], keep[2]);
b.img.map_straight(|c| {
let (h, s, _) = rgb_to_hsl(c[0], c[1], c[2]);
let dh = ((h - kh + 0.5).rem_euclid(1.0) - 0.5).abs() * 2.0;
let keepk = if s > 0.05 && dh <= tol { 1.0 } else { (1.0 - (dh - tol) * 8.0).clamp(0.0, 1.0) * s.min(1.0) };
let g = luminance(c[0], c[1], c[2]);
mix(c, [g, g, g], amt * (1.0 - keepk))
});
b
}
fn photo_filter(ctx: &EffectCtx, mut b: Buf) -> Buf {
let c = ctx.params.color("color");
let d = ctx.params.f("density") as f32 / 100.0;
let keep = ctx.params.b("preserveLuminosity");
b.img.map_straight(|x| {
let f = [x[0] * c[0], x[1] * c[1], x[2] * c[2]];
let mut o = mix(x, f, d);
if keep {
let l0 = luminance(x[0], x[1], x[2]);
let l1 = luminance(o[0], o[1], o[2]).max(1e-6);
o = o.map(|v| v * l0 / l1);
}
o
});
b
}
fn vibrance(ctx: &EffectCtx, mut b: Buf) -> Buf {
let vib = ctx.params.f("vibrance") as f32 / 100.0;
let sat = ctx.params.f("saturation") as f32 / 100.0;
b.img.map_straight(|c| {
let (h, s, l) = rgb_to_hsl(c[0], c[1], c[2]);
let s = (s * (1.0 + sat) + vib * (1.0 - s) * s.min(0.5)).clamp(0.0, 1.0);
let (r, g, bl) = hsl_to_rgb(h, s, l);
[r, g, bl]
});
b
}
fn color_balance(ctx: &EffectCtx, mut b: Buf) -> Buf {
let g = |k: &str| ctx.params.f(k) as f32 / 100.0;
let sh = [g("shadowRed"), g("shadowGreen"), g("shadowBlue")];
let md = [g("midRed"), g("midGreen"), g("midBlue")];
let hl = [g("hiRed"), g("hiGreen"), g("hiBlue")];
b.img.map_straight(|c| {
let l = luminance(c[0], c[1], c[2]).clamp(0.0, 1.0);
let ws = (1.0 - l * 2.0).clamp(0.0, 1.0);
let wh = (l * 2.0 - 1.0).clamp(0.0, 1.0);
let wm = 1.0 - ws - wh;
let mut o = c;
for i in 0..3 {
o[i] = (c[i] + (sh[i] * ws + md[i] * wm + hl[i] * wh) * 0.5).max(0.0);
}
o
});
b
}
fn channel_mixer(ctx: &EffectCtx, mut b: Buf) -> Buf {
let g = |k: &str| ctx.params.f(k) as f32 / 100.0;
let m = [[g("rr"), g("rg"), g("rb"), g("rc")], [g("gr"), g("gg"), g("gb"), g("gc")], [g("br"), g("bg"), g("bb"), g("bc")]];
let mono = ctx.params.b("monochrome");
b.img.map_straight(|c| {
let row = |r: [f32; 4]| (c[0] * r[0] + c[1] * r[1] + c[2] * r[2] + r[3]).max(0.0);
if mono {
let v = row(m[0]);
[v, v, v]
} else {
[row(m[0]), row(m[1]), row(m[2])]
}
});
b
}
fn gamma_pg(ctx: &EffectCtx, mut b: Buf) -> Buf {
let ch = |n: &str| (ctx.params.f(&format!("{n}Gamma")).max(0.01) as f32, ctx.params.f(&format!("{n}Pedestal")) as f32, ctx.params.f(&format!("{n}Gain")) as f32);
let r = ch("red");
let g = ch("green");
let bl = ch("blue");
b.img.map_straight(|c| {
let f = |v: f32, (gm, pd, gn): (f32, f32, f32)| (pd + (gn - pd) * v.max(0.0).powf(gm)).max(0.0);
[f(c[0], r), f(c[1], g), f(c[2], bl)]
});
b
}
fn colorama(ctx: &EffectCtx, mut b: Buf) -> Buf {
let shift = ctx.params.f("phaseShift") as f32 / 360.0;
let cycles = ctx.params.f("cycles").max(0.0) as f32;
let blend = 1.0 - ctx.params.f("blend") as f32 / 100.0;
b.img.map_straight(|c| {
let l = luminance(c[0], c[1], c[2]);
let (r, g, bl) = effectcraft_color::hsv_to_rgb((l * cycles + shift).rem_euclid(1.0), 1.0, 1.0);
mix(c, [r, g, bl], blend)
});
b
}
pub fn specs() -> Vec<EffectSpec> {
let pct = || slider(-100.0, 100.0, -100.0, 100.0, 1);
let mut gpg = Vec::new();
for (n, label) in [("red", "Red"), ("green", "Green"), ("blue", "Blue")] {
let leak = |s: String| -> &'static str { Box::leak(s.into_boxed_str()) };
gpg.push(p(leak(format!("{n}Gamma")), leak(format!("{label} Gamma")), num(1.0), slider(0.1, 10.0, 0.1, 4.0, 2)));
gpg.push(p(leak(format!("{n}Pedestal")), leak(format!("{label} Pedestal")), num(0.0), slider(-2.0, 2.0, -1.0, 1.0, 2)));
gpg.push(p(leak(format!("{n}Gain")), leak(format!("{label} Gain")), num(1.0), slider(0.0, 4.0, 0.0, 2.0, 2)));
}
vec![
spec(
"ec.color.tint",
"Tint",
vec![
p("black", "Map Black To", col(0.0, 0.0, 0.0), ParamUi::Color),
p("white", "Map White To", col(1.0, 1.0, 1.0), ParamUi::Color),
p("amount", "Amount to Tint", num(100.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
],
tint,
),
spec(
"ec.color.tritone",
"Tritone",
vec![
p("highlights", "Highlights", col(1.0, 1.0, 1.0), ParamUi::Color),
p("midtones", "Midtones", col(0.5, 0.4, 0.3), ParamUi::Color),
p("shadows", "Shadows", col(0.0, 0.0, 0.0), ParamUi::Color),
p("blend", "Blend With Original", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
],
tritone,
),
spec(
"ec.color.brightnesscontrast",
"Brightness & Contrast",
vec![p("brightness", "Brightness", num(0.0), slider(-150.0, 150.0, -150.0, 150.0, 1)), p("contrast", "Contrast", num(0.0), pct())],
brightness_contrast,
),
spec(
"ec.color.huesaturation",
"Hue/Saturation",
vec![
p("hue", "Master Hue", num(0.0), ParamUi::Angle),
p("saturation", "Master Saturation", num(0.0), pct()),
p("lightness", "Master Lightness", num(0.0), pct()),
p("colorize", "Colorize", Value::Bool(false), ParamUi::Checkbox),
p("colorizeHue", "Colorize Hue", num(0.0), ParamUi::Angle),
p("colorizeSaturation", "Colorize Saturation", num(25.0), slider(0.0, 100.0, 0.0, 100.0, 0)),
p("colorizeLightness", "Colorize Lightness", num(0.0), pct()),
],
hue_saturation,
),
spec(
"ec.color.levels",
"Levels",
vec![
p("inBlack", "Input Black", num(0.0), slider(-1.0, 2.0, 0.0, 1.0, 3)),
p("inWhite", "Input White", num(1.0), slider(-1.0, 2.0, 0.0, 1.0, 3)),
p("gamma", "Gamma", num(1.0), slider(0.1, 10.0, 0.1, 3.0, 2)),
p("outBlack", "Output Black", num(0.0), slider(-1.0, 2.0, 0.0, 1.0, 3)),
p("outWhite", "Output White", num(1.0), slider(-1.0, 2.0, 0.0, 1.0, 3)),
p("noClip", "Don't Clip", Value::Bool(false), ParamUi::Checkbox),
],
levels,
),
spec(
"ec.color.exposure",
"Exposure",
vec![
p("exposure", "Exposure", num(0.0), slider(-20.0, 20.0, -5.0, 5.0, 2)),
p("offset", "Offset", num(0.0), slider(-2.0, 2.0, -0.5, 0.5, 4)),
p("gamma", "Gamma Correction", num(1.0), slider(0.01, 9.99, 0.1, 3.0, 2)),
],
exposure,
),
spec(
"ec.color.blackwhite",
"Black & White",
vec![
p("reds", "Reds", num(40.0), slider(-200.0, 300.0, -200.0, 300.0, 0)),
p("yellows", "Yellows", num(60.0), slider(-200.0, 300.0, -200.0, 300.0, 0)),
p("greens", "Greens", num(40.0), slider(-200.0, 300.0, -200.0, 300.0, 0)),
p("cyans", "Cyans", num(60.0), slider(-200.0, 300.0, -200.0, 300.0, 0)),
p("blues", "Blues", num(20.0), slider(-200.0, 300.0, -200.0, 300.0, 0)),
p("magentas", "Magentas", num(80.0), slider(-200.0, 300.0, -200.0, 300.0, 0)),
p("tint", "Tint", Value::Bool(false), ParamUi::Checkbox),
p("tintColor", "Tint Color", col(0.88, 0.76, 0.6), ParamUi::Color),
],
black_white,
),
EffectSpec {
id: "ec.generate.fill",
name: "Fill",
category: "Generate",
params: vec![
p("color", "Color", col(1.0, 0.0, 0.0), ParamUi::Color),
p("invert", "Invert", Value::Bool(false), ParamUi::Checkbox),
p("opacity", "Opacity", num(100.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
],
render: fill,
gpu: false,
float: true,
},
spec(
"ec.color.changetocolor",
"Change to Color",
vec![
p("from", "From", col(1.0, 0.0, 0.0), ParamUi::Color),
p("to", "To", col(0.0, 0.0, 1.0), ParamUi::Color),
p("tolerance", "Tolerance", num(10.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("softness", "Softness", num(10.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
],
change_to_color,
),
spec(
"ec.color.leavecolor",
"Leave Color",
vec![
p("amount", "Amount to Decolor", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("color", "Color To Leave", col(1.0, 0.0, 0.0), ParamUi::Color),
p("tolerance", "Tolerance", num(15.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
],
leave_color,
),
spec(
"ec.color.photofilter",
"Photo Filter",
vec![
p("color", "Color", col(0.93, 0.54, 0.09), ParamUi::Color),
p("density", "Density", num(25.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("preserveLuminosity", "Preserve Luminosity", Value::Bool(true), ParamUi::Checkbox),
],
photo_filter,
),
spec("ec.color.vibrance", "Vibrance", vec![p("vibrance", "Vibrance", num(0.0), pct()), p("saturation", "Saturation", num(0.0), pct())], vibrance),
spec(
"ec.color.colorbalance",
"Color Balance",
["shadowRed", "shadowGreen", "shadowBlue", "midRed", "midGreen", "midBlue", "hiRed", "hiGreen", "hiBlue"]
.iter()
.zip([
"Shadow Red Balance",
"Shadow Green Balance",
"Shadow Blue Balance",
"Midtone Red Balance",
"Midtone Green Balance",
"Midtone Blue Balance",
"Highlight Red Balance",
"Highlight Green Balance",
"Highlight Blue Balance",
])
.map(|(id, n)| p(id, n, num(0.0), pct()))
.collect(),
color_balance,
),
spec(
"ec.color.channelmixer",
"Channel Mixer",
{
let ids = ["rr", "rg", "rb", "rc", "gr", "gg", "gb", "gc", "br", "bg", "bb", "bc"];
let names = [
"Red-Red", "Red-Green", "Red-Blue", "Red-Const", "Green-Red", "Green-Green", "Green-Blue", "Green-Const", "Blue-Red", "Blue-Green", "Blue-Blue",
"Blue-Const",
];
let mut v: Vec<_> = ids
.iter()
.zip(names)
.map(|(id, n)| p(id, n, num(if matches!(*id, "rr" | "gg" | "bb") { 100.0 } else { 0.0 }), slider(-200.0, 200.0, -200.0, 200.0, 0)))
.collect();
v.push(p("monochrome", "Monochrome", Value::Bool(false), ParamUi::Checkbox));
v
},
channel_mixer,
),
spec("ec.color.gammapedestalgain", "Gamma/Pedestal/Gain", gpg, gamma_pg),
spec(
"ec.color.colorama",
"Colorama",
vec![
p("phaseShift", "Phase Shift", num(0.0), ParamUi::Angle),
p("cycles", "Cycle Repetitions", num(1.0), slider(0.0, 100.0, 0.0, 10.0, 1)),
p("blend", "Blend With Original", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("mode", "Get Phase From", Value::Enum(0), popup(&["Intensity", "Red", "Green", "Blue", "Hue", "Lightness", "Saturation", "Value", "Alpha"])),
],
colorama,
),
]
}
+28
View File
@@ -0,0 +1,28 @@
//! Expression Controls: parameter-only effects whose values expressions read. They pass pixels
//! through unchanged.
use effectcraft_keyframe::Value;
use effectcraft_project::ParamUi;
use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider};
fn passthrough(_: &EffectCtx, b: Buf) -> Buf {
b
}
fn spec(id: &'static str, name: &'static str, params: Vec<crate::ParamSpec>) -> EffectSpec {
EffectSpec { id, name, category: "Expression Controls", params, render: passthrough, gpu: true, float: true }
}
pub fn specs() -> Vec<EffectSpec> {
vec![
spec("ec.control.slider", "Slider Control", vec![p("slider", "Slider", num(0.0), slider(-1_000_000.0, 1_000_000.0, 0.0, 100.0, 2))]),
spec("ec.control.angle", "Angle Control", vec![p("angle", "Angle", num(0.0), ParamUi::Angle)]),
spec("ec.control.checkbox", "Checkbox Control", vec![p("checkbox", "Checkbox", Value::Bool(false), ParamUi::Checkbox)]),
spec("ec.control.color", "Color Control", vec![p("color", "Color", col(1.0, 0.0, 0.0), ParamUi::Color)]),
spec("ec.control.point", "Point Control", vec![p("point", "Point", Value::Vec2([0.5, 0.5]), ParamUi::Point)]),
spec("ec.control.point3d", "3D Point Control", vec![p("point", "3D Point", Value::Vec3([0.0, 0.0, 0.0]), ParamUi::Point3)]),
spec("ec.control.layer", "Layer Control", vec![p("layer", "Layer", Value::Layer(None), ParamUi::Layer)]),
spec("ec.control.dropdown", "Dropdown Menu Control", vec![p("menu", "Menu", Value::Enum(0), popup(&["Item 1", "Item 2", "Item 3"]))]),
]
}
+325
View File
@@ -0,0 +1,325 @@
//! Distort effects: inverse-mapped warps (each output pixel samples the source).
use effectcraft_geom::{Mat3, vec2};
use rayon::prelude::*;
use effectcraft_keyframe::Value;
use effectcraft_project::ParamUi;
use effectcraft_raster::Image;
use crate::{Buf, EffectCtx, EffectSpec, num, p, popup, slider};
fn spec(id: &'static str, name: &'static str, params: Vec<crate::ParamSpec>, render: crate::RenderFn) -> EffectSpec {
EffectSpec { id, name, category: "Distort", params, render, gpu: false, float: true }
}
/// Resample through `f(x, y) -> source point` (pixel coordinates, centres at +0.5).
fn remap(src: &Image, repeat: bool, f: impl Fn(f64, f64) -> (f64, f64) + Sync) -> Image {
let mut out = Image::new(src.width, src.height);
out.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let (sx, sy) = f(x as f64 + 0.5, y as f64 + 0.5);
*px = if repeat { src.sample_bilinear_clamped(sx, sy) } else { src.sample_bilinear(sx, sy) };
}
});
out
}
fn twirl(ctx: &EffectCtx, mut b: Buf) -> Buf {
let ang = ctx.params.f("angle").to_radians();
let r = (ctx.params.f("radius") / 100.0 * b.img.width.min(b.img.height) as f64 * 0.5).max(1.0);
let c = b.to_px(ctx.params.v2("center"));
b.img = remap(&b.img, false, |x, y| {
let (dx, dy) = (x - c.0, y - c.1);
let d = (dx * dx + dy * dy).sqrt();
if d >= r {
return (x, y);
}
let t = 1.0 - d / r;
let a = -ang * t * t;
let (s, co) = a.sin_cos();
(c.0 + dx * co - dy * s, c.1 + dx * s + dy * co)
});
b
}
fn spherize(ctx: &EffectCtx, mut b: Buf) -> Buf {
let r = (ctx.params.f("radius") * b.scale).max(1.0);
let c = b.to_px(ctx.params.v2("center"));
b.img = remap(&b.img, false, |x, y| {
let (dx, dy) = (x - c.0, y - c.1);
let d = (dx * dx + dy * dy).sqrt();
if d >= r || d == 0.0 {
return (x, y);
}
let nd = d / r;
let k = (nd.asin() / std::f64::consts::FRAC_PI_2) / nd;
(c.0 + dx * k, c.1 + dy * k)
});
b
}
fn bulge(ctx: &EffectCtx, mut b: Buf) -> Buf {
let rx = (ctx.params.f("hradius") * b.scale).max(1.0);
let ry = (ctx.params.f("vradius") * b.scale).max(1.0);
let c = b.to_px(ctx.params.v2("center"));
let h = ctx.params.f("height");
b.img = remap(&b.img, false, |x, y| {
let (dx, dy) = ((x - c.0) / rx, (y - c.1) / ry);
let d2 = dx * dx + dy * dy;
if d2 >= 1.0 {
return (x, y);
}
let k = 1.0 - h * (1.0 - d2) * 0.5;
(c.0 + dx * rx * k, c.1 + dy * ry * k)
});
b
}
fn wave_warp(ctx: &EffectCtx, mut b: Buf) -> Buf {
let h = ctx.params.f("height") * b.scale;
let w = (ctx.params.f("width") * b.scale).max(1.0);
let dir = ctx.params.f("direction").to_radians();
let speed = ctx.params.f("speed");
let phase = ctx.params.f("phase").to_radians() + ctx.time * speed * std::f64::consts::TAU;
let kind = ctx.params.e("waveType");
let (dx, dy) = (dir.sin(), -dir.cos());
if !ctx.adjustment {
b.pad(h.abs().ceil() as u32 + 1);
}
let wave = move |t: f64| -> f64 {
let ph = t.rem_euclid(std::f64::consts::TAU) / std::f64::consts::TAU;
match kind {
1 => if ph < 0.5 { 1.0 } else { -1.0 },
2 => 1.0 - 4.0 * (ph - 0.5).abs(),
3 => 2.0 * ph - 1.0,
_ => t.sin(),
}
};
b.img = remap(&b.img, false, |x, y| {
// Waves travel along `dir`; displacement is perpendicular to it.
let along = x * dx + y * dy;
let disp = wave(along / w * std::f64::consts::TAU + phase) * h;
(x + dy * disp, y - dx * disp)
});
b
}
fn ripple(ctx: &EffectCtx, mut b: Buf) -> Buf {
let r = (ctx.params.f("radius") / 100.0 * b.img.width.max(b.img.height) as f64).max(1.0);
let c = b.to_px(ctx.params.v2("center"));
let w = (ctx.params.f("waveWidth") * b.scale).max(1.0);
let h = ctx.params.f("waveHeight") * b.scale;
let phase = ctx.params.f("phase").to_radians() - ctx.time * ctx.params.f("speed") * std::f64::consts::TAU;
b.img = remap(&b.img, false, |x, y| {
let (dx, dy) = (x - c.0, y - c.1);
let d = (dx * dx + dy * dy).sqrt();
if d >= r || d == 0.0 {
return (x, y);
}
let fall = 1.0 - d / r;
let o = (d / w * std::f64::consts::TAU + phase).sin() * h * fall;
(x + dx / d * o, y + dy / d * o)
});
b
}
fn mirror(ctx: &EffectCtx, mut b: Buf) -> Buf {
let c = b.to_px(ctx.params.v2("center"));
let a = ctx.params.f("angle").to_radians();
let n = (a.cos(), a.sin());
b.img = remap(&b.img, false, |x, y| {
let d = (x - c.0) * n.0 + (y - c.1) * n.1;
if d > 0.0 { (x - 2.0 * d * n.0, y - 2.0 * d * n.1) } else { (x, y) }
});
b
}
fn offset(ctx: &EffectCtx, mut b: Buf) -> Buf {
let shift = ctx.params.v2("shift");
let (w, h) = (b.img.width as f64, b.img.height as f64);
let blend = ctx.params.f("blend") as f32 / 100.0;
let (cx, cy) = (w / 2.0, h / 2.0);
let (sx, sy) = b.to_px(shift);
let orig = b.img.clone();
let mut o = remap(&b.img, false, |x, y| ((x - (sx - cx)).rem_euclid(w), (y - (sy - cy)).rem_euclid(h)));
if blend > 0.0 {
for (p, q) in o.data.iter_mut().zip(&orig.data) {
for i in 0..4 {
p[i] += (q[i] - p[i]) * blend;
}
}
}
b.img = o;
b
}
fn polar(ctx: &EffectCtx, mut b: Buf) -> Buf {
let amt = ctx.params.f("interpolation") / 100.0;
let to_polar = ctx.params.e("conversion") == 1;
let (w, h) = (b.img.width as f64, b.img.height as f64);
let (cx, cy) = (w / 2.0, h / 2.0);
let rmax = cx.min(cy);
b.img = remap(&b.img, true, |x, y| {
let (px, py) = if to_polar {
// output is polar: angle → x, radius → y
let dx = x - cx;
let dy = y - cy;
let a = dx.atan2(-dy).rem_euclid(std::f64::consts::TAU);
let r = (dx * dx + dy * dy).sqrt() / rmax;
(a / std::f64::consts::TAU * w, h - r * h)
} else {
let a = x / w * std::f64::consts::TAU;
let r = (h - y) / h * rmax;
(cx + r * a.sin(), cy - r * a.cos())
};
(x + (px - x) * amt, y + (py - y) * amt)
});
b
}
fn transform(ctx: &EffectCtx, mut b: Buf) -> Buf {
let anchor = b.to_px(ctx.params.v2("anchor"));
let pos = b.to_px(ctx.params.v2("position"));
let uniform = ctx.params.b("uniform");
let sh = ctx.params.f("scaleHeight");
let sw = if uniform { sh } else { ctx.params.f("scaleWidth") };
let skew = ctx.params.f("skew");
let skew_axis = ctx.params.f("skewAxis");
let rot = ctx.params.f("rotation");
let opacity = ctx.params.f("opacity") as f32 / 100.0;
let m = Mat3::translate(vec2(pos.0, pos.1))
* Mat3::rotate_deg(rot)
* Mat3::skew_deg(skew, skew_axis)
* Mat3::scale(vec2(sw / 100.0, sh / 100.0))
* Mat3::translate(vec2(-anchor.0, -anchor.1));
let mut out = Image::new(b.img.width, b.img.height);
effectcraft_raster::composite_warp(&mut out, &b.img, &m, &effectcraft_raster::WarpOpts { opacity, ..Default::default() });
b.img = out;
b
}
fn corner_pin(ctx: &EffectCtx, mut b: Buf) -> Buf {
let (w, h) = (b.layer_w(ctx), b.layer_h(ctx));
let q = ["ul", "ur", "lr", "ll"].map(|k| {
let p = b.to_px(ctx.params.v2(k));
vec2(p.0, p.1)
});
let quad = Mat3::square_to_quad(q);
let o = b.to_px([0.0, 0.0]);
let m = quad * Mat3::scale(vec2(1.0 / (w * b.scale), 1.0 / (h * b.scale))) * Mat3::translate(vec2(-o.0, -o.1));
let mut out = Image::new(b.img.width, b.img.height);
effectcraft_raster::composite_warp(&mut out, &b.img, &m, &Default::default());
b.img = out;
b
}
impl Buf {
fn layer_w(&self, ctx: &EffectCtx) -> f64 {
ctx.layer_size[0].max(1.0)
}
fn layer_h(&self, ctx: &EffectCtx) -> f64 {
ctx.layer_size[1].max(1.0)
}
}
pub fn specs() -> Vec<EffectSpec> {
let pt = |x, y| Value::Vec2([x, y]);
vec![
spec(
"ec.distort.twirl",
"Twirl",
vec![
p("angle", "Angle", num(0.0), ParamUi::Angle),
p("radius", "Twirl Radius", num(30.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("center", "Twirl Center", pt(0.5, 0.5), ParamUi::Point),
],
twirl,
),
spec(
"ec.distort.spherize",
"Spherize",
vec![p("radius", "Radius", num(0.0), slider(0.0, 2500.0, 0.0, 2500.0, 1)), p("center", "Center of Sphere", pt(0.5, 0.5), ParamUi::Point)],
spherize,
),
spec(
"ec.distort.bulge",
"Bulge",
vec![
p("hradius", "Horizontal Radius", num(50.0), slider(0.0, 4000.0, 0.0, 1000.0, 1)),
p("vradius", "Vertical Radius", num(50.0), slider(0.0, 4000.0, 0.0, 1000.0, 1)),
p("center", "Bulge Center", pt(0.5, 0.5), ParamUi::Point),
p("height", "Bulge Height", num(1.0), slider(-4.0, 4.0, -4.0, 4.0, 2)),
],
bulge,
),
spec(
"ec.distort.wavewarp",
"Wave Warp",
vec![
p("waveType", "Wave Type", Value::Enum(0), popup(&["Sine", "Square", "Triangle", "Sawtooth"])),
p("height", "Wave Height", num(10.0), slider(-1000.0, 1000.0, -100.0, 100.0, 0)),
p("width", "Wave Width", num(40.0), slider(1.0, 10000.0, 1.0, 500.0, 0)),
p("direction", "Direction", num(90.0), ParamUi::Angle),
p("speed", "Wave Speed", num(1.0), slider(-100.0, 100.0, -5.0, 5.0, 1)),
p("phase", "Phase", num(0.0), ParamUi::Angle),
],
wave_warp,
),
spec(
"ec.distort.ripple",
"Ripple",
vec![
p("radius", "Radius", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("center", "Center of Ripple", pt(0.5, 0.5), ParamUi::Point),
p("waveWidth", "Wave Width", num(20.0), slider(1.0, 1000.0, 1.0, 100.0, 1)),
p("waveHeight", "Wave Height", num(10.0), slider(0.0, 400.0, 0.0, 100.0, 1)),
p("speed", "Wave Speed", num(1.0), slider(-15.0, 15.0, -5.0, 5.0, 1)),
p("phase", "Ripple Phase", num(0.0), ParamUi::Angle),
],
ripple,
),
spec("ec.distort.mirror", "Mirror", vec![p("center", "Reflection Center", pt(0.5, 0.5), ParamUi::Point), p("angle", "Reflection Angle", num(0.0), ParamUi::Angle)], mirror),
spec(
"ec.distort.offset",
"Offset",
vec![p("shift", "Shift Center To", pt(0.5, 0.5), ParamUi::Point), p("blend", "Blend With Original", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1))],
offset,
),
spec(
"ec.distort.polar",
"Polar Coordinates",
vec![
p("interpolation", "Interpolation", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("conversion", "Type of Conversion", Value::Enum(0), popup(&["Rect to Polar", "Polar to Rect"])),
],
polar,
),
spec(
"ec.distort.transform",
"Transform",
vec![
p("anchor", "Anchor Point", pt(0.5, 0.5), ParamUi::Point),
p("position", "Position", pt(0.5, 0.5), ParamUi::Point),
p("uniform", "Uniform Scale", Value::Bool(true), ParamUi::Checkbox),
p("scaleHeight", "Scale Height", num(100.0), slider(-10000.0, 10000.0, 0.0, 600.0, 1)),
p("scaleWidth", "Scale Width", num(100.0), slider(-10000.0, 10000.0, 0.0, 600.0, 1)),
p("skew", "Skew", num(0.0), slider(-70.0, 70.0, -70.0, 70.0, 1)),
p("skewAxis", "Skew Axis", num(0.0), ParamUi::Angle),
p("rotation", "Rotation", num(0.0), ParamUi::Angle),
p("opacity", "Opacity", num(100.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
],
transform,
),
spec(
"ec.distort.cornerpin",
"Corner Pin",
vec![
p("ul", "Upper Left", pt(0.0, 0.0), ParamUi::Point),
p("ur", "Upper Right", pt(1.0, 0.0), ParamUi::Point),
p("ll", "Lower Left", pt(0.0, 1.0), ParamUi::Point),
p("lr", "Lower Right", pt(1.0, 1.0), ParamUi::Point),
],
corner_pin,
),
]
}
+316
View File
@@ -0,0 +1,316 @@
//! Generate and Noise effects that synthesise images.
use rayon::prelude::*;
use effectcraft_keyframe::Value;
use effectcraft_project::ParamUi;
use effectcraft_raster::Px;
use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider};
fn spec(id: &'static str, name: &'static str, category: &'static str, params: Vec<crate::ParamSpec>, render: crate::RenderFn) -> EffectSpec {
EffectSpec { id, name, category, params, render, gpu: false, float: true }
}
/// Blend a generated straight colour `g` (with alpha `ga`) over the original pixel.
fn put(px: &mut Px, g: [f32; 3], ga: f32, blend_orig: f32) {
let g2 = [g[0] * ga, g[1] * ga, g[2] * ga, ga];
let k = 1.0 - blend_orig;
for c in 0..4 {
px[c] = px[c] * blend_orig + g2[c] * k;
}
}
fn gradient_ramp(ctx: &EffectCtx, mut b: Buf) -> Buf {
let s = b.to_px(ctx.params.v2("start"));
let e = b.to_px(ctx.params.v2("end"));
let c0 = ctx.params.color("startColor");
let c1 = ctx.params.color("endColor");
let radial = ctx.params.e("shape") == 1;
let blend = ctx.params.f("blend") as f32 / 100.0;
let scatter = ctx.params.f("scatter") as f32 / 512.0;
let (dx, dy) = (e.0 - s.0, e.1 - s.1);
let len2 = (dx * dx + dy * dy).max(1e-9);
let seed = ctx.seed;
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let (vx, vy) = (x as f64 + 0.5 - s.0, y as f64 + 0.5 - s.1);
let mut t = if radial { ((vx * vx + vy * vy) / len2).sqrt() } else { (vx * dx + vy * dy) / len2 } as f32;
if scatter > 0.0 {
t += (effectcraft_raster::hash_noise(x as u32, y as u32, seed) - 0.5) * scatter;
}
let t = t.clamp(0.0, 1.0);
let c = [c0[0] + (c1[0] - c0[0]) * t, c0[1] + (c1[1] - c0[1]) * t, c0[2] + (c1[2] - c0[2]) * t];
put(px, c, 1.0, blend);
}
});
b
}
fn four_color(ctx: &EffectCtx, mut b: Buf) -> Buf {
let pts: Vec<((f64, f64), [f32; 4])> = (1..=4).map(|i| (b.to_px(ctx.params.v2(&format!("point{i}"))), ctx.params.color(&format!("color{i}")))).collect();
let blend = ctx.params.f("blend").max(1.0);
let op = ctx.params.f("opacity") as f32 / 100.0;
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let mut acc = [0.0f64; 3];
let mut wsum = 0.0;
for ((qx, qy), c) in &pts {
let d2 = (x as f64 + 0.5 - qx).powi(2) + (y as f64 + 0.5 - qy).powi(2);
let w = 1.0 / (d2 / (blend * 100.0) + 1e-6).powf(1.5);
for i in 0..3 {
acc[i] += c[i] as f64 * w;
}
wsum += w;
}
let c = acc.map(|v| (v / wsum) as f32);
put(px, c, 1.0, 1.0 - op);
}
});
b
}
fn checkerboard(ctx: &EffectCtx, mut b: Buf) -> Buf {
let anchor = b.to_px(ctx.params.v2("anchor"));
let w = (ctx.params.f("width") * b.scale).max(1.0);
let h = (ctx.params.f("height") * b.scale).max(1.0);
let c = ctx.params.color("color");
let op = ctx.params.f("opacity") as f32 / 100.0;
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let cx = ((x as f64 + 0.5 - anchor.0) / w).floor() as i64;
let cy = ((y as f64 + 0.5 - anchor.1) / h).floor() as i64;
if (cx + cy).rem_euclid(2) == 0 {
put(px, [c[0], c[1], c[2]], op, 0.0);
}
}
});
b
}
fn grid(ctx: &EffectCtx, mut b: Buf) -> Buf {
let anchor = b.to_px(ctx.params.v2("anchor"));
let w = (ctx.params.f("width") * b.scale).max(1.0);
let h = (ctx.params.f("height") * b.scale).max(1.0);
let border = (ctx.params.f("border") * b.scale).max(0.0);
let c = ctx.params.color("color");
let op = ctx.params.f("opacity") as f32 / 100.0;
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let fx = (x as f64 + 0.5 - anchor.0).rem_euclid(w);
let fy = (y as f64 + 0.5 - anchor.1).rem_euclid(h);
let d = fx.min(w - fx).min(fy.min(h - fy));
let cov = ((border / 2.0 - d) + 0.5).clamp(0.0, 1.0) as f32;
if cov > 0.0 {
let mut g = *px;
put(&mut g, [c[0], c[1], c[2]], op, 0.0);
for i in 0..4 {
px[i] += (g[i] - px[i]) * cov;
}
}
}
});
b
}
fn circle(ctx: &EffectCtx, mut b: Buf) -> Buf {
let c = b.to_px(ctx.params.v2("center"));
let r = ctx.params.f("radius") * b.scale;
let feather = (ctx.params.f("feather") * b.scale).max(0.5);
let color = ctx.params.color("color");
let op = ctx.params.f("opacity") as f32 / 100.0;
let invert = ctx.params.b("invert");
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let d = ((x as f64 + 0.5 - c.0).powi(2) + (y as f64 + 0.5 - c.1).powi(2)).sqrt();
let mut cov = ((r - d) / feather + 0.5).clamp(0.0, 1.0) as f32;
if invert {
cov = 1.0 - cov;
}
if cov > 0.0 {
let mut g = *px;
put(&mut g, [color[0], color[1], color[2]], op, 0.0);
for i in 0..4 {
px[i] += (g[i] - px[i]) * cov;
}
}
}
});
b
}
// ---- value noise / fBm (our own lattice noise with quintic fade) ----
#[inline]
fn lattice(ix: i32, iy: i32, iz: i32, seed: u32) -> f32 {
let mut h = (ix as u32).wrapping_mul(0x27d4_eb2d) ^ (iy as u32).wrapping_mul(0x1656_67b1) ^ (iz as u32).wrapping_mul(0x9e37_79b9) ^ seed.wrapping_mul(0x85eb_ca6b);
h ^= h >> 15;
h = h.wrapping_mul(0x2c1b_3c6d);
h ^= h >> 12;
h = h.wrapping_mul(0x297a_2d39);
h ^= h >> 15;
(h & 0xffff) as f32 / 65535.0
}
#[inline]
fn fade(t: f32) -> f32 {
t * t * t * (t * (t * 6.0 - 15.0) + 10.0)
}
/// 3D value noise in 0..1 (z = evolution).
pub fn value_noise(x: f32, y: f32, z: f32, seed: u32) -> f32 {
let (x0, y0, z0) = (x.floor(), y.floor(), z.floor());
let (fx, fy, fz) = (fade(x - x0), fade(y - y0), fade(z - z0));
let (ix, iy, iz) = (x0 as i32, y0 as i32, z0 as i32);
let l = |a: f32, b: f32, t: f32| a + (b - a) * t;
let c = |dx: i32, dy: i32, dz: i32| lattice(ix + dx, iy + dy, iz + dz, seed);
let a = l(l(c(0, 0, 0), c(1, 0, 0), fx), l(c(0, 1, 0), c(1, 1, 0), fx), fy);
let b = l(l(c(0, 0, 1), c(1, 0, 1), fx), l(c(0, 1, 1), c(1, 1, 1), fx), fy);
l(a, b, fz)
}
fn fractal_noise(ctx: &EffectCtx, mut b: Buf) -> Buf {
let kind = ctx.params.e("fractalType");
let contrast = ctx.params.f("contrast") as f32 / 100.0;
let brightness = ctx.params.f("brightness") as f32 / 100.0;
let invert = ctx.params.b("invert");
let scale = (ctx.params.f("scale") * b.scale).max(1.0) as f32;
let off = ctx.params.v2("offset");
let rot = (ctx.params.f("rotation") as f32).to_radians();
let octaves = ctx.params.f("complexity").clamp(1.0, 20.0);
let evo = ctx.params.f("evolution") as f32 / 360.0;
let seed = ctx.params.f("seed") as u32 ^ 0x51ed;
let blend = ctx.params.f("blend") as f32 / 100.0;
let opacity = ctx.params.f("opacity") as f32 / 100.0;
let (sr, cr) = rot.sin_cos();
let n_oct = octaves.ceil() as usize;
let frac = (octaves - octaves.floor()) as f32;
let (ox, oy) = b.to_px(off);
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let (dx, dy) = (x as f32 + 0.5 - ox as f32, y as f32 + 0.5 - oy as f32);
let (u, v) = ((dx * cr + dy * sr) / scale, (-dx * sr + dy * cr) / scale);
let mut sum = 0.0;
let mut amp = 1.0;
let mut norm = 0.0;
let mut f = 1.0;
for o in 0..n_oct {
let w = if o + 1 == n_oct && frac > 0.0 { frac } else { 1.0 };
let mut n = value_noise(u * f, v * f, evo + o as f32 * 7.31, seed + o as u32);
if kind == 1 {
n = 1.0 - (n * 2.0 - 1.0).abs(); // turbulent / ridged
}
sum += n * amp * w;
norm += amp * w;
amp *= 0.5;
f *= 2.0;
}
let mut val = sum / norm.max(1e-6);
val = ((val - 0.5) * contrast + 0.5 + brightness).clamp(0.0, 1.0);
if invert {
val = 1.0 - val;
}
put(px, [val, val, val], opacity, blend);
}
});
b
}
pub fn specs() -> Vec<EffectSpec> {
let pt = |x, y| Value::Vec2([x, y]);
vec![
spec(
"ec.generate.gradientramp",
"Gradient Ramp",
"Generate",
vec![
p("start", "Start of Ramp", pt(0.5, 0.0), ParamUi::Point),
p("startColor", "Start Color", col(0.0, 0.0, 0.0), ParamUi::Color),
p("end", "End of Ramp", pt(0.5, 1.0), ParamUi::Point),
p("endColor", "End Color", col(1.0, 1.0, 1.0), ParamUi::Color),
p("shape", "Ramp Shape", Value::Enum(0), popup(&["Linear Ramp", "Radial Ramp"])),
p("scatter", "Ramp Scatter", num(0.0), slider(0.0, 512.0, 0.0, 512.0, 1)),
p("blend", "Blend With Original", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
],
gradient_ramp,
),
spec(
"ec.generate.fourcolor",
"4-Color Gradient",
"Generate",
vec![
p("point1", "Point 1", pt(0.1, 0.1), ParamUi::Point),
p("color1", "Color 1", col(1.0, 1.0, 0.0), ParamUi::Color),
p("point2", "Point 2", pt(0.9, 0.1), ParamUi::Point),
p("color2", "Color 2", col(0.0, 1.0, 0.0), ParamUi::Color),
p("point3", "Point 3", pt(0.1, 0.9), ParamUi::Point),
p("color3", "Color 3", col(1.0, 0.0, 1.0), ParamUi::Color),
p("point4", "Point 4", pt(0.9, 0.9), ParamUi::Point),
p("color4", "Color 4", col(0.0, 0.0, 1.0), ParamUi::Color),
p("blend", "Blend", num(100.0), slider(1.0, 1000.0, 1.0, 1000.0, 1)),
p("opacity", "Opacity", num(100.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
],
four_color,
),
spec(
"ec.generate.checkerboard",
"Checkerboard",
"Generate",
vec![
p("anchor", "Anchor", pt(0.0, 0.0), ParamUi::Point),
p("width", "Width", num(64.0), slider(1.0, 4000.0, 1.0, 400.0, 1)),
p("height", "Height", num(64.0), slider(1.0, 4000.0, 1.0, 400.0, 1)),
p("color", "Color", col(1.0, 1.0, 1.0), ParamUi::Color),
p("opacity", "Opacity", num(100.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
],
checkerboard,
),
spec(
"ec.generate.grid",
"Grid",
"Generate",
vec![
p("anchor", "Anchor", pt(0.0, 0.0), ParamUi::Point),
p("width", "Width", num(64.0), slider(1.0, 4000.0, 1.0, 400.0, 1)),
p("height", "Height", num(64.0), slider(1.0, 4000.0, 1.0, 400.0, 1)),
p("border", "Border", num(2.0), slider(0.0, 400.0, 0.0, 40.0, 1)),
p("color", "Color", col(1.0, 1.0, 1.0), ParamUi::Color),
p("opacity", "Opacity", num(100.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
],
grid,
),
spec(
"ec.generate.circle",
"Circle",
"Generate",
vec![
p("center", "Center", pt(0.5, 0.5), ParamUi::Point),
p("radius", "Radius", num(75.0), slider(0.0, 4000.0, 0.0, 1000.0, 1)),
p("feather", "Feather Outer Edge", num(0.0), slider(0.0, 1000.0, 0.0, 100.0, 1)),
p("invert", "Invert Circle", Value::Bool(false), ParamUi::Checkbox),
p("color", "Color", col(1.0, 1.0, 1.0), ParamUi::Color),
p("opacity", "Opacity", num(100.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
],
circle,
),
spec(
"ec.noise.fractal",
"Fractal Noise",
"Noise & Grain",
vec![
p("fractalType", "Fractal Type", Value::Enum(0), popup(&["Basic", "Turbulent Smooth"])),
p("invert", "Invert", Value::Bool(false), ParamUi::Checkbox),
p("contrast", "Contrast", num(100.0), slider(0.0, 10000.0, 0.0, 400.0, 1)),
p("brightness", "Brightness", num(0.0), slider(-10000.0, 10000.0, -200.0, 200.0, 1)),
p("scale", "Scale", num(100.0), slider(1.0, 10000.0, 20.0, 600.0, 1)),
p("offset", "Offset Turbulence", pt(0.5, 0.5), ParamUi::Point),
p("rotation", "Rotation", num(0.0), ParamUi::Angle),
p("complexity", "Complexity", num(6.0), slider(1.0, 20.0, 1.0, 10.0, 1)),
p("evolution", "Evolution", num(0.0), ParamUi::Angle),
p("seed", "Random Seed", num(0.0), slider(0.0, 100000.0, 0.0, 1000.0, 0)),
p("opacity", "Opacity", num(100.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("blend", "Blend With Original", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
],
fractal_noise,
),
]
}
+236
View File
@@ -0,0 +1,236 @@
//! The effects library.
//!
//! Every effect is an [`EffectSpec`]: a stable id (`ec.blur.gaussian`), its display name and
//! category (the Effects & Presets tree), typed parameters with defaults and UI hints, and a CPU
//! render function. Effect instances on layers are ordinary property groups (see
//! [`instantiate`]), so parameters animate, take expressions and are addressable like any other
//! property.
mod color_fx;
mod controls;
mod distort;
mod generate;
mod misc;
use std::collections::HashMap;
use std::sync::OnceLock;
use effectcraft_keyframe::Value;
use effectcraft_project::build::Ids;
use effectcraft_project::{GroupKind, ParamUi, PropGroup, Property};
pub use effectcraft_raster::Image;
/// Effect categories in Effects & Presets order.
pub const CATEGORIES: &[&str] = &[
"3D Channel",
"Audio",
"Blur & Sharpen",
"Channel",
"Color Correction",
"Distort",
"Expression Controls",
"Generate",
"Immersive Video",
"Keying",
"Matte",
"Noise & Grain",
"Obsolete",
"Perspective",
"Simulation",
"Stylize",
"Text",
"Time",
"Transition",
"Utility",
];
pub struct ParamSpec {
/// Point defaults are fractions of the layer size (0.5, 0.5 = layer centre).
pub id: &'static str,
pub name: &'static str,
pub default: Value,
pub ui: ParamUi,
}
/// Parameter values evaluated at the current time.
#[derive(Clone, Debug, Default)]
pub struct Params {
pub values: HashMap<String, Value>,
}
impl Params {
pub fn get(&self, id: &str) -> Option<&Value> {
self.values.get(id)
}
pub fn f(&self, id: &str) -> f64 {
self.values.get(id).map(Value::as_f64).unwrap_or(0.0)
}
pub fn v2(&self, id: &str) -> [f64; 2] {
self.values.get(id).map(Value::as_vec2).unwrap_or([0.0; 2])
}
pub fn color(&self, id: &str) -> [f32; 4] {
self.values.get(id).map(Value::as_color).unwrap_or([1.0; 4])
}
pub fn b(&self, id: &str) -> bool {
self.values.get(id).map(Value::as_bool).unwrap_or(false)
}
pub fn e(&self, id: &str) -> u32 {
self.values.get(id).map(Value::as_enum).unwrap_or(0)
}
}
/// A layer image in flight: layer-space point `p` sits at pixel `p * scale + offset`.
#[derive(Clone, Debug, Default)]
pub struct Buf {
pub img: Image,
pub offset: [f64; 2],
pub scale: f64,
}
impl Buf {
pub fn to_px(&self, p: [f64; 2]) -> (f64, f64) {
(p[0] * self.scale + self.offset[0], p[1] * self.scale + self.offset[1])
}
/// Grow by `pad` transparent pixels on every side.
pub fn pad(&mut self, pad: u32) {
if pad == 0 {
return;
}
self.img = self.img.padded(pad);
self.offset[0] += pad as f64;
self.offset[1] += pad as f64;
}
}
/// What an effect gets to render with.
pub struct EffectCtx<'a> {
pub params: &'a Params,
/// Layer time in seconds.
pub time: f64,
/// Layer source size in layer pixels (the effect's "layer bounds").
pub layer_size: [f64; 2],
/// Instance seed (stable per effect instance).
pub seed: u32,
/// Rendering for an adjustment layer (operating on the comp below).
pub adjustment: bool,
}
pub type RenderFn = fn(&EffectCtx, Buf) -> Buf;
pub struct EffectSpec {
pub id: &'static str,
pub name: &'static str,
pub category: &'static str,
pub params: Vec<ParamSpec>,
pub render: RenderFn,
/// Implemented on the GPU path too (shown with a badge).
pub gpu: bool,
/// Supports 32 bpc float (badge).
pub float: bool,
}
pub(crate) fn p(id: &'static str, name: &'static str, default: Value, ui: ParamUi) -> ParamSpec {
ParamSpec { id, name, default, ui }
}
pub(crate) fn slider(min: f64, max: f64, smin: f64, smax: f64, decimals: u8) -> ParamUi {
ParamUi::Slider { min, max, slider_min: smin, slider_max: smax, decimals }
}
pub(crate) fn popup(opts: &[&str]) -> ParamUi {
ParamUi::Popup { options: opts.iter().map(|s| s.to_string()).collect() }
}
pub(crate) fn num(v: f64) -> Value {
Value::Scalar(v)
}
pub(crate) fn col(r: f64, g: f64, b: f64) -> Value {
Value::Color([r, g, b, 1.0])
}
/// All effects, sorted by category then name.
pub fn registry() -> &'static [EffectSpec] {
static R: OnceLock<Vec<EffectSpec>> = OnceLock::new();
R.get_or_init(|| {
let mut v = Vec::new();
v.extend(misc::specs());
v.extend(color_fx::specs());
v.extend(generate::specs());
v.extend(distort::specs());
v.extend(controls::specs());
v.sort_by(|a, b| a.category.cmp(b.category).then(a.name.cmp(b.name)));
v
})
}
pub fn find(id: &str) -> Option<&'static EffectSpec> {
registry().iter().find(|s| s.id == id)
}
/// Find by id or (case-insensitive) display name.
pub fn lookup(name_or_id: &str) -> Option<&'static EffectSpec> {
find(name_or_id).or_else(|| registry().iter().find(|s| s.name.eq_ignore_ascii_case(name_or_id)))
}
/// Build the property group for a new instance of an effect.
pub fn instantiate(spec: &EffectSpec, ids: &mut Ids, instance_name: &str, layer_size: [f64; 2]) -> PropGroup {
let mut g = ids.group(spec.id, instance_name);
g.kind = GroupKind::Effect { effect: spec.id.to_string() };
for ps in &spec.params {
let default = match (&ps.ui, &ps.default) {
(ParamUi::Point, Value::Vec2(f)) => Value::Vec2([f[0] * layer_size[0], f[1] * layer_size[1]]),
_ => ps.default.clone(),
};
let mut pr = Property::new(ids.alloc(), ps.id, ps.name, default).with_ui(ps.ui.clone());
if matches!(ps.ui, ParamUi::Point) {
pr.spatial = true;
}
if matches!(ps.ui, ParamUi::Checkbox | ParamUi::Popup { .. } | ParamUi::Layer) {
pr.hold_only = true;
}
g.children.push(pr.into());
}
g
}
/// Run an effect on a buffer.
pub fn apply(spec: &EffectSpec, ctx: &EffectCtx, buf: Buf) -> Buf {
(spec.render)(ctx, buf)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn registry_is_consistent() {
let r = registry();
assert!(r.len() >= 40, "{}", r.len());
let mut ids = std::collections::HashSet::new();
for s in r {
assert!(ids.insert(s.id), "duplicate {}", s.id);
assert!(CATEGORIES.contains(&s.category), "{} in unknown category {}", s.id, s.category);
let mut pids = std::collections::HashSet::new();
for p in &s.params {
assert!(pids.insert(p.id), "{}: duplicate param {}", s.id, p.id);
}
}
}
/// Every effect runs on a small image with its default parameters without panicking and
/// keeps alpha in range.
#[test]
fn all_effects_run_with_defaults() {
let mut img = Image::new(48, 32);
for y in 0..32 {
for x in 0..48 {
let a = if (8..40).contains(&x) && (6..26).contains(&y) { 1.0 } else { 0.0 };
img.set(x, y, [x as f32 / 48.0 * a, y as f32 / 32.0 * a, 0.5 * a, a]);
}
}
for s in registry() {
let params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
let ctx = EffectCtx { params: &params, time: 0.5, layer_size: [48.0, 32.0], seed: 1, adjustment: false };
let out = apply(s, &ctx, Buf { img: img.clone(), offset: [0.0, 0.0], scale: 1.0 });
assert!(!out.img.is_empty(), "{}", s.id);
assert!(out.img.data.iter().all(|p| p[3].is_finite() && p[3] >= -1e-4 && p[3] <= 1.0001), "{} alpha out of range", s.id);
}
}
}
+537
View File
@@ -0,0 +1,537 @@
//! Blur & Sharpen, Stylize, Perspective, Channel, Noise and Transition effects.
use effectcraft_color::luminance;
use effectcraft_keyframe::Value;
use effectcraft_project::ParamUi;
use effectcraft_raster::{Image, gaussian_blur, hash_noise};
use rayon::prelude::*;
use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider};
fn spec(id: &'static str, name: &'static str, category: &'static str, params: Vec<crate::ParamSpec>, render: crate::RenderFn) -> EffectSpec {
EffectSpec { id, name, category, params, render, gpu: false, float: true }
}
/// AE's Blurriness is roughly a radius; our Gaussian uses sigma = blurriness / 2.
fn blur_sigma(blurriness: f64) -> f64 {
blurriness.max(0.0) * 0.5
}
fn dims_xy(dim: u32) -> (f64, f64) {
match dim {
1 => (1.0, 0.0),
2 => (0.0, 1.0),
_ => (1.0, 1.0),
}
}
fn gaussian(ctx: &EffectCtx, mut b: Buf) -> Buf {
let s = blur_sigma(ctx.params.f("blurriness")) * b.scale;
if s <= 0.0 {
return b;
}
let (kx, ky) = dims_xy(ctx.params.e("dimensions"));
let repeat = ctx.params.b("repeatEdge");
if !repeat && !ctx.adjustment {
b.pad((s * 3.0).ceil() as u32);
}
b.img = gaussian_blur(&b.img, s * kx, s * ky, repeat || ctx.adjustment);
b
}
fn box_blur(ctx: &EffectCtx, mut b: Buf) -> Buf {
let r = (ctx.params.f("radius") * b.scale).round().max(0.0) as usize;
if r == 0 {
return b;
}
let it = ctx.params.f("iterations").round().clamp(1.0, 50.0) as usize;
let (kx, ky) = dims_xy(ctx.params.e("dimensions"));
let repeat = ctx.params.b("repeatEdge");
if !repeat && !ctx.adjustment {
b.pad((r * it) as u32 + 1);
}
b.img = effectcraft_raster::box_blur(&b.img, r * kx as usize, r * ky as usize, it, repeat || ctx.adjustment);
b
}
fn directional(ctx: &EffectCtx, mut b: Buf) -> Buf {
let len = ctx.params.f("length") * b.scale;
if len < 0.5 {
return b;
}
if !ctx.adjustment {
b.pad(len.ceil() as u32 + 1);
}
b.img = effectcraft_raster::directional_blur(&b.img, ctx.params.f("direction"), len);
b
}
fn radial(ctx: &EffectCtx, mut b: Buf) -> Buf {
let amt = ctx.params.f("amount");
let zoom = ctx.params.e("type") == 1;
let c = b.to_px(ctx.params.v2("center"));
b.img = effectcraft_raster::radial_blur(&b.img, c, if zoom { amt / 100.0 } else { amt }, zoom);
b
}
fn unsharp_core(img: &Image, amount: f32, sigma: f64, threshold: f32) -> Image {
let blurred = gaussian_blur(img, sigma, sigma, true);
let mut out = img.clone();
out.data.par_iter_mut().zip(blurred.data.par_iter()).for_each(|(o, bl)| {
for c in 0..3 {
let d = o[c] - bl[c];
if d.abs() * 255.0 >= threshold {
o[c] = (o[c] + d * amount).max(0.0);
}
}
});
out
}
fn sharpen(ctx: &EffectCtx, mut b: Buf) -> Buf {
let a = ctx.params.f("amount") as f32 / 50.0;
b.img = unsharp_core(&b.img, a, 1.0 * b.scale, 0.0);
b
}
fn unsharp(ctx: &EffectCtx, mut b: Buf) -> Buf {
let a = ctx.params.f("amount") as f32 / 100.0;
b.img = unsharp_core(&b.img, a, (ctx.params.f("radius") * b.scale).max(0.1), ctx.params.f("threshold") as f32);
b
}
fn glow(ctx: &EffectCtx, mut b: Buf) -> Buf {
let thr = ctx.params.f("threshold") as f32 / 100.0;
let radius = ctx.params.f("radius") * b.scale;
let intensity = ctx.params.f("intensity") as f32;
let use_colors = ctx.params.e("colors") == 1;
let ca = ctx.params.color("colorA");
let cb = ctx.params.color("colorB");
if !ctx.adjustment {
b.pad((radius * 1.5).ceil() as u32 + 2);
}
// Bright pass.
let mut bright = b.img.clone();
bright.data.par_iter_mut().for_each(|px| {
let a = px[3];
if a <= 0.0 {
*px = [0.0; 4];
return;
}
let l = luminance(px[0] / a, px[1] / a, px[2] / a);
let k = ((l - thr) / (1.0 - thr).max(1e-3)).clamp(0.0, 1.0);
if use_colors {
let t = l.clamp(0.0, 1.0);
let c = [ca[0] + (cb[0] - ca[0]) * t, ca[1] + (cb[1] - ca[1]) * t, ca[2] + (cb[2] - ca[2]) * t];
*px = [c[0] * k * a, c[1] * k * a, c[2] * k * a, k * a];
} else {
*px = px.map(|v| v * k);
}
});
let s = (radius / 2.0).max(0.5);
let blurred = gaussian_blur(&bright, s, s, false);
let composite_behind = ctx.params.e("operation") == 1;
b.img.data.par_iter_mut().zip(blurred.data.par_iter()).for_each(|(o, g)| {
let g = g.map(|v| v * intensity);
if composite_behind {
let k = 1.0 - o[3];
for c in 0..4 {
o[c] += g[c] * k;
}
} else {
// Add (screen-like add on premultiplied colour; alpha grows by the glow's alpha).
for c in 0..3 {
o[c] += g[c];
}
o[3] = (o[3] + g[3] * (1.0 - o[3])).min(1.0);
}
o[3] = o[3].clamp(0.0, 1.0);
});
b
}
fn drop_shadow(ctx: &EffectCtx, mut b: Buf) -> Buf {
let color = ctx.params.color("color");
let opacity = ctx.params.f("opacity") as f32 / 100.0;
let dir = ctx.params.f("direction").to_radians();
let dist = ctx.params.f("distance") * b.scale;
let soft = ctx.params.f("softness") * b.scale;
let only = ctx.params.b("shadowOnly");
let pad = (dist + soft * 1.5).ceil() as u32 + 2;
if !ctx.adjustment {
b.pad(pad);
}
let (dx, dy) = (dir.sin() * dist, -dir.cos() * dist);
let mut sh = Image::new(b.img.width, b.img.height);
let src = &b.img;
sh.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let a = src.sample_bilinear(x as f64 + 0.5 - dx, y as f64 + 0.5 - dy)[3] * opacity * color[3];
*px = [color[0] * a, color[1] * a, color[2] * a, a];
}
});
if soft > 0.0 {
sh = gaussian_blur(&sh, soft / 2.0, soft / 2.0, false);
}
if !only {
sh.data.par_iter_mut().zip(b.img.data.par_iter()).for_each(|(s, o)| {
let k = 1.0 - o[3];
for c in 0..4 {
s[c] = o[c] + s[c] * k;
}
});
}
b.img = sh;
b
}
fn invert(ctx: &EffectCtx, mut b: Buf) -> Buf {
let ch = ctx.params.e("channel");
let blend = 1.0 - ctx.params.f("blend") as f32 / 100.0;
b.img.data.par_iter_mut().for_each(|px| {
let a = px[3];
if ch == 4 {
let na = 1.0 - a;
let k = if a > 0.0 { na / a } else { 0.0 };
let inv = [px[0] * k, px[1] * k, px[2] * k, na];
for c in 0..4 {
px[c] += (inv[c] - px[c]) * blend;
}
return;
}
if a <= 0.0 {
return;
}
let s = [px[0] / a, px[1] / a, px[2] / a];
let mut o = s;
match ch {
1 => o[0] = 1.0 - s[0],
2 => o[1] = 1.0 - s[1],
3 => o[2] = 1.0 - s[2],
_ => o = [1.0 - s[0], 1.0 - s[1], 1.0 - s[2]],
}
for c in 0..3 {
px[c] = (s[c] + (o[c] - s[c]) * blend) * a;
}
});
b
}
fn mosaic(ctx: &EffectCtx, mut b: Buf) -> Buf {
let hb = ctx.params.f("horizontal").round().max(1.0) as usize;
let vb = ctx.params.f("vertical").round().max(1.0) as usize;
let (w, h) = (b.img.width as usize, b.img.height as usize);
let cw = (w as f64 / hb as f64).max(1.0);
let ch = (h as f64 / vb as f64).max(1.0);
let src = b.img.clone();
b.img.rows_mut().for_each(|(y, row)| {
let cy = ((y as f64 / ch).floor() + 0.5) * ch;
for (x, px) in row.iter_mut().enumerate() {
let cx = ((x as f64 / cw).floor() + 0.5) * cw;
*px = src.get_clamped(cx as i64, cy as i64);
}
});
b
}
fn posterize(ctx: &EffectCtx, mut b: Buf) -> Buf {
let lv = ctx.params.f("level").round().clamp(2.0, 255.0) as f32 - 1.0;
b.img.map_straight(|c| c.map(|v| (v.clamp(0.0, 1.0) * lv).round() / lv));
b
}
fn threshold(ctx: &EffectCtx, mut b: Buf) -> Buf {
let t = ctx.params.f("level") as f32 / 255.0;
b.img.map_straight(|c| {
let v = if luminance(c[0], c[1], c[2]) >= t { 1.0 } else { 0.0 };
[v, v, v]
});
b
}
fn find_edges(ctx: &EffectCtx, mut b: Buf) -> Buf {
let inv = ctx.params.b("invert");
let src = b.img.clone();
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let l = |dx: i64, dy: i64| {
let p = src.get_clamped(x as i64 + dx, y as i64 + dy);
luminance(p[0], p[1], p[2])
};
let gx = l(1, -1) + 2.0 * l(1, 0) + l(1, 1) - l(-1, -1) - 2.0 * l(-1, 0) - l(-1, 1);
let gy = l(-1, 1) + 2.0 * l(0, 1) + l(1, 1) - l(-1, -1) - 2.0 * l(0, -1) - l(1, -1);
let e = (gx * gx + gy * gy).sqrt().min(1.0);
let v = if inv { e } else { 1.0 - e };
let a = px[3];
*px = [v * a, v * a, v * a, a];
}
});
b
}
fn emboss(ctx: &EffectCtx, mut b: Buf) -> Buf {
let ang = ctx.params.f("direction").to_radians();
let relief = ctx.params.f("relief") * b.scale;
let contrast = ctx.params.f("contrast") as f32 / 100.0;
let (dx, dy) = (ang.cos() * relief, -ang.sin() * relief);
let src = b.img.clone();
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let a = src.sample_bilinear_clamped(x as f64 + 0.5 + dx, y as f64 + 0.5 + dy);
let c = src.sample_bilinear_clamped(x as f64 + 0.5 - dx, y as f64 + 0.5 - dy);
let d = luminance(a[0], a[1], a[2]) - luminance(c[0], c[1], c[2]);
let v = (0.5 + d * contrast).clamp(0.0, 1.0);
let al = px[3];
*px = [v * al, v * al, v * al, al];
}
});
b
}
fn noise(ctx: &EffectCtx, mut b: Buf) -> Buf {
let amt = ctx.params.f("amount") as f32 / 100.0;
let color = ctx.params.b("color");
let frame_seed = (ctx.time * 1000.0) as u32 ^ ctx.seed;
let w = b.img.width;
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let a = px[3];
if a <= 0.0 {
continue;
}
let n = |k: u32| (hash_noise(x as u32 + k * w, y as u32, frame_seed) - 0.5) * amt;
let (r, g, bl) = if color { (n(0), n(1), n(2)) } else { let v = n(0); (v, v, v) };
px[0] = (px[0] + r * a).clamp(0.0, a);
px[1] = (px[1] + g * a).clamp(0.0, a);
px[2] = (px[2] + bl * a).clamp(0.0, a);
}
});
b
}
fn linear_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf {
let done = ctx.params.f("completion") / 100.0;
let ang = ctx.params.f("angle").to_radians();
let feather = (ctx.params.f("feather") * b.scale).max(0.001);
let (w, h) = (b.img.width as f64, b.img.height as f64);
// Direction the wipe travels: angle 90° wipes left→right.
let (dx, dy) = (ang.sin(), -ang.cos());
let corners = [(0.0, 0.0), (w, 0.0), (0.0, h), (w, h)];
let proj: Vec<f64> = corners.iter().map(|(x, y)| x * dx + y * dy).collect();
let (lo, hi) = (proj.iter().cloned().fold(f64::INFINITY, f64::min), proj.iter().cloned().fold(f64::NEG_INFINITY, f64::max));
let edge = lo - feather + (hi - lo + 2.0 * feather) * done;
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let d = (x as f64 + 0.5) * dx + (y as f64 + 0.5) * dy;
let k = ((d - edge) / feather + 0.5).clamp(0.0, 1.0) as f32;
for c in px.iter_mut() {
*c *= k;
}
}
});
b
}
fn radial_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf {
let done = ctx.params.f("completion") / 100.0;
let start = ctx.params.f("startAngle");
let c = b.to_px(ctx.params.v2("center"));
let dir = ctx.params.e("wipe");
let feather = (ctx.params.f("feather")).max(0.01);
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let a = ((x as f64 + 0.5 - c.0).atan2(-(y as f64 + 0.5 - c.1)).to_degrees() - start).rem_euclid(360.0);
let a = if dir == 1 { 360.0 - a } else { a };
let edge = done * 360.0;
let k = ((a - edge) / feather + 0.5).clamp(0.0, 1.0) as f32;
for ch in px.iter_mut() {
*ch *= k;
}
}
});
b
}
fn venetian(ctx: &EffectCtx, mut b: Buf) -> Buf {
let done = ctx.params.f("completion") / 100.0;
let ang = ctx.params.f("direction").to_radians();
let width = (ctx.params.f("width") * b.scale).max(1.0);
let feather = (ctx.params.f("feather") * b.scale).max(0.001);
let (dx, dy) = (ang.cos(), ang.sin());
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let d = ((x as f64 + 0.5) * dx + (y as f64 + 0.5) * dy).rem_euclid(width);
let k = ((d - done * width) / feather + 0.5).clamp(0.0, 1.0) as f32;
for c in px.iter_mut() {
*c *= k;
}
}
});
b
}
pub fn specs() -> Vec<EffectSpec> {
let dims = || popup(&["Horizontal and Vertical", "Horizontal", "Vertical"]);
vec![
spec(
"ec.blur.gaussian",
"Gaussian Blur",
"Blur & Sharpen",
vec![
p("blurriness", "Blurriness", num(0.0), slider(0.0, 3000.0, 0.0, 50.0, 1)),
p("dimensions", "Blur Dimensions", Value::Enum(0), dims()),
p("repeatEdge", "Repeat Edge Pixels", Value::Bool(false), ParamUi::Checkbox),
],
gaussian,
),
spec(
"ec.blur.fastbox",
"Fast Box Blur",
"Blur & Sharpen",
vec![
p("radius", "Blur Radius", num(0.0), slider(0.0, 3000.0, 0.0, 50.0, 1)),
p("iterations", "Iterations", num(3.0), slider(1.0, 50.0, 1.0, 5.0, 0)),
p("dimensions", "Blur Dimensions", Value::Enum(0), dims()),
p("repeatEdge", "Repeat Edge Pixels", Value::Bool(false), ParamUi::Checkbox),
],
box_blur,
),
spec(
"ec.blur.directional",
"Directional Blur",
"Blur & Sharpen",
vec![p("direction", "Direction", num(0.0), ParamUi::Angle), p("length", "Blur Length", num(0.0), slider(0.0, 1000.0, 0.0, 100.0, 1))],
directional,
),
spec(
"ec.blur.radial",
"Radial Blur",
"Blur & Sharpen",
vec![
p("amount", "Amount", num(10.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("center", "Center", Value::Vec2([0.5, 0.5]), ParamUi::Point),
p("type", "Type", Value::Enum(0), popup(&["Spin", "Zoom"])),
],
radial,
),
spec("ec.blur.sharpen", "Sharpen", "Blur & Sharpen", vec![p("amount", "Sharpen Amount", num(0.0), slider(0.0, 500.0, 0.0, 100.0, 0))], sharpen),
spec(
"ec.blur.unsharp",
"Unsharp Mask",
"Blur & Sharpen",
vec![
p("amount", "Amount", num(50.0), slider(0.0, 500.0, 0.0, 500.0, 0)),
p("radius", "Radius", num(1.0), slider(0.1, 500.0, 0.1, 50.0, 1)),
p("threshold", "Threshold", num(0.0), slider(0.0, 255.0, 0.0, 255.0, 0)),
],
unsharp,
),
spec(
"ec.stylize.glow",
"Glow",
"Stylize",
vec![
p("based", "Glow Based On", Value::Enum(1), popup(&["Alpha Channel", "Color Channels"])),
p("threshold", "Glow Threshold", num(60.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
p("radius", "Glow Radius", num(10.0), slider(0.0, 1000.0, 0.0, 100.0, 1)),
p("intensity", "Glow Intensity", num(1.0), slider(0.0, 255.0, 0.0, 4.0, 1)),
p("operation", "Composite Original", Value::Enum(0), popup(&["On Top", "Behind", "None"])),
p("colors", "Glow Colors", Value::Enum(0), popup(&["Original Colors", "A & B Colors"])),
p("colorA", "Color A", col(1.0, 1.0, 1.0), ParamUi::Color),
p("colorB", "Color B", col(0.0, 0.0, 0.0), ParamUi::Color),
],
glow,
),
spec(
"ec.stylize.mosaic",
"Mosaic",
"Stylize",
vec![
p("horizontal", "Horizontal Blocks", num(10.0), slider(1.0, 4000.0, 1.0, 200.0, 0)),
p("vertical", "Vertical Blocks", num(10.0), slider(1.0, 4000.0, 1.0, 200.0, 0)),
],
mosaic,
),
spec("ec.stylize.posterize", "Posterize", "Stylize", vec![p("level", "Level", num(6.0), slider(2.0, 255.0, 2.0, 32.0, 0))], posterize),
spec("ec.stylize.threshold", "Threshold", "Stylize", vec![p("level", "Level", num(128.0), slider(0.0, 255.0, 0.0, 255.0, 0))], threshold),
spec("ec.stylize.findedges", "Find Edges", "Stylize", vec![p("invert", "Invert", Value::Bool(false), ParamUi::Checkbox)], find_edges),
spec(
"ec.stylize.emboss",
"Emboss",
"Stylize",
vec![
p("direction", "Direction", num(45.0), ParamUi::Angle),
p("relief", "Relief", num(1.5), slider(0.0, 10.0, 0.0, 10.0, 2)),
p("contrast", "Contrast", num(100.0), slider(0.0, 500.0, 0.0, 500.0, 0)),
],
emboss,
),
spec(
"ec.perspective.dropshadow",
"Drop Shadow",
"Perspective",
vec![
p("color", "Shadow Color", col(0.0, 0.0, 0.0), ParamUi::Color),
p("opacity", "Opacity", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 0)),
p("direction", "Direction", num(135.0), ParamUi::Angle),
p("distance", "Distance", num(5.0), slider(0.0, 32000.0, 0.0, 120.0, 1)),
p("softness", "Softness", num(0.0), slider(0.0, 1000.0, 0.0, 250.0, 1)),
p("shadowOnly", "Shadow Only", Value::Bool(false), ParamUi::Checkbox),
],
drop_shadow,
),
spec(
"ec.channel.invert",
"Invert",
"Channel",
vec![
p("channel", "Channel", Value::Enum(0), popup(&["RGB", "Red", "Green", "Blue", "Alpha"])),
p("blend", "Blend With Original", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 0)),
],
invert,
),
spec(
"ec.noise.noise",
"Noise",
"Noise & Grain",
vec![p("amount", "Amount of Noise", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)), p("color", "Use Color Noise", Value::Bool(true), ParamUi::Checkbox)],
noise,
),
spec(
"ec.transition.linearwipe",
"Linear Wipe",
"Transition",
vec![
p("completion", "Transition Completion", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 0)),
p("angle", "Wipe Angle", num(90.0), ParamUi::Angle),
p("feather", "Feather", num(0.0), slider(0.0, 32000.0, 0.0, 500.0, 1)),
],
linear_wipe,
),
spec(
"ec.transition.radialwipe",
"Radial Wipe",
"Transition",
vec![
p("completion", "Transition Completion", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 0)),
p("startAngle", "Start Angle", num(0.0), ParamUi::Angle),
p("center", "Wipe Center", Value::Vec2([0.5, 0.5]), ParamUi::Point),
p("wipe", "Wipe", Value::Enum(0), popup(&["Clockwise", "Counterclockwise", "Both"])),
p("feather", "Feather", num(0.0), slider(0.0, 360.0, 0.0, 50.0, 1)),
],
radial_wipe,
),
spec(
"ec.transition.venetian",
"Venetian Blinds",
"Transition",
vec![
p("completion", "Transition Completion", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 0)),
p("direction", "Direction", num(90.0), ParamUi::Angle),
p("width", "Width", num(30.0), slider(2.0, 1000.0, 2.0, 200.0, 0)),
p("feather", "Feather", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
],
venetian,
),
]
}
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "effectcraft-geom"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[lints]
workspace = true
[dependencies]
serde = { workspace = true }
[dev-dependencies]
proptest = { workspace = true }
+520
View File
@@ -0,0 +1,520 @@
//! Geometry for EffectCraft: 2D/3D vectors, 3×3 (2D projective) and 4×4 matrices, rectangles and
//! the layer-transform composition used by the compositor.
//!
//! Conventions follow After Effects' public documentation: y grows downwards, angles are degrees
//! clockwise on screen, 3D uses a left-handed system with +z pointing into the screen, and a 3D
//! layer's rotation is Orientation (X, Y, Z) followed by X Rotation, Y Rotation, Z Rotation.
use std::ops::{Add, AddAssign, Div, Mul, MulAssign, Neg, Sub, SubAssign};
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Vec2 {
pub x: f64,
pub y: f64,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Vec3 {
pub x: f64,
pub y: f64,
pub z: f64,
}
pub const fn vec2(x: f64, y: f64) -> Vec2 {
Vec2 { x, y }
}
pub const fn vec3(x: f64, y: f64, z: f64) -> Vec3 {
Vec3 { x, y, z }
}
impl Vec2 {
pub const ZERO: Vec2 = vec2(0.0, 0.0);
pub const ONE: Vec2 = vec2(1.0, 1.0);
pub fn dot(self, o: Vec2) -> f64 {
self.x * o.x + self.y * o.y
}
pub fn cross(self, o: Vec2) -> f64 {
self.x * o.y - self.y * o.x
}
pub fn length(self) -> f64 {
self.dot(self).sqrt()
}
pub fn normalize(self) -> Vec2 {
let l = self.length();
if l > 0.0 { self / l } else { self }
}
pub fn lerp(self, o: Vec2, t: f64) -> Vec2 {
self + (o - self) * t
}
pub fn extend(self, z: f64) -> Vec3 {
vec3(self.x, self.y, z)
}
pub fn rotate_deg(self, deg: f64) -> Vec2 {
let (s, c) = deg.to_radians().sin_cos();
vec2(self.x * c - self.y * s, self.x * s + self.y * c)
}
pub fn mul_elem(self, o: Vec2) -> Vec2 {
vec2(self.x * o.x, self.y * o.y)
}
}
impl Vec3 {
pub const ZERO: Vec3 = vec3(0.0, 0.0, 0.0);
pub const ONE: Vec3 = vec3(1.0, 1.0, 1.0);
pub fn dot(self, o: Vec3) -> f64 {
self.x * o.x + self.y * o.y + self.z * o.z
}
pub fn cross(self, o: Vec3) -> Vec3 {
vec3(self.y * o.z - self.z * o.y, self.z * o.x - self.x * o.z, self.x * o.y - self.y * o.x)
}
pub fn length(self) -> f64 {
self.dot(self).sqrt()
}
pub fn normalize(self) -> Vec3 {
let l = self.length();
if l > 0.0 { self / l } else { self }
}
pub fn lerp(self, o: Vec3, t: f64) -> Vec3 {
self + (o - self) * t
}
pub fn xy(self) -> Vec2 {
vec2(self.x, self.y)
}
pub fn mul_elem(self, o: Vec3) -> Vec3 {
vec3(self.x * o.x, self.y * o.y, self.z * o.z)
}
}
impl From<[f64; 3]> for Vec3 {
fn from(a: [f64; 3]) -> Vec3 {
vec3(a[0], a[1], a[2])
}
}
impl From<[f64; 2]> for Vec2 {
fn from(a: [f64; 2]) -> Vec2 {
vec2(a[0], a[1])
}
}
macro_rules! vec_ops {
($t:ident, $($f:ident),+) => {
impl Add for $t { type Output = $t; fn add(self, o: $t) -> $t { $t { $($f: self.$f + o.$f),+ } } }
impl Sub for $t { type Output = $t; fn sub(self, o: $t) -> $t { $t { $($f: self.$f - o.$f),+ } } }
impl Mul<f64> for $t { type Output = $t; fn mul(self, k: f64) -> $t { $t { $($f: self.$f * k),+ } } }
impl Div<f64> for $t { type Output = $t; fn div(self, k: f64) -> $t { $t { $($f: self.$f / k),+ } } }
impl Neg for $t { type Output = $t; fn neg(self) -> $t { $t { $($f: -self.$f),+ } } }
impl AddAssign for $t { fn add_assign(&mut self, o: $t) { $(self.$f += o.$f;)+ } }
impl SubAssign for $t { fn sub_assign(&mut self, o: $t) { $(self.$f -= o.$f;)+ } }
impl MulAssign<f64> for $t { fn mul_assign(&mut self, k: f64) { $(self.$f *= k;)+ } }
};
}
vec_ops!(Vec2, x, y);
vec_ops!(Vec3, x, y, z);
/// Axis-aligned rectangle (min inclusive, max exclusive for pixel rects).
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Rect {
pub x0: f64,
pub y0: f64,
pub x1: f64,
pub y1: f64,
}
impl Rect {
pub const EMPTY: Rect = Rect { x0: f64::INFINITY, y0: f64::INFINITY, x1: f64::NEG_INFINITY, y1: f64::NEG_INFINITY };
pub fn new(x0: f64, y0: f64, x1: f64, y1: f64) -> Rect {
Rect { x0, y0, x1, y1 }
}
pub fn from_size(w: f64, h: f64) -> Rect {
Rect { x0: 0.0, y0: 0.0, x1: w, y1: h }
}
pub fn width(&self) -> f64 {
(self.x1 - self.x0).max(0.0)
}
pub fn height(&self) -> f64 {
(self.y1 - self.y0).max(0.0)
}
pub fn is_empty(&self) -> bool {
!(self.x1 > self.x0 && self.y1 > self.y0)
}
pub fn center(&self) -> Vec2 {
vec2((self.x0 + self.x1) * 0.5, (self.y0 + self.y1) * 0.5)
}
pub fn union(&self, o: &Rect) -> Rect {
Rect { x0: self.x0.min(o.x0), y0: self.y0.min(o.y0), x1: self.x1.max(o.x1), y1: self.y1.max(o.y1) }
}
pub fn intersect(&self, o: &Rect) -> Rect {
Rect { x0: self.x0.max(o.x0), y0: self.y0.max(o.y0), x1: self.x1.min(o.x1), y1: self.y1.min(o.y1) }
}
pub fn include(&mut self, p: Vec2) {
self.x0 = self.x0.min(p.x);
self.y0 = self.y0.min(p.y);
self.x1 = self.x1.max(p.x);
self.y1 = self.y1.max(p.y);
}
pub fn inflate(&self, d: f64) -> Rect {
Rect { x0: self.x0 - d, y0: self.y0 - d, x1: self.x1 + d, y1: self.y1 + d }
}
pub fn contains(&self, p: Vec2) -> bool {
p.x >= self.x0 && p.x < self.x1 && p.y >= self.y0 && p.y < self.y1
}
pub fn corners(&self) -> [Vec2; 4] {
[vec2(self.x0, self.y0), vec2(self.x1, self.y0), vec2(self.x1, self.y1), vec2(self.x0, self.y1)]
}
/// Smallest integer rect covering this one.
pub fn round_out(&self) -> Rect {
Rect { x0: self.x0.floor(), y0: self.y0.floor(), x1: self.x1.ceil(), y1: self.y1.ceil() }
}
}
/// Row-major 3×3 matrix acting on column vectors `(x, y, 1)`; a 2D projective transform.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Mat3(pub [[f64; 3]; 3]);
impl Default for Mat3 {
fn default() -> Self {
Mat3::IDENTITY
}
}
impl Mat3 {
pub const IDENTITY: Mat3 = Mat3([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]);
pub fn translate(t: Vec2) -> Mat3 {
Mat3([[1.0, 0.0, t.x], [0.0, 1.0, t.y], [0.0, 0.0, 1.0]])
}
pub fn scale(s: Vec2) -> Mat3 {
Mat3([[s.x, 0.0, 0.0], [0.0, s.y, 0.0], [0.0, 0.0, 1.0]])
}
/// Clockwise on screen (y down) for positive degrees.
pub fn rotate_deg(deg: f64) -> Mat3 {
let (s, c) = deg.to_radians().sin_cos();
Mat3([[c, -s, 0.0], [s, c, 0.0], [0.0, 0.0, 1.0]])
}
pub fn skew_deg(skew: f64, axis_deg: f64) -> Mat3 {
let k = skew.to_radians().tan();
Mat3::rotate_deg(axis_deg) * Mat3([[1.0, k, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]) * Mat3::rotate_deg(-axis_deg)
}
/// The After Effects 2D layer transform: layer space → parent space.
/// `p' = position + R(rotation) · S(scale/100) · (p - anchor)`.
pub fn layer_2d(anchor: Vec2, position: Vec2, scale_pct: Vec2, rotation_deg: f64) -> Mat3 {
Mat3::translate(position) * Mat3::rotate_deg(rotation_deg) * Mat3::scale(scale_pct / 100.0) * Mat3::translate(-anchor)
}
pub fn apply(&self, p: Vec2) -> Vec2 {
let m = &self.0;
let x = m[0][0] * p.x + m[0][1] * p.y + m[0][2];
let y = m[1][0] * p.x + m[1][1] * p.y + m[1][2];
let w = m[2][0] * p.x + m[2][1] * p.y + m[2][2];
if w != 1.0 && w != 0.0 { vec2(x / w, y / w) } else { vec2(x, y) }
}
/// Apply to a direction (ignores translation; affine only).
pub fn apply_vec(&self, v: Vec2) -> Vec2 {
let m = &self.0;
vec2(m[0][0] * v.x + m[0][1] * v.y, m[1][0] * v.x + m[1][1] * v.y)
}
pub fn is_affine(&self) -> bool {
self.0[2][0] == 0.0 && self.0[2][1] == 0.0 && (self.0[2][2] - 1.0).abs() < 1e-12
}
pub fn determinant(&self) -> f64 {
let m = &self.0;
m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0])
}
pub fn inverse(&self) -> Option<Mat3> {
let d = self.determinant();
if d.abs() < 1e-300 || !d.is_finite() {
return None;
}
let m = &self.0;
let inv = [
[
(m[1][1] * m[2][2] - m[1][2] * m[2][1]) / d,
(m[0][2] * m[2][1] - m[0][1] * m[2][2]) / d,
(m[0][1] * m[1][2] - m[0][2] * m[1][1]) / d,
],
[
(m[1][2] * m[2][0] - m[1][0] * m[2][2]) / d,
(m[0][0] * m[2][2] - m[0][2] * m[2][0]) / d,
(m[0][2] * m[1][0] - m[0][0] * m[1][2]) / d,
],
[
(m[1][0] * m[2][1] - m[1][1] * m[2][0]) / d,
(m[0][1] * m[2][0] - m[0][0] * m[2][1]) / d,
(m[0][0] * m[1][1] - m[0][1] * m[1][0]) / d,
],
];
Some(Mat3(inv))
}
/// Bounding box of a rect after transformation.
pub fn map_rect(&self, r: &Rect) -> Rect {
let mut out = Rect::EMPTY;
for c in r.corners() {
out.include(self.apply(c));
}
out
}
/// Average linear scale factor (for stroke widths, blur radii in transformed space).
pub fn mean_scale(&self) -> f64 {
self.determinant().abs().sqrt()
}
/// Projective transform mapping the unit square corners to `q` (in order (0,0),(1,0),(1,1),(0,1)).
pub fn square_to_quad(q: [Vec2; 4]) -> Mat3 {
let (x0, y0, x1, y1, x2, y2, x3, y3) = (q[0].x, q[0].y, q[1].x, q[1].y, q[2].x, q[2].y, q[3].x, q[3].y);
let dx1 = x1 - x2;
let dx2 = x3 - x2;
let dy1 = y1 - y2;
let dy2 = y3 - y2;
let sx = x0 - x1 + x2 - x3;
let sy = y0 - y1 + y2 - y3;
if sx.abs() < 1e-12 && sy.abs() < 1e-12 {
return Mat3([[x1 - x0, x3 - x0, x0], [y1 - y0, y3 - y0, y0], [0.0, 0.0, 1.0]]);
}
let den = dx1 * dy2 - dx2 * dy1;
let g = (sx * dy2 - dx2 * sy) / den;
let h = (dx1 * sy - sx * dy1) / den;
Mat3([[x1 - x0 + g * x1, x3 - x0 + h * x3, x0], [y1 - y0 + g * y1, y3 - y0 + h * y3, y0], [g, h, 1.0]])
}
}
impl Mul for Mat3 {
type Output = Mat3;
fn mul(self, o: Mat3) -> Mat3 {
let mut r = [[0.0; 3]; 3];
for i in 0..3 {
for j in 0..3 {
r[i][j] = (0..3).map(|k| self.0[i][k] * o.0[k][j]).sum();
}
}
Mat3(r)
}
}
/// Row-major 4×4 matrix on column vectors `(x, y, z, 1)`.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Mat4(pub [[f64; 4]; 4]);
impl Default for Mat4 {
fn default() -> Self {
Mat4::IDENTITY
}
}
impl Mat4 {
pub const IDENTITY: Mat4 = Mat4([[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]]);
pub fn translate(t: Vec3) -> Mat4 {
let mut m = Mat4::IDENTITY;
m.0[0][3] = t.x;
m.0[1][3] = t.y;
m.0[2][3] = t.z;
m
}
pub fn scale(s: Vec3) -> Mat4 {
let mut m = Mat4::IDENTITY;
m.0[0][0] = s.x;
m.0[1][1] = s.y;
m.0[2][2] = s.z;
m
}
pub fn rotate_x(deg: f64) -> Mat4 {
let (s, c) = deg.to_radians().sin_cos();
Mat4([[1.0, 0.0, 0.0, 0.0], [0.0, c, -s, 0.0], [0.0, s, c, 0.0], [0.0, 0.0, 0.0, 1.0]])
}
pub fn rotate_y(deg: f64) -> Mat4 {
let (s, c) = deg.to_radians().sin_cos();
Mat4([[c, 0.0, s, 0.0], [0.0, 1.0, 0.0, 0.0], [-s, 0.0, c, 0.0], [0.0, 0.0, 0.0, 1.0]])
}
pub fn rotate_z(deg: f64) -> Mat4 {
let (s, c) = deg.to_radians().sin_cos();
Mat4([[c, -s, 0.0, 0.0], [s, c, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]])
}
/// Orientation (degrees, X then Y then Z applied to the vector in that order).
pub fn orientation(o: Vec3) -> Mat4 {
Mat4::rotate_z(o.z) * Mat4::rotate_y(o.y) * Mat4::rotate_x(o.x)
}
/// The 3D layer transform: layer space → parent space.
pub fn layer_3d(anchor: Vec3, position: Vec3, scale_pct: Vec3, orientation: Vec3, rotation: Vec3) -> Mat4 {
Mat4::translate(position)
* Mat4::orientation(orientation)
* Mat4::rotate_z(rotation.z)
* Mat4::rotate_y(rotation.y)
* Mat4::rotate_x(rotation.x)
* Mat4::scale(scale_pct / 100.0)
* Mat4::translate(-anchor)
}
/// Embed a 2D affine transform (z untouched).
pub fn from_mat3_affine(m: &Mat3) -> Mat4 {
let a = &m.0;
Mat4([[a[0][0], a[0][1], 0.0, a[0][2]], [a[1][0], a[1][1], 0.0, a[1][2]], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]])
}
pub fn apply(&self, p: Vec3) -> Vec3 {
let m = &self.0;
let x = m[0][0] * p.x + m[0][1] * p.y + m[0][2] * p.z + m[0][3];
let y = m[1][0] * p.x + m[1][1] * p.y + m[1][2] * p.z + m[1][3];
let z = m[2][0] * p.x + m[2][1] * p.y + m[2][2] * p.z + m[2][3];
let w = m[3][0] * p.x + m[3][1] * p.y + m[3][2] * p.z + m[3][3];
if w != 1.0 && w != 0.0 { vec3(x / w, y / w, z / w) } else { vec3(x, y, z) }
}
pub fn apply_vec(&self, v: Vec3) -> Vec3 {
let m = &self.0;
vec3(
m[0][0] * v.x + m[0][1] * v.y + m[0][2] * v.z,
m[1][0] * v.x + m[1][1] * v.y + m[1][2] * v.z,
m[2][0] * v.x + m[2][1] * v.y + m[2][2] * v.z,
)
}
pub fn transpose(&self) -> Mat4 {
let mut r = [[0.0; 4]; 4];
for i in 0..4 {
for j in 0..4 {
r[i][j] = self.0[j][i];
}
}
Mat4(r)
}
/// General inverse (Gauss-Jordan with partial pivoting).
pub fn inverse(&self) -> Option<Mat4> {
let mut a = self.0;
let mut inv = Mat4::IDENTITY.0;
for col in 0..4 {
let piv = (col..4).max_by(|&i, &j| a[i][col].abs().total_cmp(&a[j][col].abs()))?;
if a[piv][col].abs() < 1e-300 {
return None;
}
a.swap(col, piv);
inv.swap(col, piv);
let d = a[col][col];
for j in 0..4 {
a[col][j] /= d;
inv[col][j] /= d;
}
for i in 0..4 {
if i != col {
let f = a[i][col];
if f != 0.0 {
for j in 0..4 {
a[i][j] -= f * a[col][j];
inv[i][j] -= f * inv[col][j];
}
}
}
}
}
Some(Mat4(inv))
}
/// The projective 3×3 mapping a z=0 plane (x, y) through this matrix and a perspective divide
/// by `w`: used to draw planar 3D layers. `self` must already include the projection.
pub fn plane_to_mat3(&self) -> Mat3 {
let m = &self.0;
Mat3([[m[0][0], m[0][1], m[0][3]], [m[1][0], m[1][1], m[1][3]], [m[3][0], m[3][1], m[3][3]]])
}
}
impl Mul for Mat4 {
type Output = Mat4;
fn mul(self, o: Mat4) -> Mat4 {
let mut r = [[0.0; 4]; 4];
for i in 0..4 {
for j in 0..4 {
r[i][j] = (0..4).map(|k| self.0[i][k] * o.0[k][j]).sum();
}
}
Mat4(r)
}
}
/// After Effects-style camera: the comp is viewed from `position` towards `poi` with `zoom` = the
/// distance (in pixels) from the eye to the image plane. Returns view×projection that maps world
/// points to comp pixels (x, y) with depth in z and the perspective divide in w.
pub fn camera_matrix(comp_w: f64, comp_h: f64, position: Vec3, view: Mat4, zoom: f64) -> Mat4 {
let _ = position;
// Projection: x' = zoom * x / z + cx, y' = zoom * y / z + cy (z in camera space, +z forward).
let proj = Mat4([[zoom, 0.0, comp_w * 0.5, 0.0], [0.0, zoom, comp_h * 0.5, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 1.0, 0.0]]);
proj * view
}
/// The default camera AE uses when a comp has 3D layers but no camera: 50 mm equivalent, placed
/// at (w/2, h/2, -zoom) looking at the comp centre.
pub fn default_camera_zoom(comp_w: f64) -> f64 {
// 50mm preset on a 36mm film width: zoom = w * 50 / 36.
comp_w * 50.0 / 36.0
}
/// View matrix for a camera at `pos` looking at `poi` (AE: one-node cameras look along +z of
/// their own orientation; two-node cameras auto-orient to the point of interest).
pub fn look_at(pos: Vec3, poi: Vec3, extra: Mat4) -> Mat4 {
let f = (poi - pos).normalize();
let f = if f.length() == 0.0 { vec3(0.0, 0.0, 1.0) } else { f };
// y down world; choose up = -y unless parallel.
let up = if f.cross(vec3(0.0, -1.0, 0.0)).length() < 1e-9 { vec3(0.0, 0.0, 1.0) } else { vec3(0.0, -1.0, 0.0) };
let r = up.cross(f).normalize();
let u = f.cross(r);
// Camera basis: x = right (screen +x), y = down (screen +y), z = forward.
let x = -r;
let y = -u;
let rot = Mat4([[x.x, x.y, x.z, 0.0], [y.x, y.y, y.z, 0.0], [f.x, f.y, f.z, 0.0], [0.0, 0.0, 0.0, 1.0]]);
extra * rot * Mat4::translate(-pos)
}
#[cfg(test)]
mod tests {
use super::*;
fn close(a: Vec2, b: Vec2) -> bool {
(a - b).length() < 1e-9
}
#[test]
fn layer_2d_maps_anchor_to_position() {
let m = Mat3::layer_2d(vec2(50.0, 25.0), vec2(960.0, 540.0), vec2(200.0, 50.0), 30.0);
assert!(close(m.apply(vec2(50.0, 25.0)), vec2(960.0, 540.0)));
let inv = m.inverse().unwrap();
let p = vec2(12.0, -7.0);
assert!(close(inv.apply(m.apply(p)), p));
}
#[test]
fn rotation_is_clockwise_on_screen() {
let p = Mat3::rotate_deg(90.0).apply(vec2(1.0, 0.0));
assert!(close(p, vec2(0.0, 1.0)));
}
#[test]
fn quad_mapping_hits_corners() {
let q = [vec2(10.0, 10.0), vec2(110.0, 20.0), vec2(120.0, 140.0), vec2(5.0, 100.0)];
let m = Mat3::square_to_quad(q);
assert!(close(m.apply(vec2(0.0, 0.0)), q[0]));
assert!(close(m.apply(vec2(1.0, 0.0)), q[1]));
assert!(close(m.apply(vec2(1.0, 1.0)), q[2]));
assert!(close(m.apply(vec2(0.0, 1.0)), q[3]));
}
#[test]
fn mat4_inverse_roundtrip() {
let m = Mat4::layer_3d(vec3(1.0, 2.0, 3.0), vec3(100.0, 50.0, -20.0), vec3(150.0, 80.0, 100.0), vec3(10.0, 20.0, 30.0), vec3(5.0, -40.0, 12.0));
let i = m.inverse().unwrap();
let p = vec3(3.0, -4.0, 5.0);
assert!((i.apply(m.apply(p)) - p).length() < 1e-9);
}
#[test]
fn default_camera_sees_z0_plane_unscaled() {
let (w, h) = (1920.0, 1080.0);
let zoom = default_camera_zoom(w);
let pos = vec3(w / 2.0, h / 2.0, -zoom);
let view = look_at(pos, vec3(w / 2.0, h / 2.0, 0.0), Mat4::IDENTITY);
let cam = camera_matrix(w, h, pos, view, zoom);
let p = cam.apply(vec3(100.0, 200.0, 0.0));
assert!((p.x - 100.0).abs() < 1e-6 && (p.y - 200.0).abs() < 1e-6, "{p:?}");
}
proptest::proptest! {
#[test]
fn mat3_inverse(ax in -100.0f64..100.0, ay in -100.0f64..100.0, s in 10.0f64..400.0, r in -720.0f64..720.0) {
let m = Mat3::layer_2d(vec2(ax, ay), vec2(3.0, 4.0), vec2(s, s * 0.7), r);
let i = m.inverse().unwrap();
let p = vec2(17.0, 23.0);
proptest::prop_assert!((i.apply(m.apply(p)) - p).length() < 1e-6);
}
}
}
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "effectcraft-keyframe"
description = "Animated values: keyframes, temporal ease (speed/influence), spatial Bezier motion paths, hold/linear/auto interpolation"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[lints]
workspace = true
[dependencies]
effectcraft-time = { workspace = true }
effectcraft-geom = { workspace = true }
effectcraft-color = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
[dev-dependencies]
proptest = { workspace = true }
+543
View File
@@ -0,0 +1,543 @@
//! Animated values with After Effects keyframe semantics.
//!
//! * **Temporal** interpolation between two keys is linear, Bezier or hold. Bezier eases are given
//! per dimension as *speed* (value units per second) and *influence* (fraction of the segment's
//! duration, 0.1 %–100 %), exactly the numbers the Keyframe Velocity dialog shows.
//! * **Spatial** properties (Position, Anchor Point, Point effect params) move along a Bezier motion
//! path defined by spatial tangents; their temporal ease is a single speed along the path
//! (pixels/second), evaluated through an arc-length table.
//! * Auto-Bezier and continuous-Bezier keys derive their tangents from their neighbours.
pub mod value;
use effectcraft_time::{TICKS_PER_SECOND, Tick};
use serde::{Deserialize, Serialize};
pub use value::{Gradient, Justify, ShapePath, TextDoc, Value};
/// Temporal interpolation on one side of a keyframe.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Interp {
#[default]
Linear,
Bezier,
Hold,
}
impl Interp {
pub fn label(self) -> &'static str {
match self {
Interp::Linear => "Linear",
Interp::Bezier => "Bezier",
Interp::Hold => "Hold",
}
}
}
/// Temporal ease on one side of a key: speed in units/s and influence 0..1 (fraction of the
/// adjacent segment duration).
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Ease {
pub speed: f64,
pub influence: f64,
}
impl Default for Ease {
fn default() -> Self {
Ease { speed: 0.0, influence: 1.0 / 6.0 }
}
}
impl Ease {
/// Easy Ease: zero speed, 33.33 % influence.
pub const EASY: Ease = Ease { speed: 0.0, influence: 1.0 / 3.0 };
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Keyframe {
pub time: Tick,
pub value: Value,
#[serde(default)]
pub in_interp: Interp,
#[serde(default)]
pub out_interp: Interp,
/// Per-dimension (or one entry for spatial / single-dimension values).
#[serde(default)]
pub in_ease: Vec<Ease>,
#[serde(default)]
pub out_ease: Vec<Ease>,
/// Temporal continuous / auto Bezier flags.
#[serde(default)]
pub continuous: bool,
#[serde(default)]
pub auto_bezier: bool,
/// Spatial tangents relative to the key value (spatial properties only).
#[serde(default)]
pub spatial_in: [f64; 3],
#[serde(default)]
pub spatial_out: [f64; 3],
/// Spatial auto-Bezier: tangents computed from neighbours.
#[serde(default = "yes")]
pub spatial_auto: bool,
#[serde(default)]
pub spatial_continuous: bool,
#[serde(default)]
pub roving: bool,
}
fn yes() -> bool {
true
}
impl Keyframe {
pub fn new(time: Tick, value: Value) -> Keyframe {
Keyframe {
time,
value,
in_interp: Interp::Linear,
out_interp: Interp::Linear,
in_ease: vec![],
out_ease: vec![],
continuous: false,
auto_bezier: false,
spatial_in: [0.0; 3],
spatial_out: [0.0; 3],
spatial_auto: true,
spatial_continuous: false,
roving: false,
}
}
pub fn hold(mut self) -> Keyframe {
self.in_interp = Interp::Hold;
self.out_interp = Interp::Hold;
self
}
/// Easy Ease on both sides.
pub fn eased(mut self) -> Keyframe {
self.in_interp = Interp::Bezier;
self.out_interp = Interp::Bezier;
let n = self.value.dims().max(1);
self.in_ease = vec![Ease::EASY; n];
self.out_ease = vec![Ease::EASY; n];
self
}
/// Shape of the keyframe icon in the timeline.
pub fn icon(&self) -> KeyIcon {
let side = |i: Interp, auto: bool| match i {
Interp::Hold => KeyHalf::Hold,
Interp::Linear => KeyHalf::Linear,
Interp::Bezier if auto => KeyHalf::Auto,
Interp::Bezier => KeyHalf::Bezier,
};
KeyIcon { left: side(self.in_interp, self.auto_bezier), right: side(self.out_interp, self.auto_bezier) }
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum KeyHalf {
Linear,
Bezier,
Auto,
Hold,
}
/// Keyframe icon halves (diamond = linear, hourglass = bezier/ease, circle = auto, square = hold).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct KeyIcon {
pub left: KeyHalf,
pub right: KeyHalf,
}
fn secs(t: Tick) -> f64 {
t.0 as f64 / TICKS_PER_SECOND as f64
}
/// Cubic Bezier in 1D.
fn bez(p0: f64, p1: f64, p2: f64, p3: f64, u: f64) -> f64 {
let v = 1.0 - u;
v * v * v * p0 + 3.0 * v * v * u * p1 + 3.0 * v * u * u * p2 + u * u * u * p3
}
fn bez_d(p0: f64, p1: f64, p2: f64, p3: f64, u: f64) -> f64 {
let v = 1.0 - u;
3.0 * v * v * (p1 - p0) + 6.0 * v * u * (p2 - p1) + 3.0 * u * u * (p3 - p2)
}
/// Solve `bez(x0..x3, u) = x` for u in 0..1 (x curve monotone).
fn solve_u(x0: f64, x1: f64, x2: f64, x3: f64, x: f64) -> f64 {
if x <= x0 {
return 0.0;
}
if x >= x3 {
return 1.0;
}
let mut u = (x - x0) / (x3 - x0);
for _ in 0..8 {
let f = bez(x0, x1, x2, x3, u) - x;
if f.abs() < 1e-12 {
return u;
}
let d = bez_d(x0, x1, x2, x3, u);
if d.abs() < 1e-12 {
break;
}
let n = u - f / d;
if !(0.0..=1.0).contains(&n) {
break;
}
u = n;
}
let (mut lo, mut hi) = (0.0, 1.0);
for _ in 0..60 {
let mid = (lo + hi) * 0.5;
if bez(x0, x1, x2, x3, mid) < x {
lo = mid;
} else {
hi = mid;
}
}
(lo + hi) * 0.5
}
/// Fraction of progress 0..1 through a 1D Bezier temporal segment whose value goes from 0 to 1,
/// with normalised out/in speeds (speed × duration / Δvalue) and influences.
pub fn ease_progress(out_speed_n: f64, out_inf: f64, in_speed_n: f64, in_inf: f64, x: f64) -> f64 {
let (mut i0, mut i1) = (out_inf.clamp(0.0, 1.0), in_inf.clamp(0.0, 1.0));
if i0 + i1 > 1.0 {
let k = 1.0 / (i0 + i1);
i0 *= k;
i1 *= k;
}
let u = solve_u(0.0, i0, 1.0 - i1, 1.0, x);
bez(0.0, out_speed_n * i0, 1.0 - in_speed_n * i1, 1.0, u)
}
/// Spatial cubic segment with an arc-length table.
struct SpatialSeg {
p: [[f64; 3]; 4],
/// Cumulative length at u = i / N.
table: Vec<f64>,
}
const ARC_N: usize = 96;
impl SpatialSeg {
fn new(p: [[f64; 3]; 4]) -> SpatialSeg {
let mut table = Vec::with_capacity(ARC_N + 1);
table.push(0.0);
let mut prev = Self::point(&p, 0.0);
let mut acc = 0.0;
for i in 1..=ARC_N {
let q = Self::point(&p, i as f64 / ARC_N as f64);
acc += ((q[0] - prev[0]).powi(2) + (q[1] - prev[1]).powi(2) + (q[2] - prev[2]).powi(2)).sqrt();
table.push(acc);
prev = q;
}
SpatialSeg { p, table }
}
fn point(p: &[[f64; 3]; 4], u: f64) -> [f64; 3] {
[bez(p[0][0], p[1][0], p[2][0], p[3][0], u), bez(p[0][1], p[1][1], p[2][1], p[3][1], u), bez(p[0][2], p[1][2], p[2][2], p[3][2], u)]
}
fn length(&self) -> f64 {
*self.table.last().unwrap_or(&0.0)
}
fn at_length(&self, s: f64) -> [f64; 3] {
let len = self.length();
if len <= 0.0 {
return self.p[0];
}
let s = s.clamp(0.0, len);
let i = self.table.partition_point(|&v| v < s).clamp(1, ARC_N);
let (a, b) = (self.table[i - 1], self.table[i]);
let f = if b > a { (s - a) / (b - a) } else { 0.0 };
Self::point(&self.p, (i as f64 - 1.0 + f) / ARC_N as f64)
}
}
fn v3(v: &Value) -> [f64; 3] {
v.as_vec3()
}
/// Auto-Bezier spatial tangents (Catmull-Rom style) for key `i`.
fn auto_spatial(keys: &[Keyframe], i: usize) -> ([f64; 3], [f64; 3]) {
if i == 0 || i + 1 >= keys.len() {
return ([0.0; 3], [0.0; 3]);
}
let a = v3(&keys[i - 1].value);
let b = v3(&keys[i].value);
let c = v3(&keys[i + 1].value);
let d = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
let dl = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
if dl < 1e-12 {
return ([0.0; 3], [0.0; 3]);
}
let lin = ((b[0] - a[0]).powi(2) + (b[1] - a[1]).powi(2) + (b[2] - a[2]).powi(2)).sqrt();
let lout = ((c[0] - b[0]).powi(2) + (c[1] - b[1]).powi(2) + (c[2] - b[2]).powi(2)).sqrt();
let n = [d[0] / dl, d[1] / dl, d[2] / dl];
let tin = [-n[0] * lin / 3.0, -n[1] * lin / 3.0, -n[2] * lin / 3.0];
let tout = [n[0] * lout / 3.0, n[1] * lout / 3.0, n[2] * lout / 3.0];
(tin, tout)
}
/// Effective spatial tangents of key `i` (auto or stored).
pub fn spatial_tangents(keys: &[Keyframe], i: usize) -> ([f64; 3], [f64; 3]) {
if keys[i].spatial_auto { auto_spatial(keys, i) } else { (keys[i].spatial_in, keys[i].spatial_out) }
}
/// Effective temporal ease of key `i` for dimension `d` (auto-Bezier keys get the neighbour slope).
fn effective_ease(keys: &[Keyframe], i: usize, d: usize, spatial: bool, out: bool) -> Ease {
let k = &keys[i];
let list = if out { &k.out_ease } else { &k.in_ease };
if k.auto_bezier && i > 0 && i + 1 < keys.len() {
let dt = secs(keys[i + 1].time) - secs(keys[i - 1].time);
let slope = if spatial {
let a = v3(&keys[i - 1].value);
let c = v3(&keys[i + 1].value);
((c[0] - a[0]).powi(2) + (c[1] - a[1]).powi(2) + (c[2] - a[2]).powi(2)).sqrt() / dt.max(1e-12)
} else {
let a = keys[i - 1].value.components();
let c = keys[i + 1].value.components();
(c.get(d).unwrap_or(&0.0) - a.get(d).unwrap_or(&0.0)) / dt.max(1e-12)
};
return Ease { speed: slope, influence: 1.0 / 6.0 };
}
list.get(d).or(list.first()).copied().unwrap_or_default()
}
/// Evaluate a keyframed property at `t`. `spatial` selects motion-path semantics for 2D/3D values.
pub fn evaluate(keys: &[Keyframe], t: Tick, spatial: bool) -> Option<Value> {
let first = keys.first()?;
if keys.len() == 1 || t <= first.time {
return Some(first.value.clone());
}
let last = keys.last()?;
if t >= last.time {
return Some(last.value.clone());
}
let i = keys.partition_point(|k| k.time <= t) - 1;
Some(segment_value(keys, i, t, spatial))
}
/// Value inside segment `i` → `i+1` at time `t`.
pub fn segment_value(keys: &[Keyframe], i: usize, t: Tick, spatial: bool) -> Value {
let a = &keys[i];
let b = &keys[i + 1];
if a.out_interp == Interp::Hold || !a.value.interpolates() {
return a.value.clone();
}
let (t0, t1) = (secs(a.time), secs(b.time));
let dur = (t1 - t0).max(1e-12);
let x = ((secs(t) - t0) / dur).clamp(0.0, 1.0);
let lin_out = a.out_interp == Interp::Linear;
let lin_in = b.in_interp == Interp::Linear;
let is_spatial = spatial && matches!(a.value, Value::Vec2(_) | Value::Vec3(_));
if is_spatial {
let p0 = v3(&a.value);
let p3 = v3(&b.value);
let (_, out_t) = spatial_tangents(keys, i);
let (in_t, _) = spatial_tangents(keys, i + 1);
let seg = SpatialSeg::new([
p0,
[p0[0] + out_t[0], p0[1] + out_t[1], p0[2] + out_t[2]],
[p3[0] + in_t[0], p3[1] + in_t[1], p3[2] + in_t[2]],
p3,
]);
let len = seg.length();
let f = if lin_out && lin_in || len <= 1e-12 {
x
} else {
let eo = if lin_out { Ease { speed: len / dur, influence: 1.0 / 3.0 } } else { effective_ease(keys, i, 0, true, true) };
let ei = if lin_in { Ease { speed: len / dur, influence: 1.0 / 3.0 } } else { effective_ease(keys, i + 1, 0, true, false) };
ease_progress(eo.speed * dur / len, eo.influence, ei.speed * dur / len, ei.influence, x)
};
let p = seg.at_length(f * len);
return a.value.with_components(&p);
}
if lin_out && lin_in {
return a.value.lerp(&b.value, x);
}
let ca = a.value.components();
let cb = b.value.components();
if ca.is_empty() || ca.len() != cb.len() {
// Paths, gradients: ease on dimension 0 as overall progress.
let eo = if lin_out { Ease { speed: 1.0 / dur, influence: 1.0 / 3.0 } } else { effective_ease(keys, i, 0, false, true) };
let ei = if lin_in { Ease { speed: 1.0 / dur, influence: 1.0 / 3.0 } } else { effective_ease(keys, i + 1, 0, false, false) };
let f = ease_progress(eo.speed * dur, eo.influence, ei.speed * dur, ei.influence, x);
return a.value.lerp(&b.value, f);
}
let mut out = Vec::with_capacity(ca.len());
for d in 0..ca.len() {
let dv = cb[d] - ca[d];
let eo = if lin_out { Ease { speed: dv / dur, influence: 1.0 / 3.0 } } else { effective_ease(keys, i, d, false, true) };
let ei = if lin_in { Ease { speed: dv / dur, influence: 1.0 / 3.0 } } else { effective_ease(keys, i + 1, d, false, false) };
// Bezier in (time, value) space directly: P1 = (i0, v0 + s0*i0*dur), P2 = (1-i1, v1 - s1*i1*dur).
let (mut i0, mut i1) = (eo.influence.clamp(0.0, 1.0), ei.influence.clamp(0.0, 1.0));
if i0 + i1 > 1.0 {
let k = 1.0 / (i0 + i1);
i0 *= k;
i1 *= k;
}
let u = solve_u(0.0, i0, 1.0 - i1, 1.0, x);
let v = bez(ca[d], ca[d] + eo.speed * i0 * dur, cb[d] - ei.speed * i1 * dur, cb[d], u);
out.push(v);
}
a.value.with_components(&out)
}
/// Numeric velocity (units/s per dimension) at `t` by central difference.
pub fn velocity(keys: &[Keyframe], t: Tick, spatial: bool) -> Vec<f64> {
let h = Tick(TICKS_PER_SECOND / 1000);
let (Some(a), Some(b)) = (evaluate(keys, t - h, spatial), evaluate(keys, t + h, spatial)) else { return vec![] };
let dt = 2.0 * secs(h);
a.components().iter().zip(b.components()).map(|(x, y)| (y - x) / dt).collect()
}
/// Speed (magnitude of velocity).
pub fn speed(keys: &[Keyframe], t: Tick, spatial: bool) -> f64 {
velocity(keys, t, spatial).iter().map(|v| v * v).sum::<f64>().sqrt()
}
/// Insert or replace (same time) a key; keeps keys sorted. Returns its index.
pub fn set_key(keys: &mut Vec<Keyframe>, key: Keyframe) -> usize {
match keys.binary_search_by(|k| k.time.cmp(&key.time)) {
Ok(i) => {
let mut k = key;
// Keep the existing interpolation when only the value changes.
let old = &keys[i];
k.in_interp = old.in_interp;
k.out_interp = old.out_interp;
k.in_ease = old.in_ease.clone();
k.out_ease = old.out_ease.clone();
k.auto_bezier = old.auto_bezier;
k.continuous = old.continuous;
k.spatial_in = old.spatial_in;
k.spatial_out = old.spatial_out;
k.spatial_auto = old.spatial_auto;
keys[i] = k;
i
}
Err(i) => {
keys.insert(i, key);
i
}
}
}
/// Index of the key exactly at `t`.
pub fn key_at(keys: &[Keyframe], t: Tick) -> Option<usize> {
keys.binary_search_by(|k| k.time.cmp(&t)).ok()
}
/// Apply Easy Ease (in, out or both) to key `i`.
pub fn easy_ease(k: &mut Keyframe, ease_in: bool, ease_out: bool) {
let n = k.value.dims().max(1);
if ease_in {
k.in_interp = Interp::Bezier;
k.in_ease = vec![Ease::EASY; n];
}
if ease_out {
k.out_interp = Interp::Bezier;
k.out_ease = vec![Ease::EASY; n];
}
k.auto_bezier = false;
}
#[cfg(test)]
mod tests {
use super::*;
fn s(x: f64) -> Tick {
Tick::from_seconds_f64(x)
}
#[test]
fn linear_and_hold() {
let keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)), Keyframe::new(s(2.0), Value::Scalar(100.0))];
assert_eq!(evaluate(&keys, s(1.0), false), Some(Value::Scalar(50.0)));
assert_eq!(evaluate(&keys, s(-1.0), false), Some(Value::Scalar(0.0)));
assert_eq!(evaluate(&keys, s(3.0), false), Some(Value::Scalar(100.0)));
let held = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)).hold(), Keyframe::new(s(2.0), Value::Scalar(100.0))];
assert_eq!(evaluate(&held, s(1.999), false), Some(Value::Scalar(0.0)));
}
#[test]
fn easy_ease_is_symmetric_and_slow_at_ends() {
let keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)).eased(), Keyframe::new(s(1.0), Value::Scalar(100.0)).eased()];
let mid = evaluate(&keys, s(0.5), false).unwrap().as_f64();
assert!((mid - 50.0).abs() < 1e-6, "{mid}");
let early = evaluate(&keys, s(0.1), false).unwrap().as_f64();
assert!(early < 10.0 && early > 0.0, "{early}");
let v0 = velocity(&keys, s(0.01), false)[0];
let vm = velocity(&keys, s(0.5), false)[0];
assert!(v0 < vm);
}
#[test]
fn bezier_with_linear_speed_matches_linear() {
let mut a = Keyframe::new(s(0.0), Value::Scalar(0.0));
let mut b = Keyframe::new(s(2.0), Value::Scalar(100.0));
a.out_interp = Interp::Bezier;
b.in_interp = Interp::Bezier;
a.out_ease = vec![Ease { speed: 50.0, influence: 1.0 / 3.0 }];
b.in_ease = vec![Ease { speed: 50.0, influence: 1.0 / 3.0 }];
let keys = vec![a, b];
for i in 0..=20 {
let t = i as f64 * 0.1;
let v = evaluate(&keys, s(t), false).unwrap().as_f64();
assert!((v - 50.0 * t).abs() < 1e-6, "{t} {v}");
}
}
#[test]
fn spatial_linear_constant_speed() {
let keys = vec![Keyframe::new(s(0.0), Value::Vec2([0.0, 0.0])), Keyframe::new(s(1.0), Value::Vec2([300.0, 400.0]))];
let p = evaluate(&keys, s(0.5), true).unwrap().as_vec2();
assert!((p[0] - 150.0).abs() < 0.5 && (p[1] - 200.0).abs() < 0.5, "{p:?}");
}
#[test]
fn spatial_auto_bezier_passes_through_keys() {
let keys = vec![
Keyframe::new(s(0.0), Value::Vec2([0.0, 0.0])),
Keyframe::new(s(1.0), Value::Vec2([100.0, 100.0])),
Keyframe::new(s(2.0), Value::Vec2([200.0, 0.0])),
];
let p = evaluate(&keys, s(1.0), true).unwrap().as_vec2();
assert_eq!(p, [100.0, 100.0]);
// Curved: the midpoint of the first segment lies off the straight line.
let m = evaluate(&keys, s(0.5), true).unwrap().as_vec2();
assert!((m[0] - m[1]).abs() > 1.0, "{m:?}");
}
#[test]
fn set_key_replaces_same_time() {
let mut keys = vec![];
set_key(&mut keys, Keyframe::new(s(1.0), Value::Scalar(1.0)).eased());
set_key(&mut keys, Keyframe::new(s(0.0), Value::Scalar(0.0)));
set_key(&mut keys, Keyframe::new(s(1.0), Value::Scalar(5.0)));
assert_eq!(keys.len(), 2);
assert_eq!(keys[1].value, Value::Scalar(5.0));
assert_eq!(keys[1].in_interp, Interp::Bezier);
}
#[test]
fn colors_and_paths_interpolate() {
let keys = vec![Keyframe::new(s(0.0), Value::Color([0.0, 0.0, 0.0, 1.0])), Keyframe::new(s(1.0), Value::Color([1.0, 0.5, 0.0, 1.0]))];
assert_eq!(evaluate(&keys, s(0.5), false).unwrap().as_color(), [0.5, 0.25, 0.0, 1.0]);
let a = ShapePath::rect([0.0, 0.0], 10.0, 10.0);
let b = ShapePath::rect([0.0, 0.0], 20.0, 20.0);
let keys = vec![Keyframe::new(s(0.0), Value::Path(a)), Keyframe::new(s(1.0), Value::Path(b))];
let v = evaluate(&keys, s(0.5), false).unwrap();
assert_eq!(v.as_path().unwrap().vertices[0], [-7.5, -7.5]);
}
proptest::proptest! {
#[test]
fn eased_values_stay_in_range(t in 0.0f64..1.0, i0 in 0.01f64..1.0, i1 in 0.01f64..1.0) {
let mut a = Keyframe::new(s(0.0), Value::Scalar(0.0)).eased();
let mut b = Keyframe::new(s(1.0), Value::Scalar(10.0)).eased();
a.out_ease = vec![Ease { speed: 0.0, influence: i0 }];
b.in_ease = vec![Ease { speed: 0.0, influence: i1 }];
let v = evaluate(&[a, b], s(t), false).unwrap().as_f64();
proptest::prop_assert!((-1e-9..=10.0 + 1e-9).contains(&v));
}
}
}
+374
View File
@@ -0,0 +1,374 @@
//! Property values.
use serde::{Deserialize, Serialize};
/// A Bezier shape in the After Effects / Lottie vertex model: vertices with tangents relative to
/// their vertex.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct ShapePath {
pub vertices: Vec<[f64; 2]>,
pub in_tangents: Vec<[f64; 2]>,
pub out_tangents: Vec<[f64; 2]>,
pub closed: bool,
}
impl ShapePath {
pub fn polygon(points: &[[f64; 2]], closed: bool) -> ShapePath {
ShapePath { vertices: points.to_vec(), in_tangents: vec![[0.0; 2]; points.len()], out_tangents: vec![[0.0; 2]; points.len()], closed }
}
pub fn len(&self) -> usize {
self.vertices.len()
}
pub fn is_empty(&self) -> bool {
self.vertices.is_empty()
}
/// Rectangle centred at `c` (clockwise from top-left, as AE builds mask rectangles).
pub fn rect(c: [f64; 2], w: f64, h: f64) -> ShapePath {
let (x0, y0, x1, y1) = (c[0] - w / 2.0, c[1] - h / 2.0, c[0] + w / 2.0, c[1] + h / 2.0);
ShapePath::polygon(&[[x0, y0], [x1, y0], [x1, y1], [x0, y1]], true)
}
/// Ellipse with four Bezier arcs (kappa 0.5523).
pub fn ellipse(c: [f64; 2], w: f64, h: f64) -> ShapePath {
const K: f64 = 0.552_284_749_8;
let (rx, ry) = (w / 2.0, h / 2.0);
ShapePath {
vertices: vec![[c[0], c[1] - ry], [c[0] + rx, c[1]], [c[0], c[1] + ry], [c[0] - rx, c[1]]],
in_tangents: vec![[-rx * K, 0.0], [0.0, -ry * K], [rx * K, 0.0], [0.0, ry * K]],
out_tangents: vec![[rx * K, 0.0], [0.0, ry * K], [-rx * K, 0.0], [0.0, -ry * K]],
closed: true,
}
}
fn lerp(&self, o: &ShapePath, t: f64) -> ShapePath {
if self.len() != o.len() {
return if t < 1.0 { self.clone() } else { o.clone() };
}
let l = |a: &[[f64; 2]], b: &[[f64; 2]]| a.iter().zip(b).map(|(p, q)| [p[0] + (q[0] - p[0]) * t, p[1] + (q[1] - p[1]) * t]).collect();
ShapePath {
vertices: l(&self.vertices, &o.vertices),
in_tangents: l(&self.in_tangents, &o.in_tangents),
out_tangents: l(&self.out_tangents, &o.out_tangents),
closed: self.closed,
}
}
}
/// Horizontal paragraph alignment.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum Justify {
#[default]
Left,
Center,
Right,
JustifyLastLeft,
JustifyLastCenter,
JustifyLastRight,
JustifyAll,
}
/// The value of a text layer's Source Text (hold-interpolated).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct TextDoc {
pub text: String,
pub font: String,
pub style: String,
pub size: f64,
pub fill: [f32; 4],
pub stroke: [f32; 4],
pub stroke_width: f64,
pub apply_fill: bool,
pub apply_stroke: bool,
pub stroke_over_fill: bool,
pub tracking: f64,
/// None = auto (120% of size).
pub leading: Option<f64>,
pub justify: Justify,
pub baseline_shift: f64,
pub h_scale: f64,
pub v_scale: f64,
pub faux_bold: bool,
pub faux_italic: bool,
pub all_caps: bool,
pub small_caps: bool,
/// Paragraph text box (width, height) with top-left at `box_pos`; None = point text.
pub box_size: Option<[f64; 2]>,
pub box_pos: [f64; 2],
}
impl Default for TextDoc {
fn default() -> Self {
TextDoc {
text: String::new(),
font: "Inter".into(),
style: "Regular".into(),
size: 72.0,
fill: [1.0, 1.0, 1.0, 1.0],
stroke: [0.0, 0.0, 0.0, 1.0],
stroke_width: 0.0,
apply_fill: true,
apply_stroke: false,
stroke_over_fill: true,
tracking: 0.0,
leading: None,
justify: Justify::Left,
baseline_shift: 0.0,
h_scale: 100.0,
v_scale: 100.0,
faux_bold: false,
faux_italic: false,
all_caps: false,
small_caps: false,
box_size: None,
box_pos: [0.0, 0.0],
}
}
}
/// A gradient: colour stops `(position 0..1, rgba)` and opacity stops `(position, alpha)`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Gradient {
pub colors: Vec<(f64, [f32; 4])>,
pub opacities: Vec<(f64, f32)>,
}
impl Default for Gradient {
fn default() -> Self {
Gradient { colors: vec![(0.0, [1.0, 1.0, 1.0, 1.0]), (1.0, [0.0, 0.0, 0.0, 1.0])], opacities: vec![(0.0, 1.0), (1.0, 1.0)] }
}
}
impl Gradient {
pub fn sample(&self, t: f64) -> [f32; 4] {
let t = t.clamp(0.0, 1.0);
let col = sample_stops(&self.colors, t, |a, b, f| {
let mut o = [0.0; 4];
for i in 0..4 {
o[i] = a[i] + (b[i] - a[i]) * f as f32;
}
o
})
.unwrap_or([1.0; 4]);
let a = sample_stops(&self.opacities, t, |a, b, f| a + (b - a) * f as f32).unwrap_or(1.0);
[col[0], col[1], col[2], col[3] * a]
}
}
fn sample_stops<T: Copy>(stops: &[(f64, T)], t: f64, lerp: impl Fn(T, T, f64) -> T) -> Option<T> {
let first = stops.first()?;
if t <= first.0 {
return Some(first.1);
}
for w in stops.windows(2) {
if t <= w[1].0 {
let span = (w[1].0 - w[0].0).max(1e-12);
return Some(lerp(w[0].1, w[1].1, (t - w[0].0) / span));
}
}
stops.last().map(|s| s.1)
}
/// The value of a property.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "t", content = "v")]
pub enum Value {
Scalar(f64),
Vec2([f64; 2]),
Vec3([f64; 3]),
/// Straight RGBA 0..1.
Color([f64; 4]),
Bool(bool),
/// Index into a popup's options (1-based like AE's dropdown values is a UI concern; this is 0-based).
Enum(u32),
Path(ShapePath),
Text(Box<TextDoc>),
/// Layer reference (layer id), e.g. Layer Control effects, track matte sources.
Layer(Option<u64>),
Gradient(Gradient),
Str(String),
}
impl Value {
/// Numeric components (empty for non-numeric values).
pub fn components(&self) -> Vec<f64> {
match self {
Value::Scalar(v) => vec![*v],
Value::Vec2(v) => v.to_vec(),
Value::Vec3(v) => v.to_vec(),
Value::Color(v) => v.to_vec(),
Value::Bool(b) => vec![if *b { 1.0 } else { 0.0 }],
Value::Enum(i) => vec![*i as f64],
_ => vec![],
}
}
pub fn dims(&self) -> usize {
match self {
Value::Scalar(_) | Value::Bool(_) | Value::Enum(_) => 1,
Value::Vec2(_) => 2,
Value::Vec3(_) => 3,
Value::Color(_) => 4,
_ => 0,
}
}
/// Rebuild a value of the same kind from components.
pub fn with_components(&self, c: &[f64]) -> Value {
let g = |i: usize| c.get(i).copied().unwrap_or(0.0);
match self {
Value::Scalar(_) => Value::Scalar(g(0)),
Value::Vec2(_) => Value::Vec2([g(0), g(1)]),
Value::Vec3(_) => Value::Vec3([g(0), g(1), g(2)]),
Value::Color(_) => Value::Color([g(0), g(1), g(2), g(3)]),
Value::Bool(_) => Value::Bool(g(0) >= 0.5),
Value::Enum(_) => Value::Enum(g(0).round().max(0.0) as u32),
v => v.clone(),
}
}
/// Can be interpolated (otherwise keyframes behave as hold).
pub fn interpolates(&self) -> bool {
matches!(self, Value::Scalar(_) | Value::Vec2(_) | Value::Vec3(_) | Value::Color(_) | Value::Path(_) | Value::Gradient(_))
}
pub fn lerp(&self, o: &Value, t: f64) -> Value {
match (self, o) {
(Value::Path(a), Value::Path(b)) => Value::Path(a.lerp(b, t)),
(Value::Gradient(a), Value::Gradient(b)) if a.colors.len() == b.colors.len() && a.opacities.len() == b.opacities.len() => {
let colors = a
.colors
.iter()
.zip(&b.colors)
.map(|(x, y)| {
let mut c = [0.0; 4];
for i in 0..4 {
c[i] = x.1[i] + (y.1[i] - x.1[i]) * t as f32;
}
(x.0 + (y.0 - x.0) * t, c)
})
.collect();
let opacities = a.opacities.iter().zip(&b.opacities).map(|(x, y)| (x.0 + (y.0 - x.0) * t, x.1 + (y.1 - x.1) * t as f32)).collect();
Value::Gradient(Gradient { colors, opacities })
}
_ if self.interpolates() && self.dims() > 0 && self.dims() == o.dims() => {
let a = self.components();
let b = o.components();
self.with_components(&a.iter().zip(&b).map(|(x, y)| x + (y - x) * t).collect::<Vec<_>>())
}
_ => {
if t < 1.0 {
self.clone()
} else {
o.clone()
}
}
}
}
pub fn as_f64(&self) -> f64 {
self.components().first().copied().unwrap_or(0.0)
}
pub fn as_vec2(&self) -> [f64; 2] {
let c = self.components();
[c.first().copied().unwrap_or(0.0), c.get(1).copied().unwrap_or(0.0)]
}
pub fn as_vec3(&self) -> [f64; 3] {
let c = self.components();
[c.first().copied().unwrap_or(0.0), c.get(1).copied().unwrap_or(0.0), c.get(2).copied().unwrap_or(0.0)]
}
pub fn as_color(&self) -> [f32; 4] {
let c = self.components();
let g = |i: usize, d: f64| c.get(i).copied().unwrap_or(d) as f32;
[g(0, 0.0), g(1, 0.0), g(2, 0.0), g(3, 1.0)]
}
pub fn as_bool(&self) -> bool {
match self {
Value::Bool(b) => *b,
v => v.as_f64() != 0.0,
}
}
pub fn as_enum(&self) -> u32 {
match self {
Value::Enum(i) => *i,
v => v.as_f64().max(0.0) as u32,
}
}
pub fn as_path(&self) -> Option<&ShapePath> {
if let Value::Path(p) = self { Some(p) } else { None }
}
pub fn as_text(&self) -> Option<&TextDoc> {
if let Value::Text(t) = self { Some(t) } else { None }
}
pub fn as_layer(&self) -> Option<u64> {
if let Value::Layer(l) = self { *l } else { None }
}
pub fn kind_name(&self) -> &'static str {
match self {
Value::Scalar(_) => "scalar",
Value::Vec2(_) => "vec2",
Value::Vec3(_) => "vec3",
Value::Color(_) => "color",
Value::Bool(_) => "bool",
Value::Enum(_) => "enum",
Value::Path(_) => "path",
Value::Text(_) => "text",
Value::Layer(_) => "layer",
Value::Gradient(_) => "gradient",
Value::Str(_) => "string",
}
}
/// Lenient conversion from JSON (numbers, arrays, booleans, strings) to a value shaped like `self`.
pub fn coerce_json(&self, j: &serde_json::Value) -> Option<Value> {
use serde_json::Value as J;
let nums = |j: &J| -> Option<Vec<f64>> {
match j {
J::Number(n) => n.as_f64().map(|v| vec![v]),
J::Array(a) => a.iter().map(J::as_f64).collect(),
J::Bool(b) => Some(vec![if *b { 1.0 } else { 0.0 }]),
_ => None,
}
};
match self {
Value::Scalar(_) | Value::Vec2(_) | Value::Vec3(_) | Value::Enum(_) => {
let mut c = nums(j)?;
if c.len() == 1 && self.dims() > 1 {
c = vec![c[0]; self.dims()];
}
Some(self.with_components(&c))
}
Value::Color(_) => {
if let Some(s) = j.as_str() {
let c = effectcraft_color::Rgba::from_hex(s)?;
return Some(Value::Color([c.r as f64, c.g as f64, c.b as f64, c.a as f64]));
}
let mut c = nums(j)?;
if c.len() == 3 {
c.push(1.0);
}
Some(self.with_components(&c))
}
Value::Bool(_) => j.as_bool().or_else(|| j.as_f64().map(|v| v != 0.0)).map(Value::Bool),
Value::Str(_) => j.as_str().map(|s| Value::Str(s.to_string())),
Value::Text(t) => {
if let Some(s) = j.as_str() {
let mut t = (**t).clone();
t.text = s.to_string();
Some(Value::Text(Box::new(t)))
} else {
serde_json::from_value::<TextDoc>(j.clone()).ok().map(|t| Value::Text(Box::new(t)))
}
}
Value::Layer(_) => Some(Value::Layer(j.as_u64())),
_ => serde_json::from_value::<Value>(j.clone()).ok(),
}
}
/// JSON for display/automation (numbers/arrays for numeric kinds).
pub fn to_json(&self) -> serde_json::Value {
use serde_json::json;
match self {
Value::Scalar(v) => json!(v),
Value::Vec2(v) => json!(v),
Value::Vec3(v) => json!(v),
Value::Color(v) => json!(v),
Value::Bool(b) => json!(b),
Value::Enum(i) => json!(i),
Value::Str(s) => json!(s),
Value::Layer(l) => json!(l),
Value::Text(t) => json!(t.text),
v => serde_json::to_value(v).unwrap_or_default(),
}
}
}
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "effectcraft-path"
description = "Bezier shapes: AE vertex model ⇄ kurbo, parametric shapes, path operations, stroking/dashing and anti-aliased coverage rasterisation"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[lints]
workspace = true
[dependencies]
effectcraft-geom = { workspace = true }
effectcraft-keyframe = { workspace = true }
effectcraft-raster = { workspace = true }
kurbo = { workspace = true }
tiny-skia = { workspace = true }
+368
View File
@@ -0,0 +1,368 @@
//! Bezier shapes for shape layers, masks and text.
//!
//! Geometry is kept in [`kurbo::BezPath`]; anti-aliased coverage comes from tiny-skia's scanline
//! rasteriser (BSD-3) and is converted to our `f32` [`Mask`]. Path operations (trim, round corners,
//! pucker & bloat, zig zag, twist, wiggle, offset, merge) live in [`ops`].
pub mod ops;
use effectcraft_geom::Mat3;
use effectcraft_keyframe::ShapePath;
use effectcraft_raster::Mask;
pub use kurbo::{BezPath, PathEl, Point};
use kurbo::{Shape, Vec2 as KVec2};
/// Convert the AE vertex/tangent model to a kurbo path.
pub fn to_kurbo(p: &ShapePath) -> BezPath {
let mut out = BezPath::new();
let n = p.vertices.len();
if n == 0 {
return out;
}
let v = |i: usize| Point::new(p.vertices[i][0], p.vertices[i][1]);
let ot = |i: usize| p.out_tangents.get(i).copied().unwrap_or([0.0; 2]);
let it = |i: usize| p.in_tangents.get(i).copied().unwrap_or([0.0; 2]);
out.move_to(v(0));
let seg = |out: &mut BezPath, a: usize, b: usize| {
let (o, i) = (ot(a), it(b));
if o == [0.0; 2] && i == [0.0; 2] {
out.line_to(v(b));
} else {
out.curve_to(v(a) + KVec2::new(o[0], o[1]), v(b) + KVec2::new(i[0], i[1]), v(b));
}
};
for i in 0..n - 1 {
seg(&mut out, i, i + 1);
}
if p.closed && n > 1 {
seg(&mut out, n - 1, 0);
out.close_path();
}
out
}
/// Convert a kurbo path (first subpath) back to the vertex model (quads are elevated).
pub fn from_kurbo(path: &BezPath) -> Vec<ShapePath> {
let mut out = Vec::new();
let mut cur: Option<ShapePath> = None;
let mut last = Point::ZERO;
let flush = |cur: &mut Option<ShapePath>, out: &mut Vec<ShapePath>| {
if let Some(mut c) = cur.take() {
// A closing segment ending on the first vertex duplicates it.
if c.closed && c.vertices.len() > 1 {
let f = c.vertices[0];
let l = *c.vertices.last().unwrap_or(&f);
if (f[0] - l[0]).abs() < 1e-9 && (f[1] - l[1]).abs() < 1e-9 {
let it = c.in_tangents.pop().unwrap_or([0.0; 2]);
c.vertices.pop();
c.out_tangents.pop();
c.in_tangents[0] = it;
}
}
if !c.vertices.is_empty() {
out.push(c);
}
}
};
for el in path.elements() {
match *el {
PathEl::MoveTo(p) => {
flush(&mut cur, &mut out);
cur = Some(ShapePath { vertices: vec![[p.x, p.y]], in_tangents: vec![[0.0; 2]], out_tangents: vec![[0.0; 2]], closed: false });
last = p;
}
PathEl::LineTo(p) => {
if let Some(c) = cur.as_mut() {
c.vertices.push([p.x, p.y]);
c.in_tangents.push([0.0; 2]);
c.out_tangents.push([0.0; 2]);
}
last = p;
}
PathEl::QuadTo(q, p) => {
let c1 = last + (q - last) * (2.0 / 3.0);
let c2 = p + (q - p) * (2.0 / 3.0);
if let Some(c) = cur.as_mut() {
if let Some(o) = c.out_tangents.last_mut() {
*o = [c1.x - last.x, c1.y - last.y];
}
c.vertices.push([p.x, p.y]);
c.in_tangents.push([c2.x - p.x, c2.y - p.y]);
c.out_tangents.push([0.0; 2]);
}
last = p;
}
PathEl::CurveTo(c1, c2, p) => {
if let Some(c) = cur.as_mut() {
if let Some(o) = c.out_tangents.last_mut() {
*o = [c1.x - last.x, c1.y - last.y];
}
c.vertices.push([p.x, p.y]);
c.in_tangents.push([c2.x - p.x, c2.y - p.y]);
c.out_tangents.push([0.0; 2]);
}
last = p;
}
PathEl::ClosePath => {
if let Some(c) = cur.as_mut() {
c.closed = true;
}
}
}
}
flush(&mut cur, &mut out);
out
}
/// Rectangle path centred at `pos` with corner `roundness` (AE draws clockwise from the top-right).
pub fn rect(size: [f64; 2], pos: [f64; 2], roundness: f64) -> BezPath {
let r = kurbo::Rect::from_center_size(Point::new(pos[0], pos[1]), (size[0].abs(), size[1].abs()));
let rad = roundness.clamp(0.0, size[0].abs().min(size[1].abs()) / 2.0);
if rad > 0.0 { r.to_rounded_rect(rad).to_path(0.1) } else { r.to_path(0.1) }
}
pub fn ellipse(size: [f64; 2], pos: [f64; 2]) -> BezPath {
to_kurbo(&ShapePath::ellipse(pos, size[0], size[1]))
}
/// Polystar: a star (`inner` radius used) or polygon, rotation in degrees, roundness in percent.
pub fn polystar(star: bool, points: f64, pos: [f64; 2], rotation: f64, inner: f64, outer: f64, inner_round: f64, outer_round: f64) -> BezPath {
let n = points.max(3.0).round() as usize;
let verts = if star { n * 2 } else { n };
let mut path = BezPath::new();
let step = std::f64::consts::TAU / verts as f64;
let start = (rotation - 90.0).to_radians();
let mut pts = Vec::with_capacity(verts);
for i in 0..verts {
let r = if star && i % 2 == 1 { inner } else { outer };
let a = start + step * i as f64;
pts.push((Point::new(pos[0] + r * a.cos(), pos[1] + r * a.sin()), a, r, if star && i % 2 == 1 { inner_round } else { outer_round }));
}
let tangent = |(p, a, r, round): (Point, f64, f64, f64), sign: f64| -> Point {
if round == 0.0 {
return p;
}
// Tangent perpendicular to the radius, length proportional to the arc between vertices.
let len = r * step * 0.25 * round / 100.0 * 4.0 / 3.0 * 0.75;
let d = KVec2::new(-a.sin(), a.cos()) * len * sign;
p + d
};
path.move_to(pts[0].0);
for i in 0..verts {
let a = pts[i];
let b = pts[(i + 1) % verts];
if a.3 == 0.0 && b.3 == 0.0 {
path.line_to(b.0);
} else {
path.curve_to(tangent(a, 1.0), tangent(b, -1.0), b.0);
}
}
path.close_path();
path
}
fn to_skia(path: &BezPath) -> Option<tiny_skia::Path> {
let mut pb = tiny_skia::PathBuilder::new();
for el in path.elements() {
match *el {
PathEl::MoveTo(p) => pb.move_to(p.x as f32, p.y as f32),
PathEl::LineTo(p) => pb.line_to(p.x as f32, p.y as f32),
PathEl::QuadTo(a, p) => pb.quad_to(a.x as f32, a.y as f32, p.x as f32, p.y as f32),
PathEl::CurveTo(a, b, p) => pb.cubic_to(a.x as f32, a.y as f32, b.x as f32, b.y as f32, p.x as f32, p.y as f32),
PathEl::ClosePath => pb.close(),
}
}
pb.finish()
}
fn from_skia(p: &tiny_skia::Path) -> BezPath {
let mut out = BezPath::new();
let pt = |p: tiny_skia::Point| Point::new(p.x as f64, p.y as f64);
for seg in p.segments() {
match seg {
tiny_skia::PathSegment::MoveTo(a) => out.move_to(pt(a)),
tiny_skia::PathSegment::LineTo(a) => out.line_to(pt(a)),
tiny_skia::PathSegment::QuadTo(a, b) => out.quad_to(pt(a), pt(b)),
tiny_skia::PathSegment::CubicTo(a, b, c) => out.curve_to(pt(a), pt(b), pt(c)),
tiny_skia::PathSegment::Close => out.close_path(),
}
}
out
}
fn skia_transform(m: &Mat3) -> tiny_skia::Transform {
let a = &m.0;
tiny_skia::Transform::from_row(a[0][0] as f32, a[1][0] as f32, a[0][1] as f32, a[1][1] as f32, a[0][2] as f32, a[1][2] as f32)
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum FillRule {
#[default]
NonZero,
EvenOdd,
}
/// Anti-aliased coverage of `paths` (in local space) transformed by `m` into a `w × h` mask.
pub fn fill_coverage(paths: &[BezPath], m: &Mat3, w: u32, h: u32, rule: FillRule) -> Mask {
let mut out = Mask::new(w, h, 0.0);
let Some(mut mask) = tiny_skia::Mask::new(w.max(1), h.max(1)) else { return out };
let mut all = BezPath::new();
for p in paths {
all.extend(p.elements().iter().copied());
}
let Some(sp) = to_skia(&all) else { return out };
let fr = match rule {
FillRule::NonZero => tiny_skia::FillRule::Winding,
FillRule::EvenOdd => tiny_skia::FillRule::EvenOdd,
};
mask.fill_path(&sp, fr, true, skia_transform(m));
for (o, &v) in out.data.iter_mut().zip(mask.data()) {
*o = v as f32 / 255.0;
}
out
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Cap {
#[default]
Butt,
Round,
Square,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Join {
#[default]
Miter,
Round,
Bevel,
}
#[derive(Clone, Debug, PartialEq)]
pub struct StrokeStyle {
pub width: f64,
pub cap: Cap,
pub join: Join,
pub miter: f64,
/// Dash pattern (dash, gap, …) and offset.
pub dash: Option<(Vec<f64>, f64)>,
}
impl Default for StrokeStyle {
fn default() -> Self {
StrokeStyle { width: 1.0, cap: Cap::Butt, join: Join::Miter, miter: 4.0, dash: None }
}
}
/// Outline of a stroke as a fillable path (local space). `res_scale` = device/local scale.
pub fn stroke_outline(paths: &[BezPath], style: &StrokeStyle, res_scale: f64) -> Option<BezPath> {
if style.width <= 0.0 {
return None;
}
let mut all = BezPath::new();
for p in paths {
all.extend(p.elements().iter().copied());
}
let mut sp = to_skia(&all)?;
if let Some((d, off)) = &style.dash {
let arr: Vec<f32> = d.iter().map(|v| v.max(0.0) as f32).collect();
if arr.len() >= 2 && arr.iter().any(|v| *v > 0.0) {
let arr = if arr.len() % 2 == 1 { [arr.clone(), arr].concat() } else { arr };
if let Some(dash) = tiny_skia::StrokeDash::new(arr, *off as f32) {
sp = sp.dash(&dash, res_scale as f32)?;
}
}
}
let st = tiny_skia::Stroke {
width: style.width as f32,
miter_limit: style.miter.max(1.0) as f32,
line_cap: match style.cap {
Cap::Butt => tiny_skia::LineCap::Butt,
Cap::Round => tiny_skia::LineCap::Round,
Cap::Square => tiny_skia::LineCap::Square,
},
line_join: match style.join {
Join::Miter => tiny_skia::LineJoin::Miter,
Join::Round => tiny_skia::LineJoin::Round,
Join::Bevel => tiny_skia::LineJoin::Bevel,
},
dash: None,
};
let out = sp.stroke(&st, res_scale.max(0.01) as f32)?;
Some(from_skia(&out))
}
/// Coverage of a stroke.
pub fn stroke_coverage(paths: &[BezPath], style: &StrokeStyle, m: &Mat3, w: u32, h: u32) -> Mask {
match stroke_outline(paths, style, m.mean_scale()) {
Some(o) => fill_coverage(&[o], m, w, h, FillRule::NonZero),
None => Mask::new(w, h, 0.0),
}
}
/// Bounding box of paths in local space.
pub fn bounds(paths: &[BezPath]) -> Option<kurbo::Rect> {
let mut r: Option<kurbo::Rect> = None;
for p in paths {
if p.elements().is_empty() {
continue;
}
let b = p.bounding_box();
r = Some(r.map_or(b, |a| a.union(b)));
}
r
}
/// Transform paths by an affine Mat3.
pub fn transform(paths: &[BezPath], m: &Mat3) -> Vec<BezPath> {
let a = &m.0;
let k = kurbo::Affine::new([a[0][0], a[1][0], a[0][1], a[1][1], a[0][2], a[1][2]]);
paths.iter().map(|p| k * p.clone()).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrip_vertex_model() {
let sp = ShapePath::ellipse([10.0, 20.0], 40.0, 30.0);
let k = to_kurbo(&sp);
let back = from_kurbo(&k);
assert_eq!(back.len(), 1);
let b = &back[0];
assert!(b.closed);
assert_eq!(b.vertices.len(), 4);
for i in 0..4 {
for c in 0..2 {
assert!((b.vertices[i][c] - sp.vertices[i][c]).abs() < 1e-9);
assert!((b.in_tangents[i][c] - sp.in_tangents[i][c]).abs() < 1e-9, "{i} {b:?}");
assert!((b.out_tangents[i][c] - sp.out_tangents[i][c]).abs() < 1e-9);
}
}
}
#[test]
fn rect_coverage_area() {
let r = rect([20.0, 10.0], [50.0, 50.0], 0.0);
let m = fill_coverage(&[r], &Mat3::IDENTITY, 100, 100, FillRule::NonZero);
let area: f32 = m.data.iter().sum();
assert!((area - 200.0).abs() < 1.0, "{area}");
}
#[test]
fn stroke_has_area() {
let r = rect([40.0, 40.0], [50.0, 50.0], 0.0);
let st = StrokeStyle { width: 4.0, ..Default::default() };
let m = stroke_coverage(&[r], &st, &Mat3::IDENTITY, 100, 100);
let area: f32 = m.data.iter().sum();
assert!((area - 4.0 * 160.0).abs() < 40.0, "{area}");
}
#[test]
fn star_has_right_vertex_count() {
let p = polystar(true, 5.0, [0.0, 0.0], 0.0, 20.0, 50.0, 0.0, 0.0);
let v = from_kurbo(&p);
assert_eq!(v[0].vertices.len(), 10);
}
}
+138
View File
@@ -0,0 +1,138 @@
//! Shape-layer path operations.
use kurbo::{BezPath, ParamCurve, ParamCurveArclen, PathSeg};
const ACC: f64 = 1e-3;
fn segments(p: &BezPath) -> Vec<PathSeg> {
p.segments().collect()
}
fn append(out: &mut BezPath, seg: PathSeg, start: bool) {
if start {
out.move_to(seg.start());
}
match seg {
PathSeg::Line(l) => out.line_to(l.p1),
PathSeg::Quad(q) => out.quad_to(q.p1, q.p2),
PathSeg::Cubic(c) => out.curve_to(c.p1, c.p2, c.p3),
}
}
/// Portion of a path between arc-length fractions `a..b` (0..1, a ≤ b).
fn sub_path(segs: &[PathSeg], lens: &[f64], total: f64, a: f64, b: f64, out: &mut BezPath) {
let (sa, sb) = (a * total, b * total);
let mut acc = 0.0;
let mut started = false;
for (seg, &len) in segs.iter().zip(lens) {
let (s0, s1) = (acc, acc + len);
acc = s1;
if s1 <= sa || s0 >= sb || len <= 0.0 {
continue;
}
let t0 = if sa > s0 { seg.inv_arclen(sa - s0, ACC) } else { 0.0 };
let t1 = if sb < s1 { seg.inv_arclen(sb - s0, ACC) } else { 1.0 };
let piece = seg.subsegment(t0..t1);
append(out, piece, !started);
started = true;
}
}
/// Trim Paths on each path individually: `start`/`end` in percent, `offset` in degrees.
pub fn trim(paths: &[BezPath], start: f64, end: f64, offset_deg: f64) -> Vec<BezPath> {
let (mut s, mut e) = (start / 100.0, end / 100.0);
if s > e {
std::mem::swap(&mut s, &mut e);
}
if e - s >= 1.0 - 1e-9 && offset_deg == 0.0 {
return paths.to_vec();
}
if e - s <= 1e-9 {
return vec![];
}
let off = (offset_deg / 360.0).rem_euclid(1.0);
let (s, e) = (s + off, e + off);
paths
.iter()
.filter_map(|p| {
let segs = segments(p);
let lens: Vec<f64> = segs.iter().map(|s| s.arclen(ACC)).collect();
let total: f64 = lens.iter().sum();
if total <= 0.0 {
return None;
}
let mut out = BezPath::new();
if e <= 1.0 {
sub_path(&segs, &lens, total, s, e, &mut out);
} else if s >= 1.0 {
sub_path(&segs, &lens, total, s - 1.0, e - 1.0, &mut out);
} else {
sub_path(&segs, &lens, total, s, 1.0, &mut out);
sub_path(&segs, &lens, total, 0.0, e - 1.0, &mut out);
}
Some(out)
})
.collect()
}
/// Trim Paths "Simultaneously" across several paths: treated as one continuous length.
pub fn trim_simultaneous(paths: &[BezPath], start: f64, end: f64, offset_deg: f64) -> Vec<BezPath> {
// AE's "Simultaneously" trims each path by the same fractions.
trim(paths, start, end, offset_deg)
}
/// Pucker & Bloat: move vertices towards (negative) / away from (positive) the centre while
/// pulling control points the other way.
pub fn pucker_bloat(paths: &[BezPath], amount: f64) -> Vec<BezPath> {
let a = amount / 100.0;
paths
.iter()
.map(|p| {
let Some(b) = crate::bounds(std::slice::from_ref(p)) else { return p.clone() };
let c = b.center();
let mut out = BezPath::new();
for el in p.elements() {
let mv = |q: kurbo::Point| q + (q - c) * a;
let mvc = |q: kurbo::Point| q - (q - c) * a;
match *el {
kurbo::PathEl::MoveTo(q) => out.move_to(mv(q)),
kurbo::PathEl::LineTo(q) => {
let prev = out.elements().last().and_then(|e| e.end_point()).unwrap_or(q);
out.curve_to(mvc(prev.lerp(q, 1.0 / 3.0)), mvc(prev.lerp(q, 2.0 / 3.0)), mv(q))
}
kurbo::PathEl::QuadTo(x, q) => out.quad_to(mvc(x), mv(q)),
kurbo::PathEl::CurveTo(x, y, q) => out.curve_to(mvc(x), mvc(y), mv(q)),
kurbo::PathEl::ClosePath => out.close_path(),
}
}
out
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use kurbo::Shape;
fn len(p: &[BezPath]) -> f64 {
p.iter().map(|x| x.segments().map(|s| s.arclen(1e-4)).sum::<f64>()).sum()
}
#[test]
fn trim_half_square() {
let sq = kurbo::Rect::new(0.0, 0.0, 100.0, 100.0).to_path(0.1);
let t = trim(std::slice::from_ref(&sq), 0.0, 50.0, 0.0);
assert!((len(&t) - 200.0).abs() < 0.5, "{}", len(&t));
let w = trim(&[sq], 75.0, 125.0, 0.0);
let _ = w;
let t2 = trim(&[kurbo::Rect::new(0.0, 0.0, 100.0, 100.0).to_path(0.1)], 0.0, 50.0, 270.0);
assert!((len(&t2) - 200.0).abs() < 0.5);
}
#[test]
fn trim_empty() {
let sq = kurbo::Rect::new(0.0, 0.0, 10.0, 10.0).to_path(0.1);
assert!(trim(&[sq], 30.0, 30.0, 0.0).is_empty());
}
}
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "effectcraft-project"
description = "The EffectCraft document: project items, compositions, layers and the After-Effects-style property tree"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[lints]
workspace = true
[dependencies]
effectcraft-time = { workspace = true }
effectcraft-geom = { workspace = true }
effectcraft-color = { workspace = true }
effectcraft-keyframe = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
+464
View File
@@ -0,0 +1,464 @@
//! Builders for the standard property groups and layers.
use effectcraft_color::{BlendMode, Label};
use effectcraft_keyframe::{Gradient, ShapePath, TextDoc, Value};
use effectcraft_time::Tick;
use crate::props::{GroupKind, MaskMode, ParamUi, PropGroup, Property};
use crate::{Comp, Layer, LayerId, LayerSource, LightKind, Project, Switches};
/// Id allocator borrowing the project's counter.
pub struct Ids<'a>(pub &'a mut u64);
impl Ids<'_> {
pub fn alloc(&mut self) -> u64 {
let v = *self.0;
*self.0 += 1;
v
}
pub fn prop(&mut self, m: &str, name: &str, v: Value) -> Property {
Property::new(self.alloc(), m, name, v)
}
pub fn group(&mut self, m: &str, name: &str) -> PropGroup {
PropGroup::new(self.alloc(), m, name)
}
}
fn slider(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() }
}
/// Layer Transform group. Values are always 3D; 2D layers show two dimensions.
pub fn transform(ids: &mut Ids, anchor: [f64; 2], position: [f64; 2]) -> PropGroup {
let mut a = ids.prop("anchor", "Anchor Point", Value::Vec3([anchor[0], anchor[1], 0.0])).with_ui(ParamUi::Point).spatial();
a.shown_dims = 2;
let mut p = ids.prop("position", "Position", Value::Vec3([position[0], position[1], 0.0])).with_ui(ParamUi::Point).spatial();
p.shown_dims = 2;
let mut s = ids.prop("scale", "Scale", Value::Vec3([100.0, 100.0, 100.0])).with_ui(ParamUi::Percent);
s.shown_dims = 2;
let mut o = ids.prop("orientation", "Orientation", Value::Vec3([0.0; 3])).with_ui(ParamUi::Angle);
o.three_d_only = true;
let mut rx = ids.prop("rotationX", "X Rotation", Value::Scalar(0.0)).with_ui(ParamUi::Angle);
rx.three_d_only = true;
let mut ry = ids.prop("rotationY", "Y Rotation", Value::Scalar(0.0)).with_ui(ParamUi::Angle);
ry.three_d_only = true;
let rz = ids.prop("rotation", "Rotation", Value::Scalar(0.0)).with_ui(ParamUi::Angle);
let op = ids.prop("opacity", "Opacity", Value::Scalar(100.0)).with_ui(slider(0.0, 100.0, 0.0, 100.0, 0));
ids.group("transform", "Transform").with(a).with(p).with(s).with(o).with(rx).with(ry).with(rz).with(op)
}
pub fn masks(ids: &mut Ids) -> PropGroup {
ids.group("masks", "Masks")
}
pub fn effects(ids: &mut Ids) -> PropGroup {
ids.group("effects", "Effects")
}
/// Mask colours cycle like AE's default mask colours.
pub const MASK_COLORS: [[u8; 3]; 8] =
[[0xb1, 0xb4, 0x4f], [0x4f, 0x8c, 0xc9], [0xc9, 0x4f, 0x8c], [0x4f, 0xc9, 0x7a], [0xc9, 0x8c, 0x4f], [0x8c, 0x4f, 0xc9], [0x4f, 0xc9, 0xc9], [0xc9, 0x4f, 0x4f]];
pub fn mask(ids: &mut Ids, name: &str, path: ShapePath, mode: MaskMode, color: [u8; 3]) -> PropGroup {
let mut g = ids.group("mask", name);
g.kind = GroupKind::Mask { mode, inverted: false, color, locked: false };
g.with(ids.prop("path", "Mask Path", Value::Path(path)).with_ui(ParamUi::Path))
.with(ids.prop("feather", "Mask Feather", Value::Vec2([0.0, 0.0])).with_ui(ParamUi::Pixels))
.with(ids.prop("opacity", "Mask Opacity", Value::Scalar(100.0)).with_ui(slider(0.0, 100.0, 0.0, 100.0, 0)))
.with(ids.prop("expansion", "Mask Expansion", Value::Scalar(0.0)).with_ui(ParamUi::Pixels))
}
pub fn text(ids: &mut Ids, doc: TextDoc) -> PropGroup {
let mut st = ids.prop("sourceText", "Source Text", Value::Text(Box::new(doc))).with_ui(ParamUi::Text);
st.hold_only = true;
let path_opts = ids.group("pathOptions", "Path Options").with(ids.prop("path", "Path", Value::Enum(0)).with_ui(popup(&["None"])));
let more = ids
.group("moreOptions", "More Options")
.with(ids.prop("anchorGrouping", "Anchor Point Grouping", Value::Enum(0)).with_ui(popup(&["Character", "Word", "Line", "All"])))
.with(ids.prop("groupingAlignment", "Grouping Alignment", Value::Vec2([0.0, 0.0])).with_ui(ParamUi::Percent))
.with(ids.prop("fillStroke", "Fill & Stroke", Value::Enum(0)).with_ui(popup(&["Per Character Palette", "All Fills Over All Strokes", "All Strokes Over All Fills"])))
.with(ids.prop("interCharBlend", "Inter-Character Blending", Value::Enum(0)).with_ui(popup(&["Normal", "Multiply", "Screen", "Overlay"])));
ids.group("text", "Text").with(st).with(path_opts).with(more).with(ids.group("animators", "Animators"))
}
/// A text animator with one range selector and the given properties.
pub fn text_animator(ids: &mut Ids, name: &str, props: Vec<Property>) -> PropGroup {
let mut g = ids.group("animator", name);
g.kind = GroupKind::Indexed;
let mut sels = ids.group("selectors", "Selectors");
sels.children.push(range_selector(ids, "Range Selector 1").into());
let mut pg = ids.group("properties", "Properties");
for p in props {
pg.children.push(p.into());
}
g.with(sels).with(pg)
}
pub fn range_selector(ids: &mut Ids, name: &str) -> PropGroup {
let mut g = ids.group("rangeSelector", name);
g.kind = GroupKind::Indexed;
let adv = ids
.group("advanced", "Advanced")
.with(ids.prop("units", "Units", Value::Enum(0)).with_ui(popup(&["Percentage", "Index"])))
.with(ids.prop("basedOn", "Based On", Value::Enum(0)).with_ui(popup(&["Characters", "Characters Excluding Spaces", "Words", "Lines"])))
.with(ids.prop("mode", "Mode", Value::Enum(0)).with_ui(popup(&["Add", "Subtract", "Intersect", "Min", "Max", "Difference"])))
.with(ids.prop("amount", "Amount", Value::Scalar(100.0)).with_ui(ParamUi::Percent))
.with(ids.prop("shape", "Shape", Value::Enum(0)).with_ui(popup(&["Square", "Ramp Up", "Ramp Down", "Triangle", "Round", "Smooth"])))
.with(ids.prop("smoothness", "Smoothness", Value::Scalar(100.0)).with_ui(ParamUi::Percent))
.with(ids.prop("easeHigh", "Ease High", Value::Scalar(0.0)).with_ui(ParamUi::Percent))
.with(ids.prop("easeLow", "Ease Low", Value::Scalar(0.0)).with_ui(ParamUi::Percent))
.with(ids.prop("randomize", "Randomize Order", Value::Bool(false)).with_ui(ParamUi::Checkbox))
.with(ids.prop("randomSeed", "Random Seed", Value::Scalar(0.0)));
g.with(ids.prop("start", "Start", Value::Scalar(0.0)).with_ui(ParamUi::Percent))
.with(ids.prop("end", "End", Value::Scalar(100.0)).with_ui(ParamUi::Percent))
.with(ids.prop("offset", "Offset", Value::Scalar(0.0)).with_ui(ParamUi::Percent))
.with(adv)
}
/// Text animator properties by id (Animate ▸ …).
pub fn text_anim_prop(ids: &mut Ids, kind: &str) -> Option<Property> {
Some(match kind {
"anchor" => ids.prop("anchor", "Anchor Point", Value::Vec2([0.0, 0.0])).with_ui(ParamUi::Point),
"position" => ids.prop("position", "Position", Value::Vec2([0.0, 0.0])).with_ui(ParamUi::Point),
"scale" => ids.prop("scale", "Scale", Value::Vec2([100.0, 100.0])).with_ui(ParamUi::Percent),
"skew" => ids.prop("skew", "Skew", Value::Scalar(0.0)),
"rotation" => ids.prop("rotation", "Rotation", Value::Scalar(0.0)).with_ui(ParamUi::Angle),
"opacity" => ids.prop("opacity", "Opacity", Value::Scalar(100.0)).with_ui(ParamUi::Percent),
"fillColor" => ids.prop("fillColor", "Fill Color", Value::Color([1.0, 0.0, 0.0, 1.0])).with_ui(ParamUi::Color),
"strokeColor" => ids.prop("strokeColor", "Stroke Color", Value::Color([1.0, 0.0, 0.0, 1.0])).with_ui(ParamUi::Color),
"strokeWidth" => ids.prop("strokeWidth", "Stroke Width", Value::Scalar(0.0)).with_ui(ParamUi::Pixels),
"tracking" => ids.prop("tracking", "Tracking Amount", Value::Scalar(0.0)),
"lineSpacing" => ids.prop("lineSpacing", "Line Spacing", Value::Vec2([0.0, 0.0])),
"characterOffset" => ids.prop("characterOffset", "Character Offset", Value::Scalar(0.0)),
"blur" => ids.prop("blur", "Blur", Value::Vec2([0.0, 0.0])),
_ => return None,
})
}
// ---------------------------------------------------------------- shape layer contents
pub fn contents(ids: &mut Ids) -> PropGroup {
ids.group("contents", "Contents")
}
/// Shape group transform (2D).
pub fn shape_transform(ids: &mut Ids) -> PropGroup {
ids.group("transform", "Transform")
.with(ids.prop("anchor", "Anchor Point", Value::Vec2([0.0, 0.0])).with_ui(ParamUi::Point))
.with(ids.prop("position", "Position", Value::Vec2([0.0, 0.0])).with_ui(ParamUi::Point))
.with(ids.prop("scale", "Scale", Value::Vec2([100.0, 100.0])).with_ui(ParamUi::Percent))
.with(ids.prop("skew", "Skew", Value::Scalar(0.0)).with_ui(slider(-85.0, 85.0, -85.0, 85.0, 1)))
.with(ids.prop("skewAxis", "Skew Axis", Value::Scalar(0.0)).with_ui(ParamUi::Angle))
.with(ids.prop("rotation", "Rotation", Value::Scalar(0.0)).with_ui(ParamUi::Angle))
.with(ids.prop("opacity", "Opacity", Value::Scalar(100.0)).with_ui(slider(0.0, 100.0, 0.0, 100.0, 0)))
}
fn indexed(mut g: PropGroup) -> PropGroup {
g.kind = GroupKind::Indexed;
g
}
pub fn shape_group(ids: &mut Ids, name: &str, items: Vec<PropGroup>) -> PropGroup {
let mut c = ids.group("contents", "Contents");
for i in items {
c.children.push(i.into());
}
let mut g = indexed(ids.group("group", name));
g.children.push(ids.prop("blend", "Blend Mode", Value::Enum(0)).with_ui(ParamUi::Hidden).into());
g.with(c).with(shape_transform(ids))
}
pub fn shape_rect(ids: &mut Ids, size: [f64; 2], position: [f64; 2], roundness: f64) -> PropGroup {
indexed(ids.group("rect", "Rectangle Path 1"))
.with(ids.prop("direction", "Path Direction", Value::Enum(0)).with_ui(ParamUi::Hidden))
.with(ids.prop("size", "Size", Value::Vec2(size)).with_ui(ParamUi::Pixels))
.with(ids.prop("position", "Position", Value::Vec2(position)).with_ui(ParamUi::Point))
.with(ids.prop("roundness", "Roundness", Value::Scalar(roundness)).with_ui(ParamUi::Pixels))
}
pub fn shape_ellipse(ids: &mut Ids, size: [f64; 2], position: [f64; 2]) -> PropGroup {
indexed(ids.group("ellipse", "Ellipse Path 1"))
.with(ids.prop("direction", "Path Direction", Value::Enum(0)).with_ui(ParamUi::Hidden))
.with(ids.prop("size", "Size", Value::Vec2(size)).with_ui(ParamUi::Pixels))
.with(ids.prop("position", "Position", Value::Vec2(position)).with_ui(ParamUi::Point))
}
/// Polystar: `star = false` makes a polygon.
pub fn shape_star(ids: &mut Ids, star: bool, points: f64, position: [f64; 2], outer: f64, inner: f64) -> PropGroup {
let mut g = indexed(ids.group("star", "Polystar Path 1"))
.with(ids.prop("direction", "Path Direction", Value::Enum(0)).with_ui(ParamUi::Hidden))
.with(ids.prop("type", "Type", Value::Enum(if star { 0 } else { 1 })).with_ui(popup(&["Star", "Polygon"])))
.with(ids.prop("points", "Points", Value::Scalar(points)).with_ui(slider(3.0, 100.0, 3.0, 20.0, 1)))
.with(ids.prop("position", "Position", Value::Vec2(position)).with_ui(ParamUi::Point))
.with(ids.prop("rotation", "Rotation", Value::Scalar(0.0)).with_ui(ParamUi::Angle));
if star {
g = g.with(ids.prop("innerRadius", "Inner Radius", Value::Scalar(inner)).with_ui(ParamUi::Pixels));
}
g = g.with(ids.prop("outerRadius", "Outer Radius", Value::Scalar(outer)).with_ui(ParamUi::Pixels));
if star {
g = g.with(ids.prop("innerRoundness", "Inner Roundness", Value::Scalar(0.0)).with_ui(ParamUi::Percent));
}
g.with(ids.prop("outerRoundness", "Outer Roundness", Value::Scalar(0.0)).with_ui(ParamUi::Percent))
}
pub fn shape_path(ids: &mut Ids, path: ShapePath) -> PropGroup {
indexed(ids.group("path", "Path 1"))
.with(ids.prop("direction", "Path Direction", Value::Enum(0)).with_ui(ParamUi::Hidden))
.with(ids.prop("path", "Path", Value::Path(path)).with_ui(ParamUi::Path))
}
pub fn shape_fill(ids: &mut Ids, color: [f64; 4]) -> PropGroup {
indexed(ids.group("fill", "Fill 1"))
.with(ids.prop("blend", "Blend Mode", Value::Enum(0)).with_ui(ParamUi::Hidden))
.with(ids.prop("composite", "Composite", Value::Enum(0)).with_ui(popup(&["Below Previous in Same Group", "Above Previous in Same Group"])))
.with(ids.prop("rule", "Fill Rule", Value::Enum(0)).with_ui(popup(&["Non-Zero Winding", "Even-Odd"])))
.with(ids.prop("color", "Color", Value::Color(color)).with_ui(ParamUi::Color))
.with(ids.prop("opacity", "Opacity", Value::Scalar(100.0)).with_ui(slider(0.0, 100.0, 0.0, 100.0, 0)))
}
pub fn shape_stroke(ids: &mut Ids, color: [f64; 4], width: f64) -> PropGroup {
indexed(ids.group("stroke", "Stroke 1"))
.with(ids.prop("blend", "Blend Mode", Value::Enum(0)).with_ui(ParamUi::Hidden))
.with(ids.prop("composite", "Composite", Value::Enum(0)).with_ui(popup(&["Below Previous in Same Group", "Above Previous in Same Group"])))
.with(ids.prop("color", "Color", Value::Color(color)).with_ui(ParamUi::Color))
.with(ids.prop("opacity", "Opacity", Value::Scalar(100.0)).with_ui(slider(0.0, 100.0, 0.0, 100.0, 0)))
.with(ids.prop("width", "Stroke Width", Value::Scalar(width)).with_ui(ParamUi::Pixels))
.with(ids.prop("cap", "Line Cap", Value::Enum(0)).with_ui(popup(&["Butt Cap", "Round Cap", "Projecting Cap"])))
.with(ids.prop("join", "Line Join", Value::Enum(0)).with_ui(popup(&["Miter Join", "Round Join", "Bevel Join"])))
.with(ids.prop("miter", "Miter Limit", Value::Scalar(4.0)))
.with(
ids.group("dashes", "Dashes")
.with(ids.prop("dash", "Dash", Value::Scalar(0.0)).with_ui(ParamUi::Pixels))
.with(ids.prop("gap", "Gap", Value::Scalar(0.0)).with_ui(ParamUi::Pixels))
.with(ids.prop("offset", "Offset", Value::Scalar(0.0)).with_ui(ParamUi::Pixels)),
)
}
pub fn shape_gradient_fill(ids: &mut Ids, radial: bool, start: [f64; 2], end: [f64; 2], g: Gradient) -> PropGroup {
indexed(ids.group("gfill", "Gradient Fill 1"))
.with(ids.prop("blend", "Blend Mode", Value::Enum(0)).with_ui(ParamUi::Hidden))
.with(ids.prop("rule", "Fill Rule", Value::Enum(0)).with_ui(popup(&["Non-Zero Winding", "Even-Odd"])))
.with(ids.prop("type", "Type", Value::Enum(radial as u32)).with_ui(popup(&["Linear", "Radial"])))
.with(ids.prop("start", "Start Point", Value::Vec2(start)).with_ui(ParamUi::Point))
.with(ids.prop("end", "End Point", Value::Vec2(end)).with_ui(ParamUi::Point))
.with(ids.prop("highlightLength", "Highlight Length", Value::Scalar(0.0)).with_ui(ParamUi::Percent))
.with(ids.prop("highlightAngle", "Highlight Angle", Value::Scalar(0.0)).with_ui(ParamUi::Angle))
.with(ids.prop("colors", "Colors", Value::Gradient(g)).with_ui(ParamUi::Gradient))
.with(ids.prop("opacity", "Opacity", Value::Scalar(100.0)).with_ui(slider(0.0, 100.0, 0.0, 100.0, 0)))
}
pub fn shape_trim(ids: &mut Ids, start: f64, end: f64, offset: f64) -> PropGroup {
indexed(ids.group("trim", "Trim Paths 1"))
.with(ids.prop("start", "Start", Value::Scalar(start)).with_ui(slider(0.0, 100.0, 0.0, 100.0, 1)))
.with(ids.prop("end", "End", Value::Scalar(end)).with_ui(slider(0.0, 100.0, 0.0, 100.0, 1)))
.with(ids.prop("offset", "Offset", Value::Scalar(offset)).with_ui(ParamUi::Angle))
.with(ids.prop("mode", "Trim Multiple Shapes", Value::Enum(0)).with_ui(popup(&["Simultaneously", "Individually"])))
}
pub fn shape_repeater(ids: &mut Ids, copies: f64, offset_pos: [f64; 2]) -> PropGroup {
let tr = ids
.group("transform", "Transform")
.with(ids.prop("anchor", "Anchor Point", Value::Vec2([0.0, 0.0])).with_ui(ParamUi::Point))
.with(ids.prop("position", "Position", Value::Vec2(offset_pos)).with_ui(ParamUi::Point))
.with(ids.prop("scale", "Scale", Value::Vec2([100.0, 100.0])).with_ui(ParamUi::Percent))
.with(ids.prop("rotation", "Rotation", Value::Scalar(0.0)).with_ui(ParamUi::Angle))
.with(ids.prop("startOpacity", "Start Opacity", Value::Scalar(100.0)).with_ui(ParamUi::Percent))
.with(ids.prop("endOpacity", "End Opacity", Value::Scalar(100.0)).with_ui(ParamUi::Percent));
indexed(ids.group("repeater", "Repeater 1"))
.with(ids.prop("copies", "Copies", Value::Scalar(copies)))
.with(ids.prop("offset", "Offset", Value::Scalar(0.0)))
.with(ids.prop("composite", "Composite", Value::Enum(0)).with_ui(popup(&["Below", "Above"])))
.with(tr)
}
pub fn shape_simple_op(ids: &mut Ids, kind: &str) -> Option<PropGroup> {
let g = match kind {
"round" => indexed(ids.group("round", "Round Corners 1")).with(ids.prop("radius", "Radius", Value::Scalar(10.0)).with_ui(ParamUi::Pixels)),
"offset" => indexed(ids.group("offset", "Offset Paths 1"))
.with(ids.prop("amount", "Amount", Value::Scalar(10.0)).with_ui(ParamUi::Pixels))
.with(ids.prop("join", "Line Join", Value::Enum(0)).with_ui(popup(&["Miter Join", "Round Join", "Bevel Join"])))
.with(ids.prop("miter", "Miter Limit", Value::Scalar(4.0)))
.with(ids.prop("copies", "Copies", Value::Scalar(1.0)))
.with(ids.prop("copyOffset", "Copy Offset", Value::Scalar(0.0))),
"pucker" => indexed(ids.group("pucker", "Pucker & Bloat 1")).with(ids.prop("amount", "Amount", Value::Scalar(0.0)).with_ui(slider(-100.0, 100.0, -100.0, 100.0, 1))),
"twist" => indexed(ids.group("twist", "Twist 1"))
.with(ids.prop("angle", "Angle", Value::Scalar(0.0)).with_ui(ParamUi::Angle))
.with(ids.prop("center", "Center", Value::Vec2([0.0, 0.0])).with_ui(ParamUi::Point)),
"zigzag" => indexed(ids.group("zigzag", "Zig Zag 1"))
.with(ids.prop("size", "Size", Value::Scalar(10.0)).with_ui(ParamUi::Pixels))
.with(ids.prop("ridges", "Ridges per segment", Value::Scalar(5.0)))
.with(ids.prop("points", "Points", Value::Enum(0)).with_ui(popup(&["Corner", "Smooth"]))),
"wiggle" => indexed(ids.group("wiggle", "Wiggle Paths 1"))
.with(ids.prop("size", "Size", Value::Scalar(10.0)).with_ui(ParamUi::Pixels))
.with(ids.prop("detail", "Detail", Value::Scalar(10.0)))
.with(ids.prop("points", "Points", Value::Enum(1)).with_ui(popup(&["Corner", "Smooth"])))
.with(ids.prop("speed", "Wiggles/Second", Value::Scalar(2.0)))
.with(ids.prop("correlation", "Correlation", Value::Scalar(50.0)).with_ui(ParamUi::Percent))
.with(ids.prop("phase", "Temporal Phase", Value::Scalar(0.0)).with_ui(ParamUi::Angle))
.with(ids.prop("seed", "Random Seed", Value::Scalar(0.0))),
"merge" => indexed(ids.group("merge", "Merge Paths 1"))
.with(ids.prop("mode", "Mode", Value::Enum(0)).with_ui(popup(&["Merge", "Add", "Subtract", "Intersect", "Exclude Intersections"]))),
_ => return None,
};
Some(g)
}
// ---------------------------------------------------------------- camera / light / 3D
pub fn camera_options(ids: &mut Ids, zoom: f64) -> PropGroup {
ids.group("cameraOptions", "Camera Options")
.with(ids.prop("zoom", "Zoom", Value::Scalar(zoom)).with_ui(ParamUi::Pixels))
.with(ids.prop("dof", "Depth of Field", Value::Bool(false)).with_ui(ParamUi::Checkbox))
.with(ids.prop("focusDistance", "Focus Distance", Value::Scalar(zoom)).with_ui(ParamUi::Pixels))
.with(ids.prop("aperture", "Aperture", Value::Scalar(25.3)).with_ui(ParamUi::Pixels))
.with(ids.prop("blurLevel", "Blur Level", Value::Scalar(100.0)).with_ui(ParamUi::Percent))
}
pub fn light_options(ids: &mut Ids, kind: LightKind) -> PropGroup {
let mut g = ids
.group("lightOptions", "Light Options")
.with(ids.prop("intensity", "Intensity", Value::Scalar(100.0)).with_ui(ParamUi::Percent))
.with(ids.prop("color", "Color", Value::Color([1.0, 1.0, 1.0, 1.0])).with_ui(ParamUi::Color));
if kind == LightKind::Spot {
g = g
.with(ids.prop("coneAngle", "Cone Angle", Value::Scalar(90.0)).with_ui(ParamUi::Angle))
.with(ids.prop("coneFeather", "Cone Feather", Value::Scalar(50.0)).with_ui(ParamUi::Percent));
}
if kind != LightKind::Ambient {
g = g
.with(ids.prop("falloff", "Falloff", Value::Enum(0)).with_ui(popup(&["None", "Smooth", "Inverse Square Clamped"])))
.with(ids.prop("radius", "Radius", Value::Scalar(500.0)).with_ui(ParamUi::Pixels))
.with(ids.prop("falloffDistance", "Falloff Distance", Value::Scalar(500.0)).with_ui(ParamUi::Pixels))
.with(ids.prop("castsShadows", "Casts Shadows", Value::Bool(false)).with_ui(ParamUi::Checkbox))
.with(ids.prop("shadowDarkness", "Shadow Darkness", Value::Scalar(100.0)).with_ui(ParamUi::Percent))
.with(ids.prop("shadowDiffusion", "Shadow Diffusion", Value::Scalar(0.0)).with_ui(ParamUi::Pixels));
}
g
}
pub fn material_options(ids: &mut Ids) -> PropGroup {
ids.group("materialOptions", "Material Options")
.with(ids.prop("castsShadows", "Casts Shadows", Value::Enum(0)).with_ui(popup(&["Off", "On", "Only"])))
.with(ids.prop("lightTransmission", "Light Transmission", Value::Scalar(0.0)).with_ui(ParamUi::Percent))
.with(ids.prop("acceptsShadows", "Accepts Shadows", Value::Enum(1)).with_ui(popup(&["Off", "On", "Only"])))
.with(ids.prop("acceptsLights", "Accepts Lights", Value::Bool(true)).with_ui(ParamUi::Checkbox))
.with(ids.prop("ambient", "Ambient", Value::Scalar(100.0)).with_ui(ParamUi::Percent))
.with(ids.prop("diffuse", "Diffuse", Value::Scalar(50.0)).with_ui(ParamUi::Percent))
.with(ids.prop("specularIntensity", "Specular Intensity", Value::Scalar(50.0)).with_ui(ParamUi::Percent))
.with(ids.prop("specularShininess", "Specular Shininess", Value::Scalar(5.0)).with_ui(ParamUi::Percent))
.with(ids.prop("metal", "Metal", Value::Scalar(100.0)).with_ui(ParamUi::Percent))
}
pub fn audio(ids: &mut Ids) -> PropGroup {
ids.group("audio", "Audio").with(ids.prop("levels", "Audio Levels", Value::Vec2([0.0, 0.0])))
}
// ---------------------------------------------------------------- layers
/// Default label colour for a layer source (Labels preferences defaults).
pub fn default_label(src: &LayerSource, project: &Project) -> Label {
match src {
LayerSource::Comp { .. } => Label::Sandstone,
LayerSource::Footage { item } => match project.item(*item).map(|i| i.type_name()) {
Some("Image") | Some("Image Sequence") => Label::Lavender,
Some("Audio") => Label::SeaFoam,
_ => Label::Aqua,
},
LayerSource::Solid { .. } => Label::Red,
LayerSource::Text => Label::Red,
LayerSource::Shape => Label::Blue,
LayerSource::Null => Label::Red,
LayerSource::Camera | LayerSource::Light { .. } => Label::Pink,
}
}
/// A new layer spanning the whole comp, anchored at its source centre and centred in the comp.
pub fn layer(project: &mut Project, comp: &Comp, name: &str, source: LayerSource, size: (u32, u32), duration: Option<Tick>) -> Layer {
let label = default_label(&source, project);
let id = LayerId(project.alloc());
let mut ids = Ids(&mut project.next_id);
let (w, h) = (size.0 as f64, size.1 as f64);
let (cw, ch) = (comp.width as f64, comp.height as f64);
let anchor = match source {
LayerSource::Text | LayerSource::Shape | LayerSource::Camera | LayerSource::Light { .. } => [0.0, 0.0],
LayerSource::Null => [50.0, 50.0],
_ => [w / 2.0, h / 2.0],
};
let mut root = ids.group("layer", name);
match &source {
LayerSource::Text => {
root.children.push(text(&mut ids, TextDoc::default()).into());
}
LayerSource::Shape => {
root.children.push(contents(&mut ids).into());
}
_ => {}
}
if source.is_av() {
root.children.push(masks(&mut ids).into());
root.children.push(effects(&mut ids).into());
} else if matches!(source, LayerSource::Null) {
root.children.push(effects(&mut ids).into());
}
let mut tr = transform(&mut ids, anchor, [cw / 2.0, ch / 2.0]);
if let LayerSource::Camera = source {
// Two-node camera default: point of interest at the comp centre, camera at -zoom.
let zoom = effectcraft_geom::default_camera_zoom(cw);
tr = ids
.group("transform", "Transform")
.with(ids.prop("poi", "Point of Interest", Value::Vec3([cw / 2.0, ch / 2.0, 0.0])).with_ui(ParamUi::Point3).spatial())
.with(ids.prop("position", "Position", Value::Vec3([cw / 2.0, ch / 2.0, -zoom])).with_ui(ParamUi::Point3).spatial())
.with(ids.prop("orientation", "Orientation", Value::Vec3([0.0; 3])).with_ui(ParamUi::Angle))
.with(ids.prop("rotationX", "X Rotation", Value::Scalar(0.0)).with_ui(ParamUi::Angle))
.with(ids.prop("rotationY", "Y Rotation", Value::Scalar(0.0)).with_ui(ParamUi::Angle))
.with(ids.prop("rotation", "Z Rotation", Value::Scalar(0.0)).with_ui(ParamUi::Angle));
root.children.push(tr.into());
root.children.push(camera_options(&mut ids, zoom).into());
} else if let LayerSource::Light { kind } = source {
let mut g = ids.group("transform", "Transform");
if matches!(kind, LightKind::Spot | LightKind::Parallel) {
g.children.push(ids.prop("poi", "Point of Interest", Value::Vec3([cw / 2.0, ch / 2.0, 0.0])).with_ui(ParamUi::Point3).spatial().into());
}
if kind != LightKind::Ambient {
g.children.push(ids.prop("position", "Position", Value::Vec3([cw / 2.0 - 260.0, ch / 2.0 - 260.0, -440.0])).with_ui(ParamUi::Point3).spatial().into());
}
root.children.push(g.into());
root.children.push(light_options(&mut ids, kind).into());
} else {
if let LayerSource::Null = source {
// Nulls are 100×100.
}
root.children.push(std::mem::replace(&mut tr, PropGroup::new(0, "", "")).into());
if source.is_av() {
root.children.push(material_options(&mut ids).into());
}
}
let has_audio = matches!(&source, LayerSource::Footage { item } if matches!(project.item(*item).map(|i| &i.kind), Some(crate::ItemKind::Footage(f)) if f.has_audio))
|| matches!(source, LayerSource::Comp { .. });
if has_audio {
let mut ids = Ids(&mut project.next_id);
root.children.push(audio(&mut ids).into());
}
let out = duration.map(|d| d.min(comp.duration)).unwrap_or(comp.duration);
Layer {
id,
name: comp.unique_layer_name(name),
source,
label,
comment: String::new(),
start_time: Tick::ZERO,
in_point: Tick::ZERO,
out_point: out,
stretch: 100.0,
switches: Switches::default(),
blend_mode: BlendMode::Normal,
preserve_transparency: false,
track_matte: None,
parent: None,
markers: vec![],
auto_orient: Default::default(),
props: root,
}
}
+679
View File
@@ -0,0 +1,679 @@
//! The EffectCraft document model.
//!
//! `Project` → items (folders, compositions, footage, solids) → `Comp` → `Layer` → property tree
//! ([`props`]). Compositions sit behind `Arc` so undo snapshots share everything that did not
//! change.
pub mod build;
pub mod props;
use std::collections::BTreeMap;
use std::sync::Arc;
use effectcraft_color::{BlendMode, Label};
use effectcraft_time::{FrameRate, Tick};
pub use props::{Expression, GroupKind, MaskMode, Node, ParamUi, PropGroup, Property, Uid, parse_path};
use serde::{Deserialize, Serialize};
pub use effectcraft_keyframe as keyframe;
pub use effectcraft_keyframe::{Keyframe, Value};
pub const SCHEMA_VERSION: u32 = 1;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct ItemId(pub u64);
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct LayerId(pub u64);
#[derive(Debug, thiserror::Error)]
pub enum ProjectError {
#[error("no item {0:?}")]
NoItem(ItemId),
#[error("item {0:?} is not a composition")]
NotComp(ItemId),
#[error("no layer {0:?}")]
NoLayer(LayerId),
#[error("no property `{0}`")]
NoProperty(String),
#[error("{0}")]
Invalid(String),
}
/// Project bit depth.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum BitDepth {
#[default]
Bpc8,
Bpc16,
Bpc32,
}
impl BitDepth {
pub fn label(self) -> &'static str {
match self {
BitDepth::Bpc8 => "8 bpc",
BitDepth::Bpc16 => "16 bpc",
BitDepth::Bpc32 => "32 bpc",
}
}
pub fn next(self) -> BitDepth {
match self {
BitDepth::Bpc8 => BitDepth::Bpc16,
BitDepth::Bpc16 => BitDepth::Bpc32,
BitDepth::Bpc32 => BitDepth::Bpc8,
}
}
pub fn is_float(self) -> bool {
self == BitDepth::Bpc32
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum TimeDisplayStyle {
#[default]
Timecode,
Frames,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ProjectSettings {
pub bit_depth: BitDepth,
/// Blend in linear light instead of the sRGB-encoded working space.
pub linearize: bool,
pub time_display: TimeDisplayStyle,
/// Frame numbering starts at 0 (or 1).
pub frame_start: i64,
pub audio_sample_rate: u32,
}
impl Default for ProjectSettings {
fn default() -> Self {
ProjectSettings { bit_depth: BitDepth::Bpc8, linearize: false, time_display: TimeDisplayStyle::Timecode, frame_start: 0, audio_sample_rate: 48_000 }
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Marker {
pub time: Tick,
#[serde(default)]
pub duration: Tick,
#[serde(default)]
pub comment: String,
#[serde(default)]
pub label: Label,
#[serde(default)]
pub chapter: String,
#[serde(default)]
pub url: String,
#[serde(default)]
pub protected: bool,
}
/// 3D renderer of a composition.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum Renderer {
#[default]
Classic3D,
Advanced3D,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Comp {
pub width: u32,
pub height: u32,
pub pixel_aspect: f64,
pub frame_rate: FrameRate,
pub duration: Tick,
/// Start timecode/frame shown for time 0.
pub display_start: Tick,
pub background: [f32; 3],
pub work_area: (Tick, Tick),
/// Layer #1 first (top of the stack).
pub layers: Vec<Layer>,
#[serde(default)]
pub markers: Vec<Marker>,
pub shutter_angle: f64,
pub shutter_phase: f64,
pub motion_blur_samples: u32,
#[serde(default)]
pub renderer: Renderer,
/// Comp switches (timeline toolbar).
#[serde(default)]
pub hide_shy: bool,
#[serde(default = "yes")]
pub enable_motion_blur: bool,
#[serde(default = "yes")]
pub enable_frame_blending: bool,
#[serde(default)]
pub draft_3d: bool,
#[serde(default)]
pub poster_time: Tick,
}
fn yes() -> bool {
true
}
impl Comp {
pub fn new(width: u32, height: u32, frame_rate: FrameRate, duration: Tick) -> Comp {
Comp {
width,
height,
pixel_aspect: 1.0,
frame_rate,
duration,
display_start: Tick::ZERO,
background: [0.0, 0.0, 0.0],
work_area: (Tick::ZERO, duration),
layers: vec![],
markers: vec![],
shutter_angle: 180.0,
shutter_phase: -90.0,
motion_blur_samples: 16,
renderer: Renderer::Classic3D,
hide_shy: false,
enable_motion_blur: true,
enable_frame_blending: true,
draft_3d: false,
poster_time: Tick::ZERO,
}
}
pub fn layer(&self, id: LayerId) -> Option<&Layer> {
self.layers.iter().find(|l| l.id == id)
}
pub fn layer_mut(&mut self, id: LayerId) -> Option<&mut Layer> {
self.layers.iter_mut().find(|l| l.id == id)
}
/// 1-based layer number (#) of a layer.
pub fn index_of(&self, id: LayerId) -> Option<usize> {
self.layers.iter().position(|l| l.id == id).map(|i| i + 1)
}
pub fn layer_by_name(&self, name: &str) -> Option<&Layer> {
self.layers.iter().find(|l| l.name == name)
}
pub fn frame_duration(&self) -> Tick {
self.frame_rate.frame_duration()
}
/// Any 3D layer present (camera/lights or a 3D switch).
pub fn has_3d(&self) -> bool {
self.layers.iter().any(|l| l.switches.three_d || matches!(l.source, LayerSource::Camera | LayerSource::Light { .. }))
}
/// The active camera at `t`: the topmost enabled camera layer active at that time.
pub fn active_camera(&self, t: Tick) -> Option<&Layer> {
self.layers.iter().find(|l| matches!(l.source, LayerSource::Camera) && l.switches.video && l.is_active_at(t))
}
/// Unique layer name with a numeric suffix (`Shape Layer 2`).
pub fn unique_layer_name(&self, base: &str) -> String {
if !self.layers.iter().any(|l| l.name == base) {
return base.to_string();
}
let stem = base.trim_end_matches(|c: char| c.is_ascii_digit()).trim_end();
(2..).map(|i| format!("{stem} {i}")).find(|n| !self.layers.iter().any(|l| &l.name == n)).unwrap_or_default()
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum LightKind {
Parallel,
Spot,
#[default]
Point,
Ambient,
Environment,
}
impl LightKind {
pub const ALL: [LightKind; 5] = [LightKind::Parallel, LightKind::Spot, LightKind::Point, LightKind::Ambient, LightKind::Environment];
pub fn label(self) -> &'static str {
match self {
LightKind::Parallel => "Parallel",
LightKind::Spot => "Spot",
LightKind::Point => "Point",
LightKind::Ambient => "Ambient",
LightKind::Environment => "Environment",
}
}
}
/// What a layer shows.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum LayerSource {
/// Footage item (video, still, sequence, audio).
Footage { item: ItemId },
/// Nested composition (precomp).
Comp { item: ItemId },
/// Solid item (solids also back adjustment layers).
Solid { item: ItemId },
Text,
Shape,
Null,
Camera,
Light { kind: LightKind },
}
impl LayerSource {
pub fn item(&self) -> Option<ItemId> {
match self {
LayerSource::Footage { item } | LayerSource::Comp { item } | LayerSource::Solid { item } => Some(*item),
_ => None,
}
}
pub fn type_name(&self) -> &'static str {
match self {
LayerSource::Footage { .. } => "Footage",
LayerSource::Comp { .. } => "Composition",
LayerSource::Solid { .. } => "Solid",
LayerSource::Text => "Text",
LayerSource::Shape => "Shape",
LayerSource::Null => "Null",
LayerSource::Camera => "Camera",
LayerSource::Light { .. } => "Light",
}
}
/// Layers with pixels (cameras, lights and nulls have none).
pub fn is_av(&self) -> bool {
!matches!(self, LayerSource::Camera | LayerSource::Light { .. } | LayerSource::Null)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum Quality {
#[default]
Best,
Draft,
Wireframe,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum Sampling {
#[default]
Bilinear,
Bicubic,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum FrameBlend {
#[default]
Off,
FrameMix,
PixelMotion,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Switches {
pub video: bool,
pub audio: bool,
pub solo: bool,
pub locked: bool,
pub shy: bool,
/// Collapse transformations (precomps) / continuously rasterize (vector layers).
pub collapse: bool,
pub quality: Quality,
pub sampling: Sampling,
pub effects: bool,
pub frame_blend: FrameBlend,
pub motion_blur: bool,
pub adjustment: bool,
pub three_d: bool,
pub guide: bool,
}
impl Default for Switches {
fn default() -> Self {
Switches {
video: true,
audio: true,
solo: false,
locked: false,
shy: false,
collapse: false,
quality: Quality::Best,
sampling: Sampling::Bilinear,
effects: true,
frame_blend: FrameBlend::Off,
motion_blur: false,
adjustment: false,
three_d: false,
guide: false,
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum MatteKind {
#[default]
Alpha,
AlphaInverted,
Luma,
LumaInverted,
}
impl MatteKind {
pub const ALL: [MatteKind; 4] = [MatteKind::Alpha, MatteKind::AlphaInverted, MatteKind::Luma, MatteKind::LumaInverted];
pub fn label(self) -> &'static str {
match self {
MatteKind::Alpha => "Alpha Matte",
MatteKind::AlphaInverted => "Alpha Inverted Matte",
MatteKind::Luma => "Luma Matte",
MatteKind::LumaInverted => "Luma Inverted Matte",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TrackMatte {
pub layer: LayerId,
pub kind: MatteKind,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum AutoOrient {
#[default]
Off,
AlongPath,
TowardsCamera,
TowardsPointOfInterest,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Layer {
pub id: LayerId,
pub name: String,
pub source: LayerSource,
pub label: Label,
#[serde(default)]
pub comment: String,
/// Comp time where the layer's time 0 sits.
pub start_time: Tick,
/// Visible span in comp time.
pub in_point: Tick,
pub out_point: Tick,
/// Time stretch in percent (100 = normal, negative = reversed).
pub stretch: f64,
pub switches: Switches,
pub blend_mode: BlendMode,
#[serde(default)]
pub preserve_transparency: bool,
#[serde(default)]
pub track_matte: Option<TrackMatte>,
#[serde(default)]
pub parent: Option<LayerId>,
#[serde(default)]
pub markers: Vec<Marker>,
#[serde(default)]
pub auto_orient: AutoOrient,
/// The property tree (Masks, Effects, Transform, Text, Contents, Camera/Light options…).
pub props: PropGroup,
}
impl Layer {
pub fn is_active_at(&self, t: Tick) -> bool {
t >= self.in_point && t < self.out_point
}
/// Layer time for a comp time (stretch-aware).
pub fn layer_time(&self, comp_t: Tick) -> Tick {
let d = comp_t - self.start_time;
if (self.stretch - 100.0).abs() < 1e-9 {
d
} else {
Tick((d.0 as f64 * 100.0 / self.stretch) as i64)
}
}
/// Comp time for a layer time.
pub fn comp_time(&self, layer_t: Tick) -> Tick {
if (self.stretch - 100.0).abs() < 1e-9 {
self.start_time + layer_t
} else {
self.start_time + Tick((layer_t.0 as f64 * self.stretch / 100.0) as i64)
}
}
pub fn transform(&self) -> Option<&PropGroup> {
self.props.sub("transform")
}
pub fn transform_mut(&mut self) -> Option<&mut PropGroup> {
self.props.sub_mut("transform")
}
pub fn effects(&self) -> Option<&PropGroup> {
self.props.sub("effects")
}
pub fn masks(&self) -> Option<&PropGroup> {
self.props.sub("masks")
}
pub fn is_camera(&self) -> bool {
matches!(self.source, LayerSource::Camera)
}
pub fn is_light(&self) -> bool {
matches!(self.source, LayerSource::Light { .. })
}
/// The layer participates in 3D (3D switch, or cameras/lights which are always 3D).
pub fn is_3d(&self) -> bool {
self.switches.three_d || self.is_camera() || self.is_light()
}
pub fn has_video(&self) -> bool {
self.source.is_av() && self.switches.video
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum FootageKind {
#[default]
Video,
Still,
Sequence,
Audio,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum AlphaMode {
#[default]
Straight,
Premultiplied,
Ignore,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Footage {
pub path: String,
pub kind: FootageKind,
pub width: u32,
pub height: u32,
pub pixel_aspect: f64,
pub frame_rate: FrameRate,
/// Native rate before "Conform to frame rate".
#[serde(default)]
pub native_rate: Option<FrameRate>,
pub duration: Tick,
pub has_video: bool,
pub has_audio: bool,
pub alpha: AlphaMode,
#[serde(default)]
pub premul_color: [f32; 3],
#[serde(default = "one")]
pub loop_count: u32,
#[serde(default)]
pub codec: String,
#[serde(default)]
pub missing: bool,
/// Image sequence files (when kind = Sequence).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub sequence: Vec<String>,
}
fn one() -> u32 {
1
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Solid {
pub color: [f32; 3],
pub width: u32,
pub height: u32,
pub pixel_aspect: f64,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum ItemKind {
Folder,
Comp(Arc<Comp>),
Footage(Footage),
Solid(Solid),
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Item {
pub id: ItemId,
pub name: String,
pub label: Label,
#[serde(default)]
pub comment: String,
/// Containing folder (None = project root).
#[serde(default)]
pub parent: Option<ItemId>,
pub kind: ItemKind,
}
impl Item {
pub fn type_name(&self) -> &'static str {
match &self.kind {
ItemKind::Folder => "Folder",
ItemKind::Comp(_) => "Composition",
ItemKind::Footage(f) => match f.kind {
FootageKind::Video => "Video",
FootageKind::Still => "Image",
FootageKind::Sequence => "Image Sequence",
FootageKind::Audio => "Audio",
},
ItemKind::Solid(_) => "Solid",
}
}
pub fn as_comp(&self) -> Option<&Comp> {
if let ItemKind::Comp(c) = &self.kind { Some(c) } else { None }
}
pub fn is_folder(&self) -> bool {
matches!(self.kind, ItemKind::Folder)
}
/// (width, height) for visual items.
pub fn dimensions(&self) -> Option<(u32, u32)> {
match &self.kind {
ItemKind::Comp(c) => Some((c.width, c.height)),
ItemKind::Footage(f) if f.has_video => Some((f.width, f.height)),
ItemKind::Solid(s) => Some((s.width, s.height)),
_ => None,
}
}
pub fn duration(&self) -> Option<Tick> {
match &self.kind {
ItemKind::Comp(c) => Some(c.duration),
ItemKind::Footage(f) if f.kind != FootageKind::Still => Some(f.duration),
_ => None,
}
}
pub fn frame_rate(&self) -> Option<FrameRate> {
match &self.kind {
ItemKind::Comp(c) => Some(c.frame_rate),
ItemKind::Footage(f) if f.kind == FootageKind::Video || f.kind == FootageKind::Sequence => Some(f.frame_rate),
_ => None,
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Project {
pub schema: u32,
pub settings: ProjectSettings,
pub items: BTreeMap<ItemId, Item>,
pub next_id: u64,
}
impl Default for Project {
fn default() -> Self {
Project { schema: SCHEMA_VERSION, settings: ProjectSettings::default(), items: BTreeMap::new(), next_id: 1 }
}
}
impl Project {
/// Allocate an id (items, layers and property uids share one counter).
pub fn alloc(&mut self) -> u64 {
let id = self.next_id;
self.next_id += 1;
id
}
pub fn item(&self, id: ItemId) -> Option<&Item> {
self.items.get(&id)
}
pub fn item_mut(&mut self, id: ItemId) -> Option<&mut Item> {
self.items.get_mut(&id)
}
pub fn comp(&self, id: ItemId) -> Option<&Comp> {
self.items.get(&id)?.as_comp()
}
/// Mutable comp (copy-on-write).
pub fn comp_mut(&mut self, id: ItemId) -> Option<&mut Comp> {
match &mut self.items.get_mut(&id)?.kind {
ItemKind::Comp(c) => Some(Arc::make_mut(c)),
_ => None,
}
}
pub fn add_item(&mut self, name: &str, label: Label, parent: Option<ItemId>, kind: ItemKind) -> ItemId {
let id = ItemId(self.alloc());
self.items.insert(id, Item { id, name: name.into(), label, comment: String::new(), parent, kind });
id
}
/// Children of a folder (None = root), folders first then by name.
pub fn children(&self, folder: Option<ItemId>) -> Vec<&Item> {
let mut v: Vec<&Item> = self.items.values().filter(|i| i.parent == folder).collect();
v.sort_by(|a, b| b.is_folder().cmp(&a.is_folder()).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())));
v
}
pub fn comps(&self) -> impl Iterator<Item = (&ItemId, &Comp)> {
self.items.iter().filter_map(|(id, i)| i.as_comp().map(|c| (id, c)))
}
pub fn find_by_name(&self, name: &str) -> Option<&Item> {
self.items.values().find(|i| i.name == name)
}
pub fn folder_named(&self, name: &str) -> Option<ItemId> {
self.items.values().find(|i| i.is_folder() && i.name == name).map(|i| i.id)
}
/// Find the comp that contains `layer`.
pub fn comp_of_layer(&self, layer: LayerId) -> Option<ItemId> {
self.comps().find(|(_, c)| c.layer(layer).is_some()).map(|(id, _)| *id)
}
/// Whether comp `inner` is (transitively) nested inside comp `outer` (to prevent cycles).
pub fn comp_contains(&self, outer: ItemId, inner: ItemId) -> bool {
if outer == inner {
return true;
}
let Some(c) = self.comp(outer) else { return false };
c.layers.iter().any(|l| matches!(l.source, LayerSource::Comp { item } if self.comp_contains(item, inner)))
}
/// Make sure `next_id` exceeds every id in the project (after loading or merging).
pub fn fix_next_id(&mut self) {
let mut m = self.next_id;
for (id, it) in &self.items {
m = m.max(id.0 + 1);
if let ItemKind::Comp(c) = &it.kind {
for l in &c.layers {
m = m.max(l.id.0 + 1).max(l.props.max_uid() + 1);
}
}
}
self.next_id = m;
}
pub fn to_json(&self) -> String {
serde_json::to_string_pretty(self).unwrap_or_default()
}
pub fn from_json(s: &str) -> Result<Project, ProjectError> {
let mut p: Project = serde_json::from_str(s).map_err(|e| ProjectError::Invalid(e.to_string()))?;
if p.schema > SCHEMA_VERSION {
return Err(ProjectError::Invalid(format!("project schema {} is newer than this version supports ({SCHEMA_VERSION})", p.schema)));
}
p.fix_next_id();
Ok(p)
}
}
#[cfg(test)]
mod tests;
+511
View File
@@ -0,0 +1,511 @@
//! The property tree: groups and properties with stable uids and match ids, addressed by paths.
use effectcraft_keyframe::{Keyframe, Value};
use effectcraft_time::Tick;
use serde::{Deserialize, Serialize};
pub type Uid = u64;
/// How a property is presented and edited.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind")]
pub enum ParamUi {
/// Plain number with an optional valid range and a (narrower) slider range.
#[default]
Number,
Slider {
min: f64,
max: f64,
slider_min: f64,
slider_max: f64,
decimals: u8,
},
Percent,
/// Angle in degrees, shown as `revolutions x +degrees`.
Angle,
Pixels,
Point,
Point3,
Color,
Checkbox,
Popup {
options: Vec<String>,
},
Layer,
Path,
Text,
Gradient,
/// Not shown in Effect Controls / the timeline (internal data).
Hidden,
}
/// Expression attached to a property.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Expression {
pub text: String,
pub enabled: bool,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Property {
pub uid: Uid,
/// Stable id within its parent kind (`position`, `blurriness`…).
#[serde(rename = "match")]
pub match_id: String,
pub name: String,
/// Static value (used when there are no keyframes).
pub value: Value,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub keys: Vec<Keyframe>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expr: Option<Expression>,
#[serde(default)]
pub ui: ParamUi,
/// Motion-path semantics (Position, Anchor Point, points).
#[serde(default)]
pub spatial: bool,
/// Only hold keyframes (Source Text, checkboxes, popups).
#[serde(default)]
pub hold_only: bool,
/// Valid only on 3D layers (Orientation, X/Y Rotation…).
#[serde(default)]
pub three_d_only: bool,
/// Valid only on 2D layers.
#[serde(default)]
pub two_d_only: bool,
/// Dimensions shown/edited (2 for 2D position etc.); 0 = all.
#[serde(default)]
pub shown_dims: u8,
/// Can't be keyframed (header-like values, e.g. Mask Mode). Shown without a stopwatch.
#[serde(default)]
pub static_only: bool,
}
impl Property {
pub fn new(uid: Uid, match_id: &str, name: &str, value: Value) -> Property {
let hold_only = !value.interpolates();
Property {
uid,
match_id: match_id.into(),
name: name.into(),
value,
keys: vec![],
expr: None,
ui: ParamUi::Number,
spatial: false,
hold_only,
three_d_only: false,
two_d_only: false,
shown_dims: 0,
static_only: false,
}
}
pub fn with_ui(mut self, ui: ParamUi) -> Property {
self.ui = ui;
self
}
pub fn spatial(mut self) -> Property {
self.spatial = true;
self
}
pub fn is_animated(&self) -> bool {
!self.keys.is_empty()
}
pub fn has_expression(&self) -> bool {
self.expr.as_ref().is_some_and(|e| e.enabled && !e.text.trim().is_empty())
}
/// Keyframed value at `t` (no expression).
pub fn value_at(&self, t: Tick) -> Value {
if self.keys.is_empty() {
return self.value.clone();
}
effectcraft_keyframe::evaluate(&self.keys, t, self.spatial).unwrap_or_else(|| self.value.clone())
}
/// Set the value at `t`: adds/replaces a key when animated, else sets the static value.
pub fn set_value_at(&mut self, t: Tick, v: Value) {
if self.keys.is_empty() {
self.value = v;
} else {
let mut k = Keyframe::new(t, v);
if self.hold_only {
k = k.hold();
}
effectcraft_keyframe::set_key(&mut self.keys, k);
}
}
/// Toggle the stopwatch: on → one key at `t` with the current value; off → keep value at `t`.
pub fn set_animated(&mut self, on: bool, t: Tick) {
if on && self.keys.is_empty() {
let mut k = Keyframe::new(t, self.value.clone());
if self.hold_only {
k = k.hold();
}
self.keys.push(k);
} else if !on && !self.keys.is_empty() {
self.value = self.value_at(t);
self.keys.clear();
}
}
}
/// What a group represents, with its non-animatable header data.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind")]
pub enum GroupKind {
#[default]
Plain,
/// An instance in an indexed list (masks, effects, shape contents, animators): these can be
/// renamed, reordered, duplicated and deleted.
Indexed,
Mask {
mode: MaskMode,
inverted: bool,
color: [u8; 3],
locked: bool,
},
Effect {
/// Effect spec id, e.g. `ec.blur.gaussian`.
effect: String,
},
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum MaskMode {
None,
#[default]
Add,
Subtract,
Intersect,
Lighten,
Darken,
Difference,
}
impl MaskMode {
pub const ALL: [MaskMode; 7] = [MaskMode::None, MaskMode::Add, MaskMode::Subtract, MaskMode::Intersect, MaskMode::Lighten, MaskMode::Darken, MaskMode::Difference];
pub fn label(self) -> &'static str {
match self {
MaskMode::None => "None",
MaskMode::Add => "Add",
MaskMode::Subtract => "Subtract",
MaskMode::Intersect => "Intersect",
MaskMode::Lighten => "Lighten",
MaskMode::Darken => "Darken",
MaskMode::Difference => "Difference",
}
}
pub fn from_name(s: &str) -> Option<MaskMode> {
MaskMode::ALL.into_iter().find(|m| m.label().eq_ignore_ascii_case(s))
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct PropGroup {
pub uid: Uid,
#[serde(rename = "match")]
pub match_id: String,
pub name: String,
#[serde(default)]
pub kind: GroupKind,
/// fx switch for effects, eye for shape groups, etc.
#[serde(default = "yes")]
pub enabled: bool,
#[serde(default)]
pub children: Vec<Node>,
}
fn yes() -> bool {
true
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "node")]
pub enum Node {
Prop(Property),
Group(PropGroup),
}
impl Node {
pub fn uid(&self) -> Uid {
match self {
Node::Prop(p) => p.uid,
Node::Group(g) => g.uid,
}
}
pub fn match_id(&self) -> &str {
match self {
Node::Prop(p) => &p.match_id,
Node::Group(g) => &g.match_id,
}
}
pub fn name(&self) -> &str {
match self {
Node::Prop(p) => &p.name,
Node::Group(g) => &g.name,
}
}
pub fn as_prop(&self) -> Option<&Property> {
if let Node::Prop(p) = self { Some(p) } else { None }
}
pub fn as_group(&self) -> Option<&PropGroup> {
if let Node::Group(g) = self { Some(g) } else { None }
}
pub fn as_group_mut(&mut self) -> Option<&mut PropGroup> {
if let Node::Group(g) = self { Some(g) } else { None }
}
}
/// One step of a property path.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Seg {
Uid(Uid),
/// Match id and 1-based occurrence among siblings with that match id.
Match(String, usize),
/// 1-based child index.
Index(usize),
}
/// Parse `transform/position`, `effects/#1/blurriness`, `effects/@57/blurriness`,
/// `contents/rect#2/size`.
pub fn parse_path(s: &str) -> Vec<Seg> {
s.split(['/', '.'])
.filter(|x| !x.is_empty())
.map(|seg| {
if let Some(u) = seg.strip_prefix('@').and_then(|u| u.parse().ok()) {
Seg::Uid(u)
} else if let Some(i) = seg.strip_prefix('#').and_then(|u| u.parse().ok()) {
Seg::Index(i)
} else if let Some((m, n)) = seg.rsplit_once('#')
&& let Ok(n) = n.parse()
{
Seg::Match(m.to_string(), n)
} else {
Seg::Match(seg.to_string(), 1)
}
})
.collect()
}
impl PropGroup {
pub fn new(uid: Uid, match_id: &str, name: &str) -> PropGroup {
PropGroup { uid, match_id: match_id.into(), name: name.into(), kind: GroupKind::Plain, enabled: true, children: vec![] }
}
pub fn with(mut self, n: impl Into<Node>) -> PropGroup {
self.children.push(n.into());
self
}
fn child_index(&self, seg: &Seg) -> Option<usize> {
match seg {
Seg::Uid(u) => self.children.iter().position(|c| c.uid() == *u),
Seg::Index(i) => (*i >= 1 && *i <= self.children.len()).then(|| i - 1),
Seg::Match(m, n) => self
.children
.iter()
.enumerate()
.filter(|(_, c)| c.match_id() == m || c.name().eq_ignore_ascii_case(m))
.nth(n.saturating_sub(1))
.map(|(i, _)| i),
}
}
pub fn node(&self, path: &str) -> Option<&Node> {
let segs = parse_path(path);
let (last, init) = segs.split_last()?;
let mut g = self;
for s in init {
g = g.children.get(g.child_index(s)?)?.as_group()?;
}
g.children.get(g.child_index(last)?)
}
pub fn node_mut(&mut self, path: &str) -> Option<&mut Node> {
let segs = parse_path(path);
let (last, init) = segs.split_last()?;
let mut g = self;
for s in init {
let i = g.child_index(s)?;
g = g.children.get_mut(i)?.as_group_mut()?;
}
let i = g.child_index(last)?;
g.children.get_mut(i)
}
pub fn prop(&self, path: &str) -> Option<&Property> {
self.node(path)?.as_prop()
}
pub fn prop_mut(&mut self, path: &str) -> Option<&mut Property> {
match self.node_mut(path)? {
Node::Prop(p) => Some(p),
_ => None,
}
}
pub fn group(&self, path: &str) -> Option<&PropGroup> {
self.node(path)?.as_group()
}
pub fn group_mut(&mut self, path: &str) -> Option<&mut PropGroup> {
self.node_mut(path)?.as_group_mut()
}
/// Direct child property by match id.
pub fn get(&self, match_id: &str) -> Option<&Property> {
self.children.iter().find_map(|c| c.as_prop().filter(|p| p.match_id == match_id))
}
pub fn get_mut(&mut self, match_id: &str) -> Option<&mut Property> {
self.children.iter_mut().find_map(|c| match c {
Node::Prop(p) if p.match_id == match_id => Some(p),
_ => None,
})
}
pub fn sub(&self, match_id: &str) -> Option<&PropGroup> {
self.children.iter().find_map(|c| c.as_group().filter(|g| g.match_id == match_id))
}
pub fn sub_mut(&mut self, match_id: &str) -> Option<&mut PropGroup> {
self.children.iter_mut().find_map(|c| match c {
Node::Group(g) if g.match_id == match_id => Some(g),
_ => None,
})
}
pub fn groups(&self) -> impl Iterator<Item = &PropGroup> {
self.children.iter().filter_map(Node::as_group)
}
pub fn props(&self) -> impl Iterator<Item = &Property> {
self.children.iter().filter_map(Node::as_prop)
}
/// Visit every property depth-first with its uid-path (`@g/@g/@p`).
pub fn walk<'a>(&'a self, prefix: &str, f: &mut dyn FnMut(&str, &'a Property)) {
for c in &self.children {
let p = format!("{prefix}@{}", c.uid());
match c {
Node::Prop(pr) => f(&p, pr),
Node::Group(g) => g.walk(&format!("{p}/"), f),
}
}
}
pub fn walk_mut(&mut self, f: &mut dyn FnMut(&mut Property)) {
for c in &mut self.children {
match c {
Node::Prop(p) => f(p),
Node::Group(g) => g.walk_mut(f),
}
}
}
/// Uid path (`@1/@2/@3`) of the node with `uid`.
pub fn path_of(&self, uid: Uid) -> Option<String> {
for c in &self.children {
if c.uid() == uid {
return Some(format!("@{uid}"));
}
if let Node::Group(g) = c
&& let Some(p) = g.path_of(uid)
{
return Some(format!("@{}/{p}", g.uid));
}
}
None
}
/// Human path (`Transform/Position`) of the node with `uid`.
pub fn name_path_of(&self, uid: Uid) -> Option<String> {
for c in &self.children {
if c.uid() == uid {
return Some(c.name().to_string());
}
if let Node::Group(g) = c
&& let Some(p) = g.name_path_of(uid)
{
return Some(format!("{}/{p}", g.name));
}
}
None
}
/// Find a property anywhere in the tree by uid.
pub fn find(&self, uid: Uid) -> Option<&Property> {
for c in &self.children {
match c {
Node::Prop(p) if p.uid == uid => return Some(p),
Node::Group(g) => {
if let Some(p) = g.find(uid) {
return Some(p);
}
}
_ => {}
}
}
None
}
pub fn find_mut(&mut self, uid: Uid) -> Option<&mut Property> {
for c in &mut self.children {
match c {
Node::Prop(p) if p.uid == uid => return Some(p),
Node::Group(g) => {
if let Some(p) = g.find_mut(uid) {
return Some(p);
}
}
_ => {}
}
}
None
}
pub fn find_group(&self, uid: Uid) -> Option<&PropGroup> {
if self.uid == uid {
return Some(self);
}
self.groups().find_map(|g| g.find_group(uid))
}
pub fn find_group_mut(&mut self, uid: Uid) -> Option<&mut PropGroup> {
if self.uid == uid {
return Some(self);
}
self.children.iter_mut().find_map(|c| match c {
Node::Group(g) => g.find_group_mut(uid),
_ => None,
})
}
/// Parent group of the node with `uid`.
pub fn parent_of_mut(&mut self, uid: Uid) -> Option<&mut PropGroup> {
if self.children.iter().any(|c| c.uid() == uid) {
return Some(self);
}
self.children.iter_mut().find_map(|c| match c {
Node::Group(g) => g.parent_of_mut(uid),
_ => None,
})
}
/// Any property in this subtree animated (keys or expression).
pub fn any_animated(&self) -> bool {
self.children.iter().any(|c| match c {
Node::Prop(p) => p.is_animated() || p.has_expression(),
Node::Group(g) => g.any_animated(),
})
}
/// Re-assign fresh uids (after duplicating a subtree).
pub fn reassign_uids(&mut self, next: &mut Uid) {
*next += 1;
self.uid = *next;
for c in &mut self.children {
match c {
Node::Prop(p) => {
*next += 1;
p.uid = *next;
}
Node::Group(g) => g.reassign_uids(next),
}
}
}
pub fn max_uid(&self) -> Uid {
self.children.iter().map(|c| match c {
Node::Prop(p) => p.uid,
Node::Group(g) => g.max_uid(),
}).fold(self.uid, Uid::max)
}
}
impl From<Property> for Node {
fn from(p: Property) -> Node {
Node::Prop(p)
}
}
impl From<PropGroup> for Node {
fn from(g: PropGroup) -> Node {
Node::Group(g)
}
}
+81
View File
@@ -0,0 +1,81 @@
use effectcraft_keyframe::{Keyframe, ShapePath, Value};
use effectcraft_time::{FrameRate, Tick};
use crate::build::{self, Ids};
use crate::{Comp, ItemKind, LayerSource, MaskMode, Project, Solid};
fn project_with_layer() -> (Project, crate::ItemId) {
let mut p = Project::default();
let comp = Comp::new(1920, 1080, FrameRate::FPS_29_97, Tick::from_seconds_f64(10.0));
let cid = p.add_item("Main", effectcraft_color::Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
let sid = p.add_item("Red Solid", effectcraft_color::Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 0.0, 0.0], width: 1920, height: 1080, pixel_aspect: 1.0 }));
let layer = build::layer(&mut p, &comp, "Red Solid 1", LayerSource::Solid { item: sid }, (1920, 1080), None);
p.comp_mut(cid).unwrap().layers.push(layer);
(p, cid)
}
#[test]
fn paths_resolve() {
let (p, cid) = project_with_layer();
let l = &p.comp(cid).unwrap().layers[0];
assert_eq!(l.props.prop("transform/position").unwrap().value, Value::Vec3([960.0, 540.0, 0.0]));
assert_eq!(l.props.prop("Transform/Opacity").unwrap().value, Value::Scalar(100.0));
assert_eq!(l.props.prop("transform/#7").unwrap().match_id, "rotation");
let uid = l.props.prop("transform/scale").unwrap().uid;
let path = l.props.path_of(uid).unwrap();
assert_eq!(l.props.prop(&path).unwrap().match_id, "scale");
assert_eq!(l.props.name_path_of(uid).unwrap(), "Transform/Scale");
}
#[test]
fn masks_and_occurrence_paths() {
let (mut p, cid) = project_with_layer();
let mut next = p.next_id;
let m1 = build::mask(&mut Ids(&mut next), "Mask 1", ShapePath::rect([0.0, 0.0], 10.0, 10.0), MaskMode::Add, [255, 0, 0]);
let m2 = build::mask(&mut Ids(&mut next), "Mask 2", ShapePath::ellipse([0.0, 0.0], 10.0, 10.0), MaskMode::Subtract, [0, 255, 0]);
p.next_id = next;
let l = &mut p.comp_mut(cid).unwrap().layers[0];
let masks = l.props.sub_mut("masks").unwrap();
masks.children.push(m1.into());
masks.children.push(m2.into());
assert_eq!(l.props.group("masks/mask#2").unwrap().name, "Mask 2");
assert_eq!(l.props.group("masks/Mask 2").unwrap().name, "Mask 2");
assert!(l.props.prop("masks/mask#2/feather").is_some());
}
#[test]
fn animate_and_roundtrip_json() {
let (mut p, cid) = project_with_layer();
{
let l = &mut p.comp_mut(cid).unwrap().layers[0];
let op = l.props.prop_mut("transform/opacity").unwrap();
op.set_animated(true, Tick::ZERO);
op.set_value_at(Tick::from_seconds_f64(1.0), Value::Scalar(0.0));
assert_eq!(op.keys.len(), 2);
assert_eq!(op.value_at(Tick::from_seconds_f64(0.5)), Value::Scalar(50.0));
}
let json = p.to_json();
let q = Project::from_json(&json).unwrap();
assert_eq!(p, q);
let _ = Keyframe::new(Tick::ZERO, Value::Scalar(0.0));
}
#[test]
fn unique_names_and_cycles() {
let (p, cid) = project_with_layer();
let c = p.comp(cid).unwrap();
assert_eq!(c.unique_layer_name("Red Solid 1"), "Red Solid 2");
assert_eq!(c.unique_layer_name("Shape Layer 1"), "Shape Layer 1");
assert!(p.comp_contains(cid, cid));
}
#[test]
fn camera_and_light_layers_build() {
let (mut p, cid) = project_with_layer();
let comp = p.comp(cid).unwrap().clone();
let cam = build::layer(&mut p, &comp, "Camera 1", LayerSource::Camera, (0, 0), None);
assert!(cam.props.prop("cameraOptions/zoom").is_some());
assert!(cam.props.prop("transform/poi").is_some());
let light = build::layer(&mut p, &comp, "Light 1", LayerSource::Light { kind: crate::LightKind::Spot }, (0, 0), None);
assert!(light.props.prop("lightOptions/coneAngle").is_some());
}
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "effectcraft-raster"
description = "f32 premultiplied RGBA images: sampling, projective warps, blurs, convolution, compositing (rayon-parallel)"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[lints]
workspace = true
[dependencies]
effectcraft-geom = { workspace = true }
effectcraft-color = { workspace = true }
rayon = { workspace = true }
+191
View File
@@ -0,0 +1,191 @@
//! Blurs: separable box passes (exact running sums) approximating Gaussians, plus directional
//! and radial (spin/zoom) blurs.
use rayon::prelude::*;
use crate::{Image, Px};
/// One horizontal box pass of radius `r` (window 2r+1) over each row; `repeat` = edge pixels
/// extend, otherwise transparent.
fn box_h(img: &Image, r: usize, repeat: bool) -> Image {
if r == 0 {
return img.clone();
}
let w = img.width as usize;
let mut out = Image::new(img.width, img.height);
let norm = 1.0 / (2 * r + 1) as f32;
out.data.par_chunks_mut(w).zip(img.data.par_chunks(w)).for_each(|(o, row)| {
let get = |i: isize| -> Px {
if i < 0 {
if repeat { row[0] } else { [0.0; 4] }
} else if i as usize >= w {
if repeat { row[w - 1] } else { [0.0; 4] }
} else {
row[i as usize]
}
};
let mut acc = [0.0f32; 4];
for i in -(r as isize)..=(r as isize) {
let p = get(i);
for c in 0..4 {
acc[c] += p[c];
}
}
for x in 0..w {
o[x] = acc.map(|v| v * norm);
let add = get(x as isize + r as isize + 1);
let sub = get(x as isize - r as isize);
for c in 0..4 {
acc[c] += add[c] - sub[c];
}
}
});
out
}
fn transpose(img: &Image) -> Image {
let (w, h) = (img.width as usize, img.height as usize);
let mut out = Image::new(img.height, img.width);
out.data.par_chunks_mut(h.max(1)).enumerate().for_each(|(x, col)| {
for y in 0..h {
col[y] = img.data[y * w + x];
}
});
out
}
/// Box radii for `n` passes approximating a Gaussian of `sigma` (Kovesi / "boxes for Gauss").
fn box_radii(sigma: f64, n: usize) -> Vec<usize> {
if sigma <= 0.0 {
return vec![0; n];
}
let w_ideal = (12.0 * sigma * sigma / n as f64 + 1.0).sqrt();
let mut wl = w_ideal.floor() as i64;
if wl % 2 == 0 {
wl -= 1;
}
let wu = wl + 2;
let m_ideal = (12.0 * sigma * sigma - (n as i64 * wl * wl) as f64 - 4.0 * n as f64 * wl as f64 - 3.0 * n as f64) / (-4.0 * wl as f64 - 4.0);
let m = m_ideal.round() as i64;
(0..n as i64).map(|i| (((if i < m { wl } else { wu }) - 1) / 2).max(0) as usize).collect()
}
/// Separable box blur with given radii, `iterations` passes.
pub fn box_blur(img: &Image, rx: usize, ry: usize, iterations: usize, repeat: bool) -> Image {
let mut cur = img.clone();
for _ in 0..iterations.max(1) {
cur = box_h(&cur, rx, repeat);
}
if ry > 0 {
let mut t = transpose(&cur);
for _ in 0..iterations.max(1) {
t = box_h(&t, ry, repeat);
}
cur = transpose(&t);
}
cur
}
/// Gaussian blur with standard deviations in pixels (3 box passes per axis).
pub fn gaussian_blur(img: &Image, sigma_x: f64, sigma_y: f64, repeat: bool) -> Image {
let mut cur = img.clone();
if sigma_x > 0.05 {
for r in box_radii(sigma_x, 3) {
cur = box_h(&cur, r, repeat);
}
}
if sigma_y > 0.05 {
let mut t = transpose(&cur);
for r in box_radii(sigma_y, 3) {
t = box_h(&t, r, repeat);
}
cur = transpose(&t);
}
cur
}
/// Motion blur along `angle_deg` (0 = vertical in AE's Directional Blur, measured clockwise from
/// up) over `length` pixels.
pub fn directional_blur(img: &Image, angle_deg: f64, length: f64) -> Image {
if length < 0.5 {
return img.clone();
}
let a = angle_deg.to_radians();
let (dx, dy) = (a.sin(), -a.cos());
let n = (length.ceil() as usize * 2 + 1).clamp(3, 257);
let mut out = Image::new(img.width, img.height);
out.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let mut acc = [0.0f32; 4];
for i in 0..n {
let t = (i as f64 / (n - 1) as f64 - 0.5) * length;
let p = img.sample_bilinear(x as f64 + 0.5 + dx * t, y as f64 + 0.5 + dy * t);
for c in 0..4 {
acc[c] += p[c];
}
}
*px = acc.map(|v| v / n as f32);
}
});
out
}
/// Radial blur around `center`: `spin` (degrees) or zoom (`amount` as a fraction of distance).
pub fn radial_blur(img: &Image, center: (f64, f64), amount: f64, zoom: bool) -> Image {
if amount.abs() < 1e-6 {
return img.clone();
}
let n = 32usize;
let mut out = Image::new(img.width, img.height);
out.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let (vx, vy) = (x as f64 + 0.5 - center.0, y as f64 + 0.5 - center.1);
let mut acc = [0.0f32; 4];
for i in 0..n {
let t = i as f64 / (n - 1) as f64 - 0.5;
let (sx, sy) = if zoom {
let k = 1.0 + t * amount;
(center.0 + vx * k, center.1 + vy * k)
} else {
let (s, c) = (t * amount).to_radians().sin_cos();
(center.0 + vx * c - vy * s, center.1 + vx * s + vy * c)
};
let p = img.sample_bilinear(sx, sy);
for ch in 0..4 {
acc[ch] += p[ch];
}
}
*px = acc.map(|v| v / n as f32);
}
});
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gaussian_preserves_mass() {
let mut img = Image::new(64, 64);
img.set(32, 32, [1.0; 4]);
let b = gaussian_blur(&img, 4.0, 4.0, false);
let sum: f32 = b.data.iter().map(|p| p[3]).sum();
assert!((sum - 1.0).abs() < 1e-3, "{sum}");
assert!(b.get(32, 32)[3] < 0.05);
}
#[test]
fn flat_image_stays_flat_with_repeat() {
let img = Image::filled(20, 10, [0.5, 0.5, 0.5, 1.0]);
let b = gaussian_blur(&img, 3.0, 3.0, true);
assert!(b.data.iter().all(|p| (p[0] - 0.5).abs() < 1e-5));
}
#[test]
fn radii_sane() {
let r = box_radii(10.0, 3);
assert_eq!(r.len(), 3);
assert!(r.iter().all(|&v| (7..=10).contains(&v)), "{r:?}");
}
}
+358
View File
@@ -0,0 +1,358 @@
//! Raster images for the compositor.
//!
//! [`Image`] stores premultiplied RGBA in `f32` (values may exceed 1.0 in 32 bpc projects). Every
//! heavy operation is row-parallel with rayon.
pub mod blur;
pub mod warp;
use effectcraft_color::{BlendMode, blend_pixel};
use rayon::prelude::*;
pub use blur::{box_blur, directional_blur, gaussian_blur, radial_blur};
pub use warp::{Sampling, WarpOpts, composite_warp, resample};
pub type Px = [f32; 4];
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Image {
pub width: u32,
pub height: u32,
pub data: Vec<Px>,
}
impl Image {
pub fn new(width: u32, height: u32) -> Image {
Image { width, height, data: vec![[0.0; 4]; width as usize * height as usize] }
}
pub fn filled(width: u32, height: u32, px: Px) -> Image {
Image { width, height, data: vec![px; width as usize * height as usize] }
}
pub fn is_empty(&self) -> bool {
self.width == 0 || self.height == 0
}
#[inline]
pub fn idx(&self, x: u32, y: u32) -> usize {
y as usize * self.width as usize + x as usize
}
#[inline]
pub fn get(&self, x: i64, y: i64) -> Px {
if x < 0 || y < 0 || x >= self.width as i64 || y >= self.height as i64 {
[0.0; 4]
} else {
self.data[y as usize * self.width as usize + x as usize]
}
}
#[inline]
pub fn get_clamped(&self, x: i64, y: i64) -> Px {
let x = x.clamp(0, self.width as i64 - 1);
let y = y.clamp(0, self.height as i64 - 1);
self.data[y as usize * self.width as usize + x as usize]
}
#[inline]
pub fn set(&mut self, x: u32, y: u32, px: Px) {
let i = self.idx(x, y);
self.data[i] = px;
}
pub fn rows_mut(&mut self) -> impl IndexedParallelIterator<Item = (usize, &mut [Px])> {
let w = self.width.max(1) as usize;
self.data.par_chunks_mut(w).enumerate()
}
/// Bilinear sample at continuous pixel coordinates (pixel centres at +0.5); transparent outside.
#[inline]
pub fn sample_bilinear(&self, x: f64, y: f64) -> Px {
let fx = x - 0.5;
let fy = y - 0.5;
let x0 = fx.floor();
let y0 = fy.floor();
let tx = (fx - x0) as f32;
let ty = (fy - y0) as f32;
let (x0, y0) = (x0 as i64, y0 as i64);
let a = self.get(x0, y0);
let b = self.get(x0 + 1, y0);
let c = self.get(x0, y0 + 1);
let d = self.get(x0 + 1, y0 + 1);
let mut o = [0.0; 4];
for i in 0..4 {
let top = a[i] + (b[i] - a[i]) * tx;
let bot = c[i] + (d[i] - c[i]) * tx;
o[i] = top + (bot - top) * ty;
}
o
}
/// Bilinear with edge pixels repeated outside the image.
#[inline]
pub fn sample_bilinear_clamped(&self, x: f64, y: f64) -> Px {
let fx = x - 0.5;
let fy = y - 0.5;
let x0 = fx.floor();
let y0 = fy.floor();
let tx = (fx - x0) as f32;
let ty = (fy - y0) as f32;
let (x0, y0) = (x0 as i64, y0 as i64);
let a = self.get_clamped(x0, y0);
let b = self.get_clamped(x0 + 1, y0);
let c = self.get_clamped(x0, y0 + 1);
let d = self.get_clamped(x0 + 1, y0 + 1);
let mut o = [0.0; 4];
for i in 0..4 {
let top = a[i] + (b[i] - a[i]) * tx;
let bot = c[i] + (d[i] - c[i]) * tx;
o[i] = top + (bot - top) * ty;
}
o
}
/// Catmull-Rom bicubic sample; transparent outside.
pub fn sample_bicubic(&self, x: f64, y: f64) -> Px {
let fx = x - 0.5;
let fy = y - 0.5;
let x0 = fx.floor();
let y0 = fy.floor();
let tx = fx - x0;
let ty = fy - y0;
let w = |t: f64| -> [f32; 4] {
let t2 = t * t;
let t3 = t2 * t;
[
(-0.5 * t3 + t2 - 0.5 * t) as f32,
(1.5 * t3 - 2.5 * t2 + 1.0) as f32,
(-1.5 * t3 + 2.0 * t2 + 0.5 * t) as f32,
(0.5 * t3 - 0.5 * t2) as f32,
]
};
let wx = w(tx);
let wy = w(ty);
let mut o = [0.0f32; 4];
for j in 0..4 {
for i in 0..4 {
let p = self.get(x0 as i64 - 1 + i as i64, y0 as i64 - 1 + j as i64);
let k = wx[i] * wy[j];
for c in 0..4 {
o[c] += p[c] * k;
}
}
}
// Keep premultiplied invariants after ringing.
o[3] = o[3].max(0.0);
for c in 0..3 {
o[c] = o[c].max(0.0);
}
o
}
/// Copy with `pad` transparent pixels on every side.
pub fn padded(&self, pad: u32) -> Image {
let mut out = Image::new(self.width + 2 * pad, self.height + 2 * pad);
let ow = out.width as usize;
for y in 0..self.height as usize {
let src = &self.data[y * self.width as usize..(y + 1) * self.width as usize];
let o = (y + pad as usize) * ow + pad as usize;
out.data[o..o + self.width as usize].copy_from_slice(src);
}
out
}
/// Sub-rectangle copy (clipped).
pub fn crop(&self, x: i64, y: i64, w: u32, h: u32) -> Image {
let mut out = Image::new(w, h);
out.rows_mut().for_each(|(oy, row)| {
for (ox, px) in row.iter_mut().enumerate() {
*px = self.get(x + ox as i64, y + oy as i64);
}
});
out
}
/// Multiply every pixel (premultiplied) by `k`.
pub fn scale_alpha(&mut self, k: f32) {
if (k - 1.0).abs() < 1e-7 {
return;
}
self.data.par_iter_mut().for_each(|p| {
for c in p.iter_mut() {
*c *= k;
}
});
}
/// Multiply each pixel by a coverage value from `mask` (same size).
pub fn mul_mask(&mut self, mask: &[f32]) {
self.data.par_iter_mut().zip(mask.par_iter()).for_each(|(p, &m)| {
for c in p.iter_mut() {
*c *= m;
}
});
}
/// Clamp to [0, 1] (8/16 bpc working space).
pub fn clamp01(&mut self) {
self.data.par_iter_mut().for_each(|p| {
for c in p.iter_mut() {
*c = c.clamp(0.0, 1.0);
}
for c in 0..3 {
p[c] = p[c].min(p[3]);
}
});
}
/// Quantise to `levels` per channel (simulates 8/16 bpc output).
pub fn quantize(&mut self, levels: f32) {
self.data.par_iter_mut().for_each(|p| {
for c in p.iter_mut() {
*c = (*c * levels).round() / levels;
}
});
}
/// Straight-alpha 8-bit RGBA (sRGB-encoded values as stored).
pub fn to_rgba8(&self) -> Vec<u8> {
let mut out = vec![0u8; self.data.len() * 4];
out.par_chunks_mut(4).zip(self.data.par_iter()).for_each(|(o, p)| {
let a = p[3].clamp(0.0, 1.0);
let inv = if a > 0.0 { 1.0 / p[3] } else { 0.0 };
for c in 0..3 {
o[c] = ((p[c] * inv).clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
}
o[3] = (a * 255.0 + 0.5) as u8;
});
out
}
/// Composite over an opaque background colour and return opaque 8-bit RGBA.
pub fn to_rgba8_over(&self, bg: [f32; 3]) -> Vec<u8> {
let mut out = vec![0u8; self.data.len() * 4];
out.par_chunks_mut(4).zip(self.data.par_iter()).for_each(|(o, p)| {
let k = 1.0 - p[3].clamp(0.0, 1.0);
for c in 0..3 {
o[c] = ((p[c] + bg[c] * k).clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
}
o[3] = 255;
});
out
}
/// From straight-alpha 8-bit RGBA.
pub fn from_rgba8(width: u32, height: u32, rgba: &[u8]) -> Image {
let mut img = Image::new(width, height);
img.data.par_iter_mut().zip(rgba.par_chunks(4)).for_each(|(p, s)| {
let a = s[3] as f32 / 255.0;
*p = [s[0] as f32 / 255.0 * a, s[1] as f32 / 255.0 * a, s[2] as f32 / 255.0 * a, a];
});
img
}
/// Straight-alpha colour at a pixel.
pub fn straight(&self, x: i64, y: i64) -> Px {
let p = self.get(x, y);
if p[3] > 0.0 { [p[0] / p[3], p[1] / p[3], p[2] / p[3], p[3]] } else { [0.0; 4] }
}
/// Map every pixel through `f` on straight colour (keeps alpha).
pub fn map_straight(&mut self, f: impl Fn([f32; 3]) -> [f32; 3] + Sync) {
self.data.par_iter_mut().for_each(|p| {
let a = p[3];
if a <= 0.0 {
return;
}
let c = f([p[0] / a, p[1] / a, p[2] / a]);
*p = [c[0] * a, c[1] * a, c[2] * a, a];
});
}
/// Blend `src` (same size) onto self with a mode and opacity.
pub fn blend_from(&mut self, src: &Image, mode: BlendMode, opacity: f32, seed: u32) {
let w = self.width as usize;
self.data.par_chunks_mut(w.max(1)).zip(src.data.par_chunks(w.max(1))).enumerate().for_each(|(y, (d, s))| {
for x in 0..d.len() {
let mut sp = s[x];
if sp[3] <= 0.0 && !mode.is_stencil() {
continue;
}
for c in sp.iter_mut() {
*c *= opacity;
}
let n = if matches!(mode, BlendMode::Dissolve | BlendMode::DancingDissolve) { hash_noise(x as u32, y as u32, seed) } else { 0.5 };
d[x] = blend_pixel(mode, d[x], sp, n);
}
});
}
/// Bounding box of non-transparent pixels (x0, y0, x1, y1), None if empty.
pub fn alpha_bounds(&self) -> Option<(u32, u32, u32, u32)> {
let w = self.width as usize;
let mut b: Option<(u32, u32, u32, u32)> = None;
for (y, row) in self.data.chunks(w.max(1)).enumerate() {
if let Some(first) = row.iter().position(|p| p[3] > 1e-6) {
let last = row.iter().rposition(|p| p[3] > 1e-6).unwrap_or(first);
let (y, f, l) = (y as u32, first as u32, last as u32 + 1);
b = Some(match b {
None => (f, y, l, y + 1),
Some((x0, y0, x1, _)) => (x0.min(f), y0, x1.max(l), y + 1),
});
}
}
b
}
}
/// Deterministic per-pixel noise in 0..1.
#[inline]
pub fn hash_noise(x: u32, y: u32, seed: u32) -> f32 {
let mut h = x.wrapping_mul(0x8da6_b343) ^ y.wrapping_mul(0xd816_3841) ^ seed.wrapping_mul(0xcb1a_b31f);
h ^= h >> 13;
h = h.wrapping_mul(0x5bd1_e995);
h ^= h >> 15;
(h & 0x00ff_ffff) as f32 / 16_777_216.0
}
/// A single-channel coverage mask.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Mask {
pub width: u32,
pub height: u32,
pub data: Vec<f32>,
}
impl Mask {
pub fn new(width: u32, height: u32, v: f32) -> Mask {
Mask { width, height, data: vec![v; width as usize * height as usize] }
}
pub fn to_image(&self) -> Image {
Image { width: self.width, height: self.height, data: self.data.iter().map(|&a| [a, a, a, a]).collect() }
}
pub fn from_alpha(img: &Image) -> Mask {
Mask { width: img.width, height: img.height, data: img.data.iter().map(|p| p[3]).collect() }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rgba8_roundtrip() {
let src: Vec<u8> = vec![255, 128, 0, 255, 10, 20, 30, 128, 0, 0, 0, 0, 1, 2, 3, 4];
let img = Image::from_rgba8(2, 2, &src);
let back = img.to_rgba8();
for (a, b) in src.chunks(4).zip(back.chunks(4)) {
if a[3] >= 128 {
assert!(a.iter().zip(b).all(|(x, y)| (*x as i32 - *y as i32).abs() <= 1), "{a:?} {b:?}");
} else {
assert!((a[3] as i32 - b[3] as i32).abs() <= 1);
}
}
}
#[test]
fn bilinear_at_centres_is_exact() {
let mut img = Image::new(3, 3);
img.set(1, 1, [0.5, 0.25, 0.125, 1.0]);
assert_eq!(img.sample_bilinear(1.5, 1.5), [0.5, 0.25, 0.125, 1.0]);
let half = img.sample_bilinear(2.0, 1.5);
assert!((half[3] - 0.5).abs() < 1e-6);
}
#[test]
fn alpha_bounds_found() {
let mut img = Image::new(10, 10);
img.set(3, 4, [1.0; 4]);
img.set(6, 7, [1.0; 4]);
assert_eq!(img.alpha_bounds(), Some((3, 4, 7, 8)));
}
#[test]
fn noise_in_range() {
for i in 0..1000 {
let n = hash_noise(i, i * 7, 3);
assert!((0.0..1.0).contains(&n));
}
}
}
+182
View File
@@ -0,0 +1,182 @@
//! Projective warps composited straight into a destination (the layer transform step).
use effectcraft_color::{BlendMode, blend_pixel};
use effectcraft_geom::{Mat3, Rect, vec2};
use rayon::prelude::*;
use crate::{Image, hash_noise};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Sampling {
Nearest,
#[default]
Bilinear,
Bicubic,
}
#[derive(Clone, Copy, Debug)]
pub struct WarpOpts {
pub sampling: Sampling,
pub opacity: f32,
pub mode: BlendMode,
pub seed: u32,
/// Only touch destination pixels inside this rect (dst pixel coords).
pub clip: Option<Rect>,
}
impl Default for WarpOpts {
fn default() -> Self {
WarpOpts { sampling: Sampling::Bilinear, opacity: 1.0, mode: BlendMode::Normal, seed: 0, clip: None }
}
}
/// Box-filter halving (for minification).
fn half(src: &Image) -> Image {
let w = src.width.div_ceil(2).max(1);
let h = src.height.div_ceil(2).max(1);
let mut out = Image::new(w, h);
out.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let (sx, sy) = (2 * x as i64, 2 * y as i64);
let mut acc = [0.0f32; 4];
for (dx, dy) in [(0, 0), (1, 0), (0, 1), (1, 1)] {
let p = src.get_clamped(sx + dx, sy + dy);
for c in 0..4 {
acc[c] += p[c];
}
}
*px = acc.map(|v| v * 0.25);
}
});
out
}
/// Draw `src` transformed by `m` (src pixel space → dst pixel space) onto `dst` with blending.
pub fn composite_warp(dst: &mut Image, src: &Image, m: &Mat3, opts: &WarpOpts) {
if src.is_empty() || dst.is_empty() || opts.opacity <= 0.0 {
return;
}
let Some(inv) = m.inverse() else { return };
// Minification: pick a pre-filtered level so sampling doesn't alias.
let scale = m.mean_scale();
let mut level_img;
let mut img = src;
let mut inv = inv;
if scale < 0.5 && m.is_affine() {
let mut s = scale;
let mut f = 1.0;
level_img = half(src);
f *= 0.5;
s *= 2.0;
while s < 0.5 && level_img.width > 1 && level_img.height > 1 {
level_img = half(&level_img);
f *= 0.5;
s *= 2.0;
}
img = &level_img;
inv = Mat3::scale(vec2(f, f)) * inv;
}
let bounds = m.map_rect(&Rect::from_size(src.width as f64, src.height as f64)).inflate(1.0).round_out();
let full = Rect::from_size(dst.width as f64, dst.height as f64);
let mut area = bounds.intersect(&full);
if let Some(c) = opts.clip {
area = area.intersect(&c);
}
if area.is_empty() {
return;
}
let (x0, x1) = (area.x0.max(0.0) as usize, area.x1.min(dst.width as f64) as usize);
let (y0, y1) = (area.y0.max(0.0) as usize, area.y1.min(dst.height as f64) as usize);
let w = dst.width as usize;
let affine = inv.is_affine();
let mode = opts.mode;
let op = opts.opacity;
dst.data.par_chunks_mut(w).enumerate().skip(y0).take(y1 - y0).for_each(|(y, row)| {
let fy = y as f64 + 0.5;
let mut sp = inv.apply(vec2(x0 as f64 + 0.5, fy));
let step = inv.apply_vec(vec2(1.0, 0.0));
for x in x0..x1 {
let p = if affine {
let p = sp;
sp += step;
p
} else {
inv.apply(vec2(x as f64 + 0.5, fy))
};
if p.x < -1.0 || p.y < -1.0 || p.x > img.width as f64 + 1.0 || p.y > img.height as f64 + 1.0 {
if mode.is_stencil() {
row[x] = blend_pixel(mode, row[x], [0.0; 4], 0.5);
}
continue;
}
let mut s = match opts.sampling {
Sampling::Nearest => img.get(p.x.floor() as i64, p.y.floor() as i64),
Sampling::Bilinear => img.sample_bilinear(p.x, p.y),
Sampling::Bicubic => img.sample_bicubic(p.x, p.y),
};
if s[3] <= 0.0 && !mode.is_stencil() {
continue;
}
if op < 1.0 {
for c in s.iter_mut() {
*c *= op;
}
}
let n = if matches!(mode, BlendMode::Dissolve | BlendMode::DancingDissolve) { hash_noise(x as u32, y as u32, opts.seed) } else { 0.5 };
row[x] = blend_pixel(mode, row[x], s, n);
}
});
// Stencil modes also clear everything outside the layer bounds.
if mode.is_stencil() && matches!(mode, BlendMode::StencilAlpha | BlendMode::StencilLuma) {
for (y, row) in dst.data.chunks_mut(w).enumerate() {
for (x, px) in row.iter_mut().enumerate() {
if x < x0 || x >= x1 || y < y0 || y >= y1 {
*px = [0.0; 4];
}
}
}
}
}
/// Resize with bilinear filtering (box pre-filter when shrinking).
pub fn resample(src: &Image, w: u32, h: u32) -> Image {
let mut out = Image::new(w, h);
if src.is_empty() || w == 0 || h == 0 {
return out;
}
let m = Mat3::scale(vec2(w as f64 / src.width as f64, h as f64 / src.height as f64));
composite_warp(&mut out, src, &m, &WarpOpts::default());
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn identity_warp_copies() {
let mut src = Image::new(4, 4);
src.set(1, 2, [0.5, 0.5, 0.5, 1.0]);
let mut dst = Image::new(4, 4);
composite_warp(&mut dst, &src, &Mat3::IDENTITY, &WarpOpts::default());
assert_eq!(dst.get(1, 2), [0.5, 0.5, 0.5, 1.0]);
assert_eq!(dst.get(0, 0), [0.0; 4]);
}
#[test]
fn translate_by_integer() {
let mut src = Image::new(4, 4);
src.set(0, 0, [1.0; 4]);
let mut dst = Image::new(8, 8);
composite_warp(&mut dst, &src, &Mat3::translate(vec2(3.0, 2.0)), &WarpOpts::default());
assert_eq!(dst.get(3, 2), [1.0; 4]);
}
#[test]
fn downscale_preserves_average() {
let src = Image::filled(64, 64, [0.25, 0.5, 0.75, 1.0]);
let out = resample(&src, 8, 8);
let p = out.get(4, 4);
assert!((p[1] - 0.5).abs() < 1e-4 && (p[3] - 1.0).abs() < 1e-4, "{p:?}");
}
}
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "effectcraft-render"
description = "The CPU compositor: property evaluation, layer sources (solids, footage, text, shapes, precomps), masks, effects, track mattes, 2D/3D transforms, motion blur and blending"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[lints]
workspace = true
[dependencies]
effectcraft-time = { workspace = true }
effectcraft-geom = { workspace = true }
effectcraft-color = { workspace = true }
effectcraft-keyframe = { workspace = true }
effectcraft-project = { workspace = true }
effectcraft-raster = { workspace = true }
effectcraft-path = { workspace = true }
effectcraft-text = { workspace = true }
effectcraft-effects = { workspace = true }
kurbo = { workspace = true }
rayon = { workspace = true }
log = { workspace = true }
+177
View File
@@ -0,0 +1,177 @@
//! Property evaluation (keyframes + expressions) and layer transforms.
//!
//! Keyframe times are stored in **layer time** (they move with the layer), so evaluating a
//! property at comp time `t` first maps `t` through the layer's start time and stretch.
use effectcraft_geom::{Mat3, Mat4, Vec3, look_at, vec3};
use effectcraft_keyframe::Value;
use effectcraft_project::{Comp, ItemId, Layer, LayerId, LayerSource, Project, PropGroup, Property};
use effectcraft_time::Tick;
/// Expression evaluation hook (implemented by `effectcraft-expr`).
pub trait ExprHost: Send + Sync {
/// Evaluate the expression of `prop` (on `layer`, comp time `t`), given the keyframed value.
fn eval(&self, ctx: &EvalCtx, layer: &Layer, prop: &Property, value: &Value) -> Result<Value, String>;
}
#[derive(Clone, Copy)]
pub struct EvalCtx<'a> {
pub project: &'a Project,
pub comp_id: ItemId,
pub comp: &'a Comp,
/// Comp time.
pub time: Tick,
pub expr: Option<&'a dyn ExprHost>,
}
impl<'a> EvalCtx<'a> {
pub fn new(project: &'a Project, comp_id: ItemId, comp: &'a Comp, time: Tick) -> EvalCtx<'a> {
EvalCtx { project, comp_id, comp, time, expr: None }
}
pub fn at(&self, time: Tick) -> EvalCtx<'a> {
EvalCtx { time, ..*self }
}
/// Value of a property at the context time (keyframes, then expression).
pub fn value(&self, layer: &Layer, prop: &Property) -> Value {
let lt = layer.layer_time(self.time);
let v = prop.value_at(lt);
if prop.has_expression()
&& let Some(h) = self.expr
{
return h.eval(self, layer, prop, &v).unwrap_or(v);
}
v
}
pub fn group_value(&self, layer: &Layer, g: &PropGroup, m: &str) -> Option<Value> {
g.get(m).map(|p| self.value(layer, p))
}
pub fn f(&self, layer: &Layer, g: &PropGroup, m: &str, d: f64) -> f64 {
self.group_value(layer, g, m).map(|v| v.as_f64()).unwrap_or(d)
}
pub fn v2(&self, layer: &Layer, g: &PropGroup, m: &str, d: [f64; 2]) -> [f64; 2] {
self.group_value(layer, g, m).map(|v| v.as_vec2()).unwrap_or(d)
}
pub fn v3(&self, layer: &Layer, g: &PropGroup, m: &str, d: [f64; 3]) -> [f64; 3] {
self.group_value(layer, g, m).map(|v| v.as_vec3()).unwrap_or(d)
}
pub fn color(&self, layer: &Layer, g: &PropGroup, m: &str) -> [f32; 4] {
self.group_value(layer, g, m).map(|v| v.as_color()).unwrap_or([1.0; 4])
}
pub fn e(&self, layer: &Layer, g: &PropGroup, m: &str) -> u32 {
self.group_value(layer, g, m).map(|v| v.as_enum()).unwrap_or(0)
}
pub fn b(&self, layer: &Layer, g: &PropGroup, m: &str) -> bool {
self.group_value(layer, g, m).map(|v| v.as_bool()).unwrap_or(false)
}
pub fn layer(&self, id: LayerId) -> Option<&'a Layer> {
self.comp.layer(id)
}
/// Local (parent-space) transform of a layer.
pub fn local_matrix(&self, layer: &Layer) -> Mat4 {
let Some(tr) = layer.transform() else { return Mat4::IDENTITY };
let three = layer.is_3d();
let pos = self.v3(layer, tr, "position", [0.0; 3]);
let rz = self.f(layer, tr, "rotation", 0.0);
if layer.is_camera() || layer.is_light() {
return Mat4::translate(Vec3::from(pos)) * Mat4::rotate_z(rz);
}
let anchor = self.v3(layer, tr, "anchor", [0.0; 3]);
let mut scale = self.v3(layer, tr, "scale", [100.0; 3]);
if !three {
scale[2] = 100.0;
let a = Vec3::from([anchor[0], anchor[1], 0.0]);
let p = Vec3::from([pos[0], pos[1], 0.0]);
return Mat4::layer_3d(a, p, Vec3::from(scale), Vec3::ZERO, vec3(0.0, 0.0, rz));
}
let o = self.v3(layer, tr, "orientation", [0.0; 3]);
let rx = self.f(layer, tr, "rotationX", 0.0);
let ry = self.f(layer, tr, "rotationY", 0.0);
Mat4::layer_3d(Vec3::from(anchor), Vec3::from(pos), Vec3::from(scale), Vec3::from(o), vec3(rx, ry, rz))
}
/// Layer space → comp (world) space, including parents.
pub fn world_matrix(&self, layer: &Layer) -> Mat4 {
let mut m = self.local_matrix(layer);
let mut cur = layer.parent;
let mut guard = 0;
while let Some(pid) = cur {
guard += 1;
if guard > 64 {
break;
}
let Some(p) = self.comp.layer(pid) else { break };
m = self.local_matrix(p) * m;
cur = p.parent;
}
m
}
pub fn opacity(&self, layer: &Layer) -> f64 {
layer.transform().map(|tr| self.f(layer, tr, "opacity", 100.0)).unwrap_or(100.0) / 100.0
}
/// The comp's camera at this time: (view-projection to comp pixels, eye position).
pub fn camera(&self) -> (Mat4, Vec3, f64) {
let (w, h) = (self.comp.width as f64, self.comp.height as f64);
if let Some(cam) = self.comp.active_camera(self.time)
&& let Some(tr) = cam.transform()
{
let pos = Vec3::from(self.v3(cam, tr, "position", [w / 2.0, h / 2.0, -1000.0]));
let zoom = cam.props.sub("cameraOptions").map(|g| self.f(cam, g, "zoom", 1000.0)).unwrap_or(1000.0);
let o = self.v3(cam, tr, "orientation", [0.0; 3]);
let rx = self.f(cam, tr, "rotationX", 0.0);
let ry = self.f(cam, tr, "rotationY", 0.0);
let rz = self.f(cam, tr, "rotation", 0.0);
// Extra camera rotation is applied in camera space (inverse, because it rotates the eye).
let extra = (Mat4::orientation(Vec3::from(o)) * Mat4::rotate_z(rz) * Mat4::rotate_y(ry) * Mat4::rotate_x(rx)).transpose();
let view = if let Some(poi) = tr.get("poi").map(|p| self.value(cam, p).as_vec3()) {
look_at(pos, Vec3::from(poi), extra)
} else {
look_at(pos, pos + vec3(0.0, 0.0, 1.0), extra)
};
let mut view = view;
// Parented cameras: apply the parent's world matrix inverse.
if let Some(pid) = cam.parent
&& let Some(p) = self.comp.layer(pid)
&& let Some(inv) = self.world_matrix(p).inverse()
{
view = view * inv;
let _ = pos;
}
return (effectcraft_geom::camera_matrix(w, h, pos, view, zoom), pos, zoom);
}
let zoom = effectcraft_geom::default_camera_zoom(w);
let pos = vec3(w / 2.0, h / 2.0, -zoom);
let view = look_at(pos, vec3(w / 2.0, h / 2.0, 0.0), Mat4::IDENTITY);
(effectcraft_geom::camera_matrix(w, h, pos, view, zoom), pos, zoom)
}
/// Layer space → comp pixel space as a 2D projective matrix, plus camera-space depth of the
/// layer's anchor (for 3D sorting). 2D layers ignore the camera.
pub fn layer_to_comp(&self, layer: &Layer) -> (Mat3, f64) {
let world = self.world_matrix(layer);
if layer.is_3d() {
let (cam, _, _) = self.camera();
let full = cam * world;
let anchor = layer.transform().map(|tr| self.v3(layer, tr, "anchor", [0.0; 3])).unwrap_or([0.0; 3]);
let a = world.apply(Vec3::from(anchor));
let depth = cam.0[3][0] * a.x + cam.0[3][1] * a.y + cam.0[3][2] * a.z + cam.0[3][3];
(full.plane_to_mat3(), depth)
} else {
let m = &world.0;
(Mat3([[m[0][0], m[0][1], m[0][3]], [m[1][0], m[1][1], m[1][3]], [0.0, 0.0, 1.0]]), 0.0)
}
}
}
/// Size of a layer's source in layer pixels.
pub fn source_size(project: &Project, layer: &Layer) -> (u32, u32) {
match &layer.source {
LayerSource::Footage { item } | LayerSource::Comp { item } | LayerSource::Solid { item } => {
project.item(*item).and_then(|i| i.dimensions()).unwrap_or((0, 0))
}
LayerSource::Null => (100, 100),
_ => (0, 0),
}
}
+333
View File
@@ -0,0 +1,333 @@
//! The EffectCraft compositor (CPU reference path).
//!
//! `Renderer::comp_frame` renders a composition at a time:
//! bottom-to-top over visible layers → **source** (solid, footage, text, shapes, precomp) →
//! **masks** → **effects** → **transform** (2D affine or 3D projective through the active camera,
//! with motion blur sub-samples) → **track matte** → **blend** with the layer's mode and opacity.
//! Adjustment layers run their effects on everything below, limited by their own bounds/masks.
pub mod eval;
pub mod masks;
pub mod shapes;
pub mod text;
use std::sync::Arc;
use effectcraft_color::BlendMode;
use effectcraft_effects::{Buf, EffectCtx, Params};
use effectcraft_geom::{Mat3, vec2};
pub use eval::{EvalCtx, ExprHost, source_size};
use effectcraft_project::{Comp, Footage, GroupKind, ItemId, ItemKind, Layer, LayerSource, MatteKind, Node, Project, Quality, Sampling};
use effectcraft_raster::{Image, WarpOpts, composite_warp};
use effectcraft_time::Tick;
use rayon::prelude::*;
/// Supplies decoded footage frames (implemented by the media layer).
pub trait FootageSource: Send + Sync {
/// The frame of `item` at source time `t`, straight from the file (any size).
fn frame(&self, item: ItemId, footage: &Footage, t: Tick) -> Option<Arc<Image>>;
}
/// No footage available (renders footage layers as transparent).
pub struct NoFootage;
impl FootageSource for NoFootage {
fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<Image>> {
None
}
}
#[derive(Clone, Copy, Debug)]
pub struct RenderOpts {
/// Output scale relative to comp pixels (1 = Full, 0.5 = Half…).
pub scale: f64,
pub motion_blur: bool,
/// Render guide layers (viewer only).
pub guides: bool,
/// Draft quality: fewer motion-blur samples, bilinear only.
pub draft: bool,
}
impl Default for RenderOpts {
fn default() -> Self {
RenderOpts { scale: 1.0, motion_blur: true, guides: false, draft: false }
}
}
pub struct Renderer<'a> {
pub project: &'a Project,
pub footage: &'a dyn FootageSource,
pub expr: Option<&'a dyn ExprHost>,
pub opts: RenderOpts,
/// Current nesting depth (precomp recursion guard).
depth: usize,
}
impl<'a> Renderer<'a> {
pub fn new(project: &'a Project, footage: &'a dyn FootageSource, opts: RenderOpts) -> Renderer<'a> {
Renderer { project, footage, expr: None, opts, depth: 0 }
}
fn ctx(&self, comp_id: ItemId, comp: &'a Comp, t: Tick) -> EvalCtx<'a> {
EvalCtx { project: self.project, comp_id, comp, time: t, expr: self.expr }
}
/// Render a composition at comp time `t` (transparent background, comp size × scale).
pub fn comp_frame(&self, comp_id: ItemId, t: Tick) -> Image {
let Some(comp) = self.project.comp(comp_id) else { return Image::new(1, 1) };
let s = self.opts.scale;
let w = ((comp.width as f64 * s).round() as u32).max(1);
let h = ((comp.height as f64 * s).round() as u32).max(1);
let mut canvas = Image::new(w, h);
if self.depth > 16 {
return canvas;
}
let ctx = self.ctx(comp_id, comp, t);
let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.source.is_av() && l.is_active_at(t));
// Bottom-to-top, with runs of consecutive 3D layers depth-sorted (farthest first).
let visible: Vec<&Layer> = comp
.layers
.iter()
.rev()
.filter(|l| l.is_active_at(t) && l.has_video() && (!any_solo || l.switches.solo) && (self.opts.guides || !l.switches.guide))
.collect();
let mut i = 0;
while i < visible.len() {
if visible[i].is_3d() {
let mut j = i;
while j < visible.len() && visible[j].is_3d() {
j += 1;
}
let mut run: Vec<(&Layer, f64)> = visible[i..j].iter().map(|l| (*l, ctx.layer_to_comp(l).1)).collect();
run.sort_by(|a, b| b.1.total_cmp(&a.1));
for (l, _) in run {
self.draw_layer(&ctx, l, &mut canvas);
}
i = j;
} else {
self.draw_layer(&ctx, visible[i], &mut canvas);
i += 1;
}
}
if !self.project.settings.bit_depth.is_float() {
canvas.clamp01();
}
canvas
}
/// Evaluate an effect instance's parameters.
fn effect_params(&self, ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup) -> Params {
let mut p = Params::default();
for c in &g.children {
if let Node::Prop(pr) = c {
p.values.insert(pr.match_id.clone(), ctx.value(layer, pr));
}
}
p
}
fn apply_effects(&self, ctx: &EvalCtx, layer: &Layer, mut buf: Buf, adjustment: bool) -> Buf {
if !layer.switches.effects {
return buf;
}
let Some(fx) = layer.effects() else { return buf };
let lt = layer.layer_time(ctx.time);
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] };
for g in fx.groups() {
if !g.enabled {
continue;
}
let GroupKind::Effect { effect } = &g.kind else { continue };
let Some(spec) = effectcraft_effects::find(effect) else { continue };
let params = self.effect_params(ctx, layer, g);
let ectx = EffectCtx { params: &params, time: lt.seconds(), layer_size, seed: g.uid as u32, adjustment };
buf = effectcraft_effects::apply(spec, &ectx, buf);
}
buf
}
/// Source pixels of a layer in layer space (before masks/effects).
fn source(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Buf> {
let s = self.opts.scale;
match &layer.source {
LayerSource::Solid { item } => {
let ItemKind::Solid(sol) = &self.project.item(*item)?.kind else { return None };
let w = ((sol.width as f64 * s).ceil() as u32).max(1);
let h = ((sol.height as f64 * s).ceil() as u32).max(1);
if layer.switches.adjustment {
return Some(Buf { img: Image::filled(w, h, [1.0; 4]), offset: [0.0; 2], scale: s });
}
let c = sol.color;
Some(Buf { img: Image::filled(w, h, [c[0], c[1], c[2], 1.0]), offset: [0.0; 2], scale: s })
}
LayerSource::Comp { item } => {
if self.project.comp_contains(*item, ctx.comp_id) {
return None;
}
let sub = Renderer { project: self.project, footage: self.footage, expr: self.expr, opts: self.opts, depth: self.depth + 1 };
let lt = layer.layer_time(ctx.time);
Some(Buf { img: sub.comp_frame(*item, lt), offset: [0.0; 2], scale: s })
}
LayerSource::Footage { item } => {
let it = self.project.item(*item)?;
let ItemKind::Footage(f) = &it.kind else { return None };
if !f.has_video {
return None;
}
let lt = layer.layer_time(ctx.time);
let img = self.footage.frame(*item, f, lt)?;
let k = if img.width > 0 { f.width.max(1) as f64 / img.width as f64 } else { 1.0 };
// Keep native pixels when downsampling is small; resample otherwise.
if s < 0.75 {
let w = ((f.width as f64 * s).round() as u32).max(1);
let h = ((f.height as f64 * s).round() as u32).max(1);
return Some(Buf { img: effectcraft_raster::resample(&img, w, h), offset: [0.0; 2], scale: s });
}
Some(Buf { img: (*img).clone(), offset: [0.0; 2], scale: 1.0 / k })
}
LayerSource::Text => Some(text::render(ctx, layer, s)),
LayerSource::Shape => layer.props.sub("contents").map(|c| shapes::render(ctx, layer, c, s)),
_ => None,
}
}
/// Fully processed layer buffer (source → masks → effects).
pub fn layer_buf(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Buf> {
let mut buf = self.source(ctx, layer)?;
masks::apply(ctx, layer, &mut buf);
Some(self.apply_effects(ctx, layer, buf, false))
}
fn sampling(&self, layer: &Layer) -> effectcraft_raster::Sampling {
if self.opts.draft || layer.switches.quality == Quality::Draft {
effectcraft_raster::Sampling::Bilinear
} else if layer.switches.sampling == Sampling::Bicubic {
effectcraft_raster::Sampling::Bicubic
} else {
effectcraft_raster::Sampling::Bilinear
}
}
/// Buffer pixel → output pixel matrix for the layer at the context time.
fn buf_matrix(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf) -> Mat3 {
let s = self.opts.scale;
let (l2c, _) = ctx.layer_to_comp(layer);
Mat3::scale(vec2(s, s)) * l2c * Mat3::scale(vec2(1.0 / buf.scale, 1.0 / buf.scale)) * Mat3::translate(vec2(-buf.offset[0], -buf.offset[1]))
}
/// Draw a processed layer into `target` (transform, motion blur) with `mode`/`opacity`.
fn place(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf, target: &mut Image, mode: BlendMode, opacity: f32) {
let samples = if self.opts.motion_blur && ctx.comp.enable_motion_blur && layer.switches.motion_blur {
if self.opts.draft { 4 } else { ctx.comp.motion_blur_samples.clamp(2, 64) as usize }
} else {
1
};
let opts = WarpOpts { sampling: self.sampling(layer), opacity, mode, seed: layer.id.0 as u32, clip: None };
if samples <= 1 {
let m = self.buf_matrix(ctx, layer, buf);
composite_warp(target, &buf.img, &m, &opts);
return;
}
let fd = ctx.comp.frame_duration().seconds();
let angle = ctx.comp.shutter_angle / 360.0;
let phase = ctx.comp.shutter_phase / 360.0;
let mut acc = Image::new(target.width, target.height);
let accum: Vec<Image> = (0..samples)
.into_par_iter()
.map(|i| {
let f = phase + angle * i as f64 / (samples - 1) as f64;
let sub = ctx.at(ctx.time + Tick::from_seconds_f64(f * fd));
let m = self.buf_matrix(&sub, layer, buf);
let mut img = Image::new(target.width, target.height);
composite_warp(&mut img, &buf.img, &m, &WarpOpts { mode: BlendMode::Normal, opacity: 1.0, ..opts });
img
})
.collect();
let k = 1.0 / samples as f32;
for img in &accum {
acc.data.par_iter_mut().zip(img.data.par_iter()).for_each(|(a, b)| {
for c in 0..4 {
a[c] += b[c] * k;
}
});
}
target.blend_from(&acc, mode, opacity, layer.id.0 as u32);
}
fn draw_layer(&self, ctx: &EvalCtx, layer: &Layer, canvas: &mut Image) {
let opacity = ctx.opacity(layer) as f32;
if opacity <= 0.0 && !layer.switches.adjustment {
return;
}
if layer.switches.adjustment {
self.draw_adjustment(ctx, layer, canvas, opacity);
return;
}
let Some(buf) = self.layer_buf(ctx, layer) else { return };
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 {
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);
if let Some((m, kind)) = matte {
let mut mimg = Image::new(canvas.width, canvas.height);
if m.is_active_at(ctx.time)
&& let Some(mb) = self.layer_buf(ctx, m)
{
let mo = ctx.opacity(m) as f32;
self.place(ctx, m, &mb, &mut mimg, BlendMode::Normal, mo);
}
iso.data.par_iter_mut().zip(mimg.data.par_iter()).for_each(|(p, q)| {
let k = match kind {
MatteKind::Alpha => q[3],
MatteKind::AlphaInverted => 1.0 - q[3],
MatteKind::Luma => effectcraft_color::luminance(q[0], q[1], q[2]),
MatteKind::LumaInverted => 1.0 - effectcraft_color::luminance(q[0], q[1], q[2]),
}
.clamp(0.0, 1.0);
for c in p.iter_mut() {
*c *= k;
}
});
}
if preserve {
iso.data.par_iter_mut().zip(canvas.data.par_iter()).for_each(|(p, q)| {
for c in p.iter_mut() {
*c *= q[3];
}
});
}
canvas.blend_from(&iso, layer.blend_mode, opacity, layer.id.0 as u32);
}
/// Adjustment layer: effects applied to the comp below, limited to the layer's footprint.
fn draw_adjustment(&self, ctx: &EvalCtx, layer: &Layer, canvas: &mut Image, opacity: f32) {
let Some(mut foot) = self.source(ctx, layer) else { return };
masks::apply(ctx, layer, &mut foot);
let mut matte = Image::new(canvas.width, canvas.height);
self.place(ctx, layer, &foot, &mut matte, BlendMode::Normal, 1.0);
let below = Buf { img: canvas.clone(), offset: [0.0; 2], scale: self.opts.scale };
let adjusted = self.apply_effects(ctx, layer, below, true);
if adjusted.img.width != canvas.width || adjusted.img.height != canvas.height {
return;
}
canvas.data.par_iter_mut().zip(adjusted.img.data.par_iter()).zip(matte.data.par_iter()).for_each(|((c, a), m)| {
let k = m[3] * opacity;
for i in 0..4 {
c[i] += (a[i] - c[i]) * k;
}
});
}
}
/// 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)
}
#[cfg(test)]
mod tests;
+70
View File
@@ -0,0 +1,70 @@
//! Layer masks: path coverage with expansion, feather, opacity, invert, combined by mode.
use effectcraft_effects::Buf;
use effectcraft_geom::{Mat3, vec2};
use effectcraft_keyframe::Value;
use effectcraft_path::{FillRule, StrokeStyle};
use effectcraft_project::{GroupKind, Layer, MaskMode};
use effectcraft_raster::{Mask, gaussian_blur};
use rayon::prelude::*;
use crate::eval::EvalCtx;
/// Coverage of one mask in buffer pixels (before mode/opacity/invert).
fn coverage(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup, buf: &Buf) -> Option<Mask> {
let Value::Path(sp) = ctx.group_value(layer, g, "path")? else { return None };
let path = effectcraft_path::to_kurbo(&sp);
let (w, h) = (buf.img.width, buf.img.height);
let m = Mat3::translate(vec2(buf.offset[0], buf.offset[1])) * Mat3::scale(vec2(buf.scale, buf.scale));
let mut cov = effectcraft_path::fill_coverage(std::slice::from_ref(&path), &m, w, h, FillRule::NonZero);
let exp = ctx.f(layer, g, "expansion", 0.0);
if exp.abs() > 0.01 {
let ring = effectcraft_path::stroke_coverage(std::slice::from_ref(&path), &StrokeStyle { width: exp.abs() * 2.0, join: effectcraft_path::Join::Round, ..Default::default() }, &m, w, h);
cov.data.par_iter_mut().zip(ring.data.par_iter()).for_each(|(c, r)| {
*c = if exp > 0.0 { (*c + r - *c * r).min(1.0) } else { (*c * (1.0 - r)).max(0.0) };
});
}
let feather = ctx.v2(layer, g, "feather", [0.0; 2]);
if feather[0] > 0.0 || feather[1] > 0.0 {
let img = cov.to_image();
let b = gaussian_blur(&img, feather[0] * buf.scale / 2.0, feather[1] * buf.scale / 2.0, false);
cov = Mask::from_alpha(&b);
}
Some(cov)
}
/// Apply the layer's masks to its buffer.
pub fn apply(ctx: &EvalCtx, layer: &Layer, buf: &mut Buf) {
let Some(masks) = layer.masks() else { return };
let list: Vec<_> = masks.groups().filter(|g| g.enabled && matches!(g.kind, GroupKind::Mask { mode, .. } if mode != MaskMode::None)).collect();
if list.is_empty() {
return;
}
// Masks may extend past the source: grow the buffer to cover them when feather/expansion pads.
let n = (buf.img.width * buf.img.height) as usize;
let first_sub = matches!(list[0].kind, GroupKind::Mask { mode: MaskMode::Subtract, .. });
let mut acc = vec![if first_sub { 1.0f32 } else { 0.0 }; n];
for g in list {
let GroupKind::Mask { mode, inverted, .. } = g.kind else { continue };
let Some(mut cov) = coverage(ctx, layer, g, buf) else { continue };
let op = (ctx.f(layer, g, "opacity", 100.0) / 100.0) as f32;
cov.data.par_iter_mut().for_each(|c| {
if inverted {
*c = 1.0 - *c;
}
*c *= op;
});
acc.par_iter_mut().zip(cov.data.par_iter()).for_each(|(a, &c)| {
*a = match mode {
MaskMode::Add => *a + c - *a * c,
MaskMode::Subtract => *a * (1.0 - c),
MaskMode::Intersect => *a * c,
MaskMode::Lighten => a.max(c),
MaskMode::Darken => a.min(c),
MaskMode::Difference => (*a - c).abs(),
MaskMode::None => *a,
};
});
}
buf.img.mul_mask(&acc);
}
+284
View File
@@ -0,0 +1,284 @@
//! Shape-layer contents: groups, parametric paths, fills, strokes, gradients and path operators,
//! with the After Effects stacking semantics (operators and paint items act on the paths above
//! them in the same group; items higher in the list draw on top).
use effectcraft_effects::Buf;
use effectcraft_geom::{Mat3, vec2};
use effectcraft_keyframe::{Gradient, Value};
use effectcraft_path::{BezPath, Cap, FillRule, Join, StrokeStyle};
use effectcraft_project::{Layer, Node, PropGroup};
use effectcraft_raster::{Image, Mask};
use rayon::prelude::*;
use crate::eval::EvalCtx;
#[derive(Clone, Debug)]
enum Paint {
Fill { color: [f32; 4], rule: FillRule },
Stroke { color: [f32; 4], style: StrokeStyle },
Gradient { g: Gradient, radial: bool, start: [f64; 2], end: [f64; 2], rule: FillRule },
}
#[derive(Clone, Debug)]
struct Draw {
paint: Paint,
paths: Vec<BezPath>,
/// Group-space → layer-space for gradients and stroke scaling.
xf: Mat3,
opacity: f32,
}
fn mat_paths(paths: &[BezPath], m: &Mat3) -> Vec<BezPath> {
effectcraft_path::transform(paths, m)
}
fn group_matrix(ctx: &EvalCtx, layer: &Layer, tr: &PropGroup) -> (Mat3, f32) {
let anchor = ctx.v2(layer, tr, "anchor", [0.0; 2]);
let pos = ctx.v2(layer, tr, "position", [0.0; 2]);
let scale = ctx.v2(layer, tr, "scale", [100.0; 2]);
let rot = ctx.f(layer, tr, "rotation", 0.0);
let skew = ctx.f(layer, tr, "skew", 0.0);
let skew_axis = ctx.f(layer, tr, "skewAxis", 0.0);
let m = Mat3::translate(vec2(pos[0], pos[1]))
* Mat3::rotate_deg(rot)
* Mat3::skew_deg(-skew, skew_axis)
* Mat3::scale(vec2(scale[0] / 100.0, scale[1] / 100.0))
* Mat3::translate(vec2(-anchor[0], -anchor[1]));
(m, (ctx.f(layer, tr, "opacity", 100.0) / 100.0) as f32)
}
fn rule(ctx: &EvalCtx, layer: &Layer, g: &PropGroup) -> FillRule {
if ctx.e(layer, g, "rule") == 1 { FillRule::EvenOdd } else { FillRule::NonZero }
}
/// Collect draws and accumulated paths of a contents group (in group space).
fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup) -> (Vec<Draw>, Vec<BezPath>) {
let mut draws: Vec<Draw> = Vec::new();
let mut acc: Vec<BezPath> = Vec::new();
for node in &contents.children {
let Node::Group(g) = node else { continue };
if !g.enabled {
continue;
}
match g.match_id.as_str() {
"rect" => {
let size = ctx.v2(layer, g, "size", [100.0; 2]);
let pos = ctx.v2(layer, g, "position", [0.0; 2]);
acc.push(effectcraft_path::rect(size, pos, ctx.f(layer, g, "roundness", 0.0)));
}
"ellipse" => {
let size = ctx.v2(layer, g, "size", [100.0; 2]);
let pos = ctx.v2(layer, g, "position", [0.0; 2]);
acc.push(effectcraft_path::ellipse(size, pos));
}
"star" => {
let star = ctx.e(layer, g, "type") == 0;
acc.push(effectcraft_path::polystar(
star,
ctx.f(layer, g, "points", 5.0),
ctx.v2(layer, g, "position", [0.0; 2]),
ctx.f(layer, g, "rotation", 0.0),
ctx.f(layer, g, "innerRadius", 50.0),
ctx.f(layer, g, "outerRadius", 100.0),
ctx.f(layer, g, "innerRoundness", 0.0),
ctx.f(layer, g, "outerRoundness", 0.0),
));
}
"path" => {
if let Some(Value::Path(p)) = ctx.group_value(layer, g, "path") {
acc.push(effectcraft_path::to_kurbo(&p));
}
}
"fill" => {
let mut c = ctx.color(layer, g, "color");
c[3] = 1.0;
draws.push(Draw { paint: Paint::Fill { color: c, rule: rule(ctx, layer, g) }, paths: acc.clone(), xf: Mat3::IDENTITY, opacity: (ctx.f(layer, g, "opacity", 100.0) / 100.0) as f32 });
}
"stroke" => {
let mut c = ctx.color(layer, g, "color");
c[3] = 1.0;
let dash = g.sub("dashes").and_then(|d| {
let dash = ctx.f(layer, d, "dash", 0.0);
let gap = ctx.f(layer, d, "gap", 0.0);
(dash > 0.0).then(|| (vec![dash, if gap > 0.0 { gap } else { dash }], ctx.f(layer, d, "offset", 0.0)))
});
let style = StrokeStyle {
width: ctx.f(layer, g, "width", 2.0),
cap: [Cap::Butt, Cap::Round, Cap::Square][ctx.e(layer, g, "cap").min(2) as usize],
join: [Join::Miter, Join::Round, Join::Bevel][ctx.e(layer, g, "join").min(2) as usize],
miter: ctx.f(layer, g, "miter", 4.0),
dash,
};
draws.push(Draw { paint: Paint::Stroke { color: c, style }, paths: acc.clone(), xf: Mat3::IDENTITY, opacity: (ctx.f(layer, g, "opacity", 100.0) / 100.0) as f32 });
}
"gfill" => {
let gr = match ctx.group_value(layer, g, "colors") {
Some(Value::Gradient(gr)) => gr,
_ => Gradient::default(),
};
draws.push(Draw {
paint: Paint::Gradient {
g: gr,
radial: ctx.e(layer, g, "type") == 1,
start: ctx.v2(layer, g, "start", [0.0; 2]),
end: ctx.v2(layer, g, "end", [100.0, 0.0]),
rule: rule(ctx, layer, g),
},
paths: acc.clone(),
xf: Mat3::IDENTITY,
opacity: (ctx.f(layer, g, "opacity", 100.0) / 100.0) as f32,
});
}
"trim" => {
let (s, e, o) = (ctx.f(layer, g, "start", 0.0), ctx.f(layer, g, "end", 100.0), ctx.f(layer, g, "offset", 0.0));
acc = effectcraft_path::ops::trim(&acc, s, e, o);
for d in &mut draws {
// Paint items above the trim are not affected (AE semantics).
let _ = d;
}
}
"pucker" => {
acc = effectcraft_path::ops::pucker_bloat(&acc, ctx.f(layer, g, "amount", 0.0));
}
"group" => {
let Some(inner) = g.sub("contents") else { continue };
let (cd, cp) = collect(ctx, layer, inner);
let (m, op) = g.sub("transform").map(|t| group_matrix(ctx, layer, t)).unwrap_or((Mat3::IDENTITY, 1.0));
for mut d in cd {
d.paths = mat_paths(&d.paths, &m);
d.xf = m * d.xf;
d.opacity *= op;
draws.push(d);
}
acc.extend(mat_paths(&cp, &m));
}
"repeater" => {
let copies = ctx.f(layer, g, "copies", 3.0).max(0.0);
let offset = ctx.f(layer, g, "offset", 0.0);
let n = copies.ceil() as usize;
let Some(tr) = g.sub("transform") else { continue };
let anchor = ctx.v2(layer, tr, "anchor", [0.0; 2]);
let pos = ctx.v2(layer, tr, "position", [100.0, 0.0]);
let scale = ctx.v2(layer, tr, "scale", [100.0; 2]);
let rot = ctx.f(layer, tr, "rotation", 0.0);
let so = (ctx.f(layer, tr, "startOpacity", 100.0) / 100.0) as f32;
let eo = (ctx.f(layer, tr, "endOpacity", 100.0) / 100.0) as f32;
let step = |k: f64| {
Mat3::translate(vec2(pos[0] * k, pos[1] * k))
* Mat3::translate(vec2(anchor[0], anchor[1]))
* Mat3::rotate_deg(rot * k)
* Mat3::scale(vec2((scale[0] / 100.0).powf(k), (scale[1] / 100.0).powf(k)))
* Mat3::translate(vec2(-anchor[0], -anchor[1]))
};
let base_draws = std::mem::take(&mut draws);
let base_acc = std::mem::take(&mut acc);
let above = ctx.e(layer, g, "composite") == 1;
let mut order: Vec<usize> = (0..n).collect();
if !above {
order.reverse();
}
for i in order {
let k = i as f64 + offset;
let m = step(k);
let t = if n > 1 { i as f32 / (n - 1) as f32 } else { 0.0 };
let opk = so + (eo - so) * t;
let partial = if i + 1 == n && copies.fract() > 0.0 { copies.fract() as f32 } else { 1.0 };
for d in &base_draws {
let mut d = d.clone();
d.paths = mat_paths(&d.paths, &m);
d.xf = m * d.xf;
d.opacity *= opk * partial;
draws.push(d);
}
acc.extend(mat_paths(&base_acc, &m));
}
}
_ => {}
}
}
(draws, acc)
}
fn composite_coverage(img: &mut Image, cov: &Mask, color: [f32; 4], opacity: f32) {
img.data.par_iter_mut().zip(cov.data.par_iter()).for_each(|(px, &c)| {
let a = c * opacity * color[3];
if a <= 0.0 {
return;
}
let k = 1.0 - a;
px[0] = color[0] * a + px[0] * k;
px[1] = color[1] * a + px[1] * k;
px[2] = color[2] * a + px[2] * k;
px[3] = a + px[3] * k;
});
}
/// Render a shape layer's contents into a layer-space buffer at scale `s`.
pub fn render(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, s: f64) -> Buf {
let (draws, _) = collect(ctx, layer, contents);
let mut bounds: Option<kurbo::Rect> = None;
for d in &draws {
if let Some(b) = effectcraft_path::bounds(&d.paths) {
let grow = match &d.paint {
Paint::Stroke { style, .. } => style.width * style.miter.max(1.0) * 0.5 * d.xf.mean_scale().max(1.0) + 1.0,
_ => 1.0,
};
let b = b.inflate(grow, grow);
bounds = Some(bounds.map_or(b, |a| a.union(b)));
}
}
let Some(b) = bounds else { return Buf { img: Image::new(1, 1), offset: [0.0, 0.0], scale: s } };
// Keep buffers sane for runaway geometry.
let b = b.intersect(kurbo::Rect::new(-20000.0, -20000.0, 20000.0, 20000.0));
let w = ((b.width() * s).ceil() as u32 + 4).clamp(1, 16384);
let h = ((b.height() * s).ceil() as u32 + 4).clamp(1, 16384);
let offset = [-b.x0 * s + 2.0, -b.y0 * s + 2.0];
let m = Mat3::translate(vec2(offset[0], offset[1])) * Mat3::scale(vec2(s, s));
let mut img = Image::new(w, h);
for d in draws.iter().rev() {
if d.opacity <= 0.0 || d.paths.is_empty() {
continue;
}
match &d.paint {
Paint::Fill { color, rule } => {
let cov = effectcraft_path::fill_coverage(&d.paths, &m, w, h, *rule);
composite_coverage(&mut img, &cov, *color, d.opacity);
}
Paint::Stroke { color, style } => {
// Strokes scale with their group transform.
let mut st = style.clone();
st.width *= d.xf.mean_scale();
let cov = effectcraft_path::stroke_coverage(&d.paths, &st, &m, w, h);
composite_coverage(&mut img, &cov, *color, d.opacity);
}
Paint::Gradient { g, radial, start, end, rule } => {
let cov = effectcraft_path::fill_coverage(&d.paths, &m, w, h, *rule);
let to_local = (m * d.xf).inverse().unwrap_or(Mat3::IDENTITY);
let (sx, sy) = (start[0], start[1]);
let (dx, dy) = (end[0] - sx, end[1] - sy);
let len2 = (dx * dx + dy * dy).max(1e-9);
let op = d.opacity;
img.data.par_chunks_mut(w as usize).zip(cov.data.par_chunks(w as usize)).enumerate().for_each(|(y, (row, crow))| {
for x in 0..row.len() {
let c = crow[x];
if c <= 0.0 {
continue;
}
let p = to_local.apply(vec2(x as f64 + 0.5, y as f64 + 0.5));
let (vx, vy) = (p.x - sx, p.y - sy);
let t = if *radial { ((vx * vx + vy * vy) / len2).sqrt() } else { (vx * dx + vy * dy) / len2 };
let col = g.sample(t);
let a = c * op * col[3];
let k = 1.0 - a;
let px = &mut row[x];
px[0] = col[0] * a + px[0] * k;
px[1] = col[1] * a + px[1] * k;
px[2] = col[2] * a + px[2] * k;
px[3] = a + px[3] * k;
}
});
}
}
}
Buf { img, offset, scale: s }
}
+162
View File
@@ -0,0 +1,162 @@
use effectcraft_color::{BlendMode, Label};
use effectcraft_keyframe::{Keyframe, ShapePath, TextDoc, Value};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, MaskMode, MatteKind, Project, Solid, TrackMatte};
use effectcraft_time::{FrameRate, Tick};
use crate::render_frame;
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) -> effectcraft_project::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)
}
#[test]
fn solid_fills_comp() {
let (mut p, cid, comp) = setup();
let l = solid(&mut p, &comp, [1.0, 0.0, 0.0], 200, 100);
p.comp_mut(cid).unwrap().layers.push(l);
let img = render_frame(&p, cid, Tick::ZERO, 1.0);
assert_eq!(img.get(10, 10), [1.0, 0.0, 0.0, 1.0]);
assert_eq!(img.get(199, 99), [1.0, 0.0, 0.0, 1.0]);
}
#[test]
fn position_keyframes_move_layer() {
let (mut p, cid, comp) = setup();
let mut l = solid(&mut p, &comp, [0.0, 1.0, 0.0], 20, 20);
let pos = l.props.prop_mut("transform/position").unwrap();
pos.keys = vec![Keyframe::new(Tick::ZERO, Value::Vec3([20.0, 50.0, 0.0])), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Vec3([180.0, 50.0, 0.0]))];
p.comp_mut(cid).unwrap().layers.push(l);
let a = render_frame(&p, cid, Tick::ZERO, 1.0);
assert!(a.get(20, 50)[3] > 0.99 && a.get(100, 50)[3] < 0.01);
let b = render_frame(&p, cid, Tick::from_seconds_f64(0.5), 1.0);
assert!(b.get(100, 50)[3] > 0.99 && b.get(20, 50)[3] < 0.01);
}
#[test]
fn opacity_and_blend_modes() {
let (mut p, cid, comp) = setup();
let bottom = solid(&mut p, &comp, [0.5, 0.5, 0.5], 200, 100);
let mut top = solid(&mut p, &comp, [0.5, 0.5, 0.5], 200, 100);
top.blend_mode = BlendMode::Multiply;
let c = p.comp_mut(cid).unwrap();
c.layers.push(top);
c.layers.push(bottom);
let img = render_frame(&p, cid, Tick::ZERO, 1.0);
assert!((img.get(50, 50)[0] - 0.25).abs() < 1e-4);
}
#[test]
fn half_resolution() {
let (mut p, cid, comp) = setup();
let l = solid(&mut p, &comp, [1.0, 1.0, 1.0], 200, 100);
p.comp_mut(cid).unwrap().layers.push(l);
let img = render_frame(&p, cid, Tick::ZERO, 0.5);
assert_eq!((img.width, img.height), (100, 50));
assert!((img.get(50, 25)[3] - 1.0).abs() < 1e-4);
}
#[test]
fn mask_cuts_layer() {
let (mut p, cid, comp) = setup();
let mut l = solid(&mut p, &comp, [1.0, 1.0, 1.0], 200, 100);
let mut next = p.next_id;
let m = build::mask(&mut Ids(&mut next), "Mask 1", ShapePath::rect([50.0, 50.0], 40.0, 40.0), MaskMode::Add, [255, 255, 0]);
p.next_id = next;
l.props.sub_mut("masks").unwrap().children.push(m.into());
p.comp_mut(cid).unwrap().layers.push(l);
let img = render_frame(&p, cid, Tick::ZERO, 1.0);
assert!(img.get(50, 50)[3] > 0.99);
assert!(img.get(150, 50)[3] < 0.01);
}
#[test]
fn track_matte_alpha() {
let (mut p, cid, comp) = setup();
let mut matte = solid(&mut p, &comp, [1.0, 1.0, 1.0], 50, 50);
matte.props.prop_mut("transform/position").unwrap().value = Value::Vec3([50.0, 50.0, 0.0]);
matte.switches.video = false;
let mut fill = solid(&mut p, &comp, [0.0, 0.0, 1.0], 200, 100);
fill.track_matte = Some(TrackMatte { layer: matte.id, kind: MatteKind::Alpha });
let c = p.comp_mut(cid).unwrap();
c.layers.push(matte);
c.layers.push(fill);
let img = render_frame(&p, cid, Tick::ZERO, 1.0);
assert!(img.get(50, 50)[2] > 0.99);
assert!(img.get(150, 50)[3] < 0.01);
}
#[test]
fn shape_layer_draws_fill_and_stroke() {
let (mut p, cid, comp) = setup();
let mut l = build::layer(&mut p, &comp, "Shape Layer 1", LayerSource::Shape, (200, 100), None);
let mut next = p.next_id;
let mut ids = Ids(&mut next);
let rect = build::shape_rect(&mut ids, [40.0, 40.0], [0.0, 0.0], 0.0);
let fill = build::shape_fill(&mut ids, [1.0, 0.0, 0.0, 1.0]);
let stroke = build::shape_stroke(&mut ids, [1.0, 1.0, 1.0, 1.0], 4.0);
let g = build::shape_group(&mut ids, "Rectangle 1", vec![rect, stroke, fill]);
p.next_id = next;
l.props.sub_mut("contents").unwrap().children.push(g.into());
p.comp_mut(cid).unwrap().layers.push(l);
let img = render_frame(&p, cid, Tick::ZERO, 1.0);
let centre = img.get(100, 50);
assert!(centre[0] > 0.99 && centre[1] < 0.01, "{centre:?}");
let edge = img.get(80, 50);
assert!(edge[1] > 0.5, "stroke on top {edge:?}");
}
#[test]
fn text_layer_renders_glyphs() {
let (mut p, cid, comp) = setup();
let mut l = build::layer(&mut p, &comp, "Text", LayerSource::Text, (200, 100), None);
l.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "HI".into(), size: 60.0, ..Default::default() }));
l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([60.0, 80.0, 0.0]);
p.comp_mut(cid).unwrap().layers.push(l);
let img = render_frame(&p, cid, Tick::ZERO, 1.0);
let covered = img.data.iter().filter(|px| px[3] > 0.5).count();
assert!(covered > 300, "{covered}");
}
#[test]
fn precomp_and_3d_render() {
let (mut p, cid, comp) = setup();
let inner = Comp::new(100, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
let iid = p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.clone().into()));
let s = solid(&mut p, &inner, [1.0, 0.5, 0.0], 100, 100);
p.comp_mut(iid).unwrap().layers.push(s);
let mut pre = build::layer(&mut p, &comp, "Inner", LayerSource::Comp { item: iid }, (100, 100), None);
pre.switches.three_d = true;
p.comp_mut(cid).unwrap().layers.push(pre);
let img = render_frame(&p, cid, Tick::ZERO, 1.0);
// A 3D layer at z = 0 with the default camera looks exactly like 2D.
let c = img.get(100, 50);
assert!((c[0] - 1.0).abs() < 1e-3 && (c[1] - 0.5).abs() < 1e-3, "{c:?}");
assert!(img.get(10, 50)[3] < 0.01);
}
#[test]
fn effects_run_in_pipeline() {
let (mut p, cid, comp) = setup();
let mut l = solid(&mut p, &comp, [1.0, 1.0, 1.0], 50, 50);
let spec = effectcraft_effects::find("ec.color.tint").unwrap();
let mut next = p.next_id;
let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), "Tint", [50.0, 50.0]);
p.next_id = next;
if let Some(pr) = g.prop_mut("white") {
pr.value = Value::Color([0.0, 1.0, 0.0, 1.0]);
}
l.props.sub_mut("effects").unwrap().children.push(g.into());
p.comp_mut(cid).unwrap().layers.push(l);
let img = render_frame(&p, cid, Tick::ZERO, 1.0);
let c = img.get(100, 50);
assert!(c[1] > 0.99 && c[0] < 0.01, "{c:?}");
}
+276
View File
@@ -0,0 +1,276 @@
//! Text layers: Source Text layout + text animators (range selectors) → per-character outlines.
use effectcraft_effects::Buf;
use effectcraft_geom::{Mat3, vec2};
use effectcraft_keyframe::{TextDoc, Value};
use effectcraft_path::{BezPath, FillRule, StrokeStyle};
use effectcraft_project::{Layer, PropGroup};
use effectcraft_raster::Image;
use effectcraft_text::{TextLayout, layout_doc};
use crate::eval::EvalCtx;
/// Evaluated per-character animation state.
#[derive(Clone, Copy, Debug)]
pub struct CharXf {
pub offset: [f64; 2],
pub anchor: [f64; 2],
pub scale: [f64; 2],
pub rotation: f64,
pub skew: f64,
pub opacity: f64,
pub fill: Option<[f32; 4]>,
pub fill_k: f32,
pub stroke: Option<[f32; 4]>,
pub stroke_k: f32,
pub stroke_width: f64,
pub tracking: f64,
}
impl Default for CharXf {
fn default() -> Self {
CharXf { offset: [0.0; 2], anchor: [0.0; 2], scale: [100.0; 2], rotation: 0.0, skew: 0.0, opacity: 100.0, fill: None, fill_k: 0.0, stroke: None, stroke_k: 0.0, stroke_width: 0.0, tracking: 0.0 }
}
}
fn shape_value(shape: u32, f: f64) -> f64 {
let f = f.clamp(0.0, 1.0);
match shape {
1 => f, // ramp up
2 => 1.0 - f, // ramp down
3 => 1.0 - (2.0 * f - 1.0).abs(), // triangle
4 => (1.0 - (2.0 * f - 1.0).powi(2)).sqrt(), // round
5 => {
let t = 1.0 - (2.0 * f - 1.0).abs();
t * t * (3.0 - 2.0 * t)
} // smooth
_ => 1.0,
}
}
/// Selection amount (0..1) of unit `i` of `n` for a range selector.
fn range_amount(ctx: &EvalCtx, layer: &Layer, sel: &PropGroup, i: usize, n: usize) -> f64 {
let adv = sel.sub("advanced");
let units_index = adv.map(|a| ctx.e(layer, a, "units") == 1).unwrap_or(false);
let shape = adv.map(|a| ctx.e(layer, a, "shape")).unwrap_or(0);
let amount = adv.map(|a| ctx.f(layer, a, "amount", 100.0)).unwrap_or(100.0) / 100.0;
let n = n.max(1) as f64;
let (mut s, mut e, o) = if units_index {
(ctx.f(layer, sel, "start", 0.0) / n, ctx.f(layer, sel, "end", n) / n, ctx.f(layer, sel, "offset", 0.0) / n)
} else {
(ctx.f(layer, sel, "start", 0.0) / 100.0, ctx.f(layer, sel, "end", 100.0) / 100.0, ctx.f(layer, sel, "offset", 0.0) / 100.0)
};
if s > e {
std::mem::swap(&mut s, &mut e);
}
s += o;
e += o;
let (a, b) = (i as f64 / n, (i as f64 + 1.0) / n);
let v = if shape == 0 {
// Square: the fraction of this unit covered by the range (smooth partial selection).
((b.min(e) - a.max(s)) / (b - a)).clamp(0.0, 1.0)
} else {
if e - s <= 1e-9 {
return 0.0;
}
let c = (a + b) * 0.5;
if c < s || c > e { 0.0 } else { shape_value(shape, (c - s) / (e - s)) }
};
v * amount
}
/// Evaluate animators for every glyph.
pub fn char_transforms(ctx: &EvalCtx, layer: &Layer, text: &PropGroup, lay: &TextLayout) -> Vec<CharXf> {
let mut out = vec![CharXf::default(); lay.glyphs.len()];
let Some(anims) = text.sub("animators") else { return out };
for anim in anims.groups().filter(|g| g.enabled) {
let Some(props) = anim.sub("properties") else { continue };
let sels: Vec<&PropGroup> = anim.sub("selectors").map(|s| s.groups().filter(|g| g.enabled).collect()).unwrap_or_default();
let get = |m: &str| props.get(m).map(|p| ctx.value(layer, p));
let pos = get("position").map(|v| v.as_vec2());
let anchor = get("anchor").map(|v| v.as_vec2());
let scale = get("scale").map(|v| v.as_vec2());
let rot = get("rotation").map(|v| v.as_f64());
let skew = get("skew").map(|v| v.as_f64());
let op = get("opacity").map(|v| v.as_f64());
let fill = get("fillColor").map(|v| v.as_color());
let stroke = get("strokeColor").map(|v| v.as_color());
let sw = get("strokeWidth").map(|v| v.as_f64());
let tracking = get("tracking").map(|v| v.as_f64());
for (gi, g) in lay.glyphs.iter().enumerate() {
let mut k = 1.0;
for sel in &sels {
let based = sel.sub("advanced").map(|a| ctx.e(layer, a, "basedOn")).unwrap_or(0);
let (i, n) = match based {
1 => {
if g.is_space {
k = 0.0;
continue;
}
(g.char_index_no_space, lay.chars_no_space)
}
2 => (g.word_index, lay.words),
3 => (g.line_index, lay.lines),
_ => (g.char_index, lay.chars),
};
k *= range_amount(ctx, layer, sel, i, n);
}
if k <= 0.0 {
continue;
}
let c = &mut out[gi];
if let Some(p) = pos {
c.offset[0] += p[0] * k;
c.offset[1] += p[1] * k;
}
if let Some(a) = anchor {
c.anchor[0] += a[0] * k;
c.anchor[1] += a[1] * k;
}
if let Some(s) = scale {
c.scale[0] *= 1.0 + (s[0] / 100.0 - 1.0) * k;
c.scale[1] *= 1.0 + (s[1] / 100.0 - 1.0) * k;
}
if let Some(r) = rot {
c.rotation += r * k;
}
if let Some(s) = skew {
c.skew += s * k;
}
if let Some(o) = op {
c.opacity *= 1.0 + (o / 100.0 - 1.0) * k;
}
if let Some(f) = fill {
c.fill = Some(f);
c.fill_k = (c.fill_k + k as f32).min(1.0);
}
if let Some(f) = stroke {
c.stroke = Some(f);
c.stroke_k = (c.stroke_k + k as f32).min(1.0);
}
if let Some(w) = sw {
c.stroke_width += w * k;
}
if let Some(t) = tracking {
c.tracking += t * k;
}
}
}
out
}
/// The Source Text value at the current time.
pub fn source_text(ctx: &EvalCtx, layer: &Layer) -> Option<TextDoc> {
let text = layer.props.sub("text")?;
match ctx.group_value(layer, text, "sourceText")? {
Value::Text(t) => Some(*t),
Value::Str(s) => Some(TextDoc { text: s, ..Default::default() }),
_ => None,
}
}
/// Outlines of all glyphs in layer space with their paint (for text → shapes and hit testing).
pub fn glyph_paths(ctx: &EvalCtx, layer: &Layer) -> Vec<(BezPath, CharXf)> {
let Some(doc) = source_text(ctx, layer) else { return vec![] };
let Some(text) = layer.props.sub("text") else { return vec![] };
let lay = layout_doc(&doc);
let xfs = char_transforms(ctx, layer, text, &lay);
let mut track = 0.0;
let mut out = Vec::with_capacity(lay.glyphs.len());
let mut last_line = usize::MAX;
for (g, x) in lay.glyphs.iter().zip(&xfs) {
if g.line_index != last_line {
track = 0.0;
last_line = g.line_index;
}
let pivot = [g.origin.x + g.advance / 2.0 + track, g.origin.y];
track += x.tracking / 1000.0 * doc.size;
if g.is_space || g.path.elements().is_empty() {
continue;
}
let m = Mat3::translate(vec2(pivot[0] + x.offset[0], pivot[1] + x.offset[1]))
* Mat3::rotate_deg(x.rotation)
* Mat3::skew_deg(-x.skew, 0.0)
* Mat3::scale(vec2(x.scale[0] / 100.0, x.scale[1] / 100.0))
* Mat3::translate(vec2(-g.advance / 2.0 - x.anchor[0], -x.anchor[1]));
out.push((effectcraft_path::transform(std::slice::from_ref(&g.path), &m).remove(0), *x));
}
out
}
fn lerp4(a: [f32; 4], b: [f32; 4], k: f32) -> [f32; 4] {
[a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k, a[2] + (b[2] - a[2]) * k, a[3] + (b[3] - a[3]) * k]
}
/// Render a text layer into a layer-space buffer at scale `s`.
pub fn render(ctx: &EvalCtx, layer: &Layer, s: f64) -> Buf {
let Some(doc) = source_text(ctx, layer) else { return Buf { img: Image::new(1, 1), offset: [0.0; 2], scale: s } };
let glyphs = glyph_paths(ctx, layer);
let stroke_w = if doc.apply_stroke { doc.stroke_width } else { 0.0 };
let mut bounds: Option<kurbo::Rect> = None;
for (p, x) in &glyphs {
if let Some(b) = effectcraft_path::bounds(std::slice::from_ref(p)) {
let b = b.inflate(stroke_w + x.stroke_width + 2.0, stroke_w + x.stroke_width + 2.0);
bounds = Some(bounds.map_or(b, |a| a.union(b)));
}
}
let Some(b) = bounds else { return Buf { img: Image::new(1, 1), offset: [0.0; 2], scale: s } };
let b = b.intersect(kurbo::Rect::new(-20000.0, -20000.0, 20000.0, 20000.0));
let w = ((b.width() * s).ceil() as u32 + 4).clamp(1, 16384);
let h = ((b.height() * s).ceil() as u32 + 4).clamp(1, 16384);
let offset = [-b.x0 * s + 2.0, -b.y0 * s + 2.0];
let mut img = Image::new(w, h);
let base = Mat3::translate(vec2(offset[0], offset[1])) * Mat3::scale(vec2(s, s));
for (p, x) in &glyphs {
let op = (x.opacity / 100.0).clamp(0.0, 1.0) as f32;
if op <= 0.0 {
continue;
}
let Some(gb) = effectcraft_path::bounds(std::slice::from_ref(p)) else { continue };
let sw = stroke_w + x.stroke_width;
let gb = gb.inflate(sw + 1.0, sw + 1.0);
let x0 = ((gb.x0 * s + offset[0]).floor() as i64).max(0);
let y0 = ((gb.y0 * s + offset[1]).floor() as i64).max(0);
let x1 = ((gb.x1 * s + offset[0]).ceil() as i64).min(w as i64);
let y1 = ((gb.y1 * s + offset[1]).ceil() as i64).min(h as i64);
if x1 <= x0 || y1 <= y0 {
continue;
}
let (gw, gh) = ((x1 - x0) as u32, (y1 - y0) as u32);
let m = Mat3::translate(vec2(-x0 as f64, -y0 as f64)) * base;
let fill_col = x.fill.map(|f| lerp4(doc.fill, f, x.fill_k)).unwrap_or(doc.fill);
let stroke_col = x.stroke.map(|f| lerp4(doc.stroke, f, x.stroke_k)).unwrap_or(doc.stroke);
let mut layers: Vec<(effectcraft_raster::Mask, [f32; 4])> = Vec::new();
let fill_cov = doc.apply_fill.then(|| (effectcraft_path::fill_coverage(std::slice::from_ref(p), &m, gw, gh, FillRule::NonZero), fill_col));
let stroke_cov = (sw > 0.0).then(|| {
let st = StrokeStyle { width: sw, join: effectcraft_path::Join::Round, ..Default::default() };
(effectcraft_path::stroke_coverage(std::slice::from_ref(p), &st, &m, gw, gh), stroke_col)
});
if doc.stroke_over_fill {
layers.extend(fill_cov);
layers.extend(stroke_cov);
} else {
layers.extend(stroke_cov);
layers.extend(fill_cov);
}
for (cov, col) in layers {
for yy in 0..gh as usize {
let row = (y0 as usize + yy) * w as usize + x0 as usize;
for xx in 0..gw as usize {
let c = cov.data[yy * gw as usize + xx];
if c <= 0.0 {
continue;
}
let a = c * op * col[3];
let px = &mut img.data[row + xx];
let k = 1.0 - a;
px[0] = col[0] * a + px[0] * k;
px[1] = col[1] * a + px[1] * k;
px[2] = col[2] * a + px[2] * k;
px[3] = a + px[3] * k;
}
}
}
}
Buf { img, offset, scale: s }
}
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "effectcraft-text"
description = "Text engine: fonts (bundled OFL + system), shaping (harfrust), bidi, line breaking, paragraph layout and glyph outlines as Bezier paths"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[lints]
workspace = true
[dependencies]
effectcraft-keyframe = { workspace = true }
kurbo = { workspace = true }
# Font tables, glyph outlines and metrics (MIT OR Apache-2.0).
skrifa = { workspace = true }
# OpenType shaping (HarfBuzz port on read-fonts, MIT).
harfrust = { workspace = true }
# Unicode Bidirectional Algorithm (MIT OR Apache-2.0).
unicode-bidi = "0.3.18"
# UAX #14 line breaking (Apache-2.0).
unicode-linebreak = "0.1.5"
+474
View File
@@ -0,0 +1,474 @@
//! The font database: bundled fonts (always available, also on the web) plus fonts discovered by
//! [`FontSource`]s (system font folders on native platforms).
//!
//! Faces are addressed by [`FaceId`] (an index that never changes for the life of the process).
//! [`resolve`] maps a family + style name (as stored in projects) to a face and the synthetic
//! bold/italic needed when the family lacks that style. Unknown families fall back to Inter.
use std::path::PathBuf;
use std::sync::{Arc, OnceLock, RwLock};
use skrifa::instance::{LocationRef, Size};
use skrifa::{FontRef, MetadataProvider};
use crate::sfnt::{FaceNames, read_faces, read_faces_bytes};
/// Bundled fonts (SIL OFL 1.1; see `assets/fonts/*.attribution` and ATTRIBUTION.md).
pub static INTER_REGULAR: &[u8] = include_bytes!("../../../assets/fonts/Inter-Regular.ttf");
pub static INTER_MEDIUM: &[u8] = include_bytes!("../../../assets/fonts/Inter-Medium.ttf");
pub static INTER_SEMIBOLD: &[u8] = include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf");
pub static INTER_BOLD: &[u8] = include_bytes!("../../../assets/fonts/Inter-Bold.ttf");
pub static INTER_ITALIC: &[u8] = include_bytes!("../../../assets/fonts/Inter-Italic.ttf");
pub static JETBRAINS_MONO_REGULAR: &[u8] = include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf");
pub static NOTO_SERIF_REGULAR: &[u8] = include_bytes!("../../../assets/fonts/NotoSerif-Regular.ttf");
/// The default family for new text.
pub const DEFAULT_FAMILY: &str = "Inter";
/// Index of a face in the database.
pub type FaceId = usize;
/// Where a face's bytes come from.
#[derive(Clone, Debug)]
pub enum FaceData {
Static(&'static [u8]),
Shared(Arc<Vec<u8>>),
/// A file read on first use.
File(PathBuf),
}
/// Description of a face offered by a [`FontSource`].
#[derive(Clone, Debug)]
pub struct FaceInfo {
pub family: String,
pub style: String,
pub weight: u16,
pub italic: bool,
pub index: u32,
pub data: FaceData,
/// "bundled", "system", …
pub origin: &'static str,
}
/// Something that can list fonts (system folders, a web font picker, a project's font folder…).
pub trait FontSource: Send + Sync {
fn faces(&self) -> Vec<FaceInfo>;
}
/// Scans font folders on disk (reading only name tables). Native only; on the web there are no
/// folders and the scan finds nothing.
pub struct DirectorySource {
pub dirs: Vec<PathBuf>,
}
impl DirectorySource {
/// The platform's standard font folders.
pub fn system() -> Self {
let mut dirs: Vec<PathBuf> = Vec::new();
let home = std::env::var_os("HOME").map(PathBuf::from);
if cfg!(target_os = "macos") {
dirs.extend(["/System/Library/Fonts", "/Library/Fonts"].map(PathBuf::from));
if let Some(h) = &home {
dirs.push(h.join("Library/Fonts"));
}
} else if cfg!(windows) {
let win = std::env::var_os("WINDIR").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("C:\\Windows"));
dirs.push(win.join("Fonts"));
if let Some(l) = std::env::var_os("LOCALAPPDATA") {
dirs.push(PathBuf::from(l).join("Microsoft\\Windows\\Fonts"));
}
} else if cfg!(unix) && !cfg!(target_arch = "wasm32") {
dirs.extend(["/usr/share/fonts", "/usr/local/share/fonts"].map(PathBuf::from));
if let Some(h) = &home {
dirs.push(h.join(".local/share/fonts"));
dirs.push(h.join(".fonts"));
}
}
Self { dirs }
}
}
fn walk(dir: &std::path::Path, depth: u32, out: &mut Vec<PathBuf>) {
let Ok(rd) = std::fs::read_dir(dir) else { return };
for e in rd.flatten() {
let p = e.path();
if p.is_dir() {
if depth < 4 {
walk(&p, depth + 1, out);
}
} else if p.extension().and_then(|x| x.to_str()).is_some_and(|x| matches!(x.to_ascii_lowercase().as_str(), "ttf" | "otf" | "ttc" | "otc")) {
out.push(p);
}
}
}
impl FontSource for DirectorySource {
fn faces(&self) -> Vec<FaceInfo> {
let mut files = Vec::new();
for d in &self.dirs {
walk(d, 0, &mut files);
}
files.sort();
let mut out = Vec::new();
for f in files {
let Ok(mut file) = std::fs::File::open(&f) else { continue };
for n in read_faces(&mut file) {
out.push(info_from(n, FaceData::File(f.clone()), "system"));
}
}
out
}
}
fn info_from(n: FaceNames, data: FaceData, origin: &'static str) -> FaceInfo {
FaceInfo { family: n.family, style: n.style, weight: n.weight, italic: n.italic, index: n.index, data, origin }
}
/// A loaded (or loadable) face.
pub struct Face {
pub id: FaceId,
pub info: FaceInfo,
bytes: OnceLock<Option<FaceBytes>>,
shaper: OnceLock<harfrust::ShaperData>,
}
#[derive(Clone)]
enum FaceBytes {
Static(&'static [u8]),
Shared(Arc<Vec<u8>>),
}
impl FaceBytes {
fn get(&self) -> &[u8] {
match self {
FaceBytes::Static(b) => b,
FaceBytes::Shared(b) => b,
}
}
}
/// Vertical metrics at a pixel size (all positive distances, y down from the baseline for the
/// underline position).
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct VMetrics {
pub ascent: f32,
pub descent: f32,
pub line_gap: f32,
pub cap_height: f32,
pub x_height: f32,
pub underline_pos: f32,
pub underline_thickness: f32,
}
impl Face {
fn bytes(&self) -> Option<&[u8]> {
self.bytes
.get_or_init(|| match &self.info.data {
FaceData::Static(b) => Some(FaceBytes::Static(b)),
FaceData::Shared(b) => Some(FaceBytes::Shared(b.clone())),
FaceData::File(p) => std::fs::read(p).ok().map(|v| FaceBytes::Shared(Arc::new(v))),
})
.as_ref()
.map(FaceBytes::get)
}
/// The parsed font (None if the file is missing or unreadable).
pub fn font(&self) -> Option<FontRef<'_>> {
FontRef::from_index(self.bytes()?, self.info.index).ok()
}
pub(crate) fn shaper_data(&self) -> Option<&harfrust::ShaperData> {
let f = self.font()?;
Some(self.shaper.get_or_init(|| harfrust::ShaperData::new(&f)))
}
pub fn units_per_em(&self) -> f32 {
self.font().map(|f| f.metrics(Size::unscaled(), LocationRef::default()).units_per_em as f32).filter(|u| *u > 0.0).unwrap_or(1000.0)
}
/// Glyph for a character (None when the face does not cover it).
pub fn glyph(&self, c: char) -> Option<u32> {
self.font()?.charmap().map(c).map(|g| g.to_u32()).filter(|g| *g != 0)
}
pub fn has_char(&self, c: char) -> bool {
self.glyph(c).is_some()
}
pub fn metrics(&self, px: f32) -> VMetrics {
let Some(f) = self.font() else {
return VMetrics {
ascent: px * 0.8,
descent: px * 0.2,
cap_height: px * 0.7,
x_height: px * 0.5,
underline_pos: px * 0.1,
underline_thickness: px * 0.06,
..Default::default()
};
};
let m = f.metrics(Size::new(px), LocationRef::default());
VMetrics {
ascent: m.ascent,
descent: -m.descent,
line_gap: m.leading,
cap_height: m.cap_height.unwrap_or(m.ascent * 0.7),
x_height: m.x_height.unwrap_or(m.ascent * 0.5),
underline_pos: m.underline.map_or(px * 0.1, |u| -u.offset),
underline_thickness: m.underline.map_or(px * 0.06, |u| u.thickness.max(px * 0.03)),
}
}
/// Whether the face's GSUB offers an OpenType feature (e.g. `smcp`).
pub fn has_feature(&self, tag: &[u8; 4]) -> bool {
use skrifa::raw::TableProvider;
let Some(f) = self.font() else { return false };
let Ok(gsub) = f.gsub() else { return false };
let Ok(list) = gsub.feature_list() else { return false };
list.feature_records().iter().any(|r| r.feature_tag().to_be_bytes() == *tag)
}
}
struct Db {
faces: Vec<Arc<Face>>,
scanned: bool,
}
fn db() -> &'static RwLock<Db> {
static DB: OnceLock<RwLock<Db>> = OnceLock::new();
DB.get_or_init(|| {
let mut db = Db { faces: Vec::new(), scanned: false };
for b in [INTER_REGULAR, INTER_MEDIUM, INTER_SEMIBOLD, INTER_BOLD, INTER_ITALIC, JETBRAINS_MONO_REGULAR, NOTO_SERIF_REGULAR] {
for n in read_faces_bytes(b) {
push(&mut db, info_from(n, FaceData::Static(b), "bundled"));
}
}
RwLock::new(db)
})
}
fn push(db: &mut Db, info: FaceInfo) -> FaceId {
let id = db.faces.len();
db.faces.push(Arc::new(Face { id, info, bytes: OnceLock::new(), shaper: OnceLock::new() }));
id
}
/// Add faces from a source (skipping family+style pairs already present). Returns how many were
/// added.
pub fn add_source(src: &dyn FontSource) -> usize {
let faces = src.faces();
let mut d = db().write().unwrap_or_else(|e| e.into_inner());
let mut n = 0;
for f in faces {
let dup = d.faces.iter().any(|g| g.info.family.eq_ignore_ascii_case(&f.family) && g.info.style.eq_ignore_ascii_case(&f.style));
if !dup {
push(&mut d, f);
n += 1;
}
}
n
}
/// Register a font from memory (e.g. a font file the user picked on the web). Returns its faces.
pub fn add_font_data(data: Vec<u8>) -> Vec<FaceId> {
let data = Arc::new(data);
let names = read_faces_bytes(&data);
let mut d = db().write().unwrap_or_else(|e| e.into_inner());
names.into_iter().map(|n| push(&mut d, info_from(n, FaceData::Shared(data.clone()), "user"))).collect()
}
/// Scan the system font folders once (native only; a no-op on wasm). Returns the number of faces
/// added by this call.
pub fn scan_system() -> usize {
// Held for the whole scan: a concurrent caller waits for the complete list instead of
// returning early and resolving against a half-filled database.
static SCAN: std::sync::Mutex<()> = std::sync::Mutex::new(());
let _scan = SCAN.lock().unwrap_or_else(|e| e.into_inner());
if system_scanned() {
return 0;
}
let n = if cfg!(target_arch = "wasm32") { 0 } else { add_source(&DirectorySource::system()) };
db().write().unwrap_or_else(|e| e.into_inner()).scanned = true;
n
}
pub fn system_scanned() -> bool {
db().read().unwrap_or_else(|e| e.into_inner()).scanned
}
/// A face by id (panics never: unknown ids give the default face).
pub fn face(id: FaceId) -> Arc<Face> {
let d = db().read().unwrap_or_else(|e| e.into_inner());
d.faces.get(id).or_else(|| d.faces.first()).cloned().expect("bundled fonts present")
}
/// All faces (bundled first).
pub fn all_faces() -> Vec<Arc<Face>> {
db().read().unwrap_or_else(|e| e.into_inner()).faces.clone()
}
/// Families with their style names, sorted by family; styles in weight order.
pub fn families() -> Vec<(String, Vec<String>)> {
let mut m: std::collections::BTreeMap<String, Vec<(bool, u16, String)>> = Default::default();
for f in all_faces() {
let v = m.entry(f.info.family.clone()).or_default();
if !v.iter().any(|(_, _, s)| s == &f.info.style) {
v.push((f.info.italic, f.info.weight, f.info.style.clone()));
}
}
m.into_iter()
.map(|(k, mut v)| {
v.sort();
(k, v.into_iter().map(|(_, _, s)| s).collect())
})
.collect()
}
/// The face chosen for a family/style request, plus synthetic emboldening / slant when the family
/// lacks a real bold / italic.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct Resolved {
pub face: FaceId,
pub synth_bold: bool,
pub synth_italic: bool,
/// The requested family was not found (shown as missing in the UI).
pub missing: bool,
}
fn style_wants(style: &str) -> (u16, bool) {
let s = style.to_ascii_lowercase().replace([' ', '-'], "");
let italic = s.contains("italic") || s.contains("oblique");
let w = if s.contains("thin") || s.contains("hairline") {
100
} else if s.contains("extralight") || s.contains("ultralight") {
200
} else if s.contains("light") {
300
} else if s.contains("medium") {
500
} else if s.contains("semibold") || s.contains("demibold") {
600
} else if s.contains("extrabold") || s.contains("ultrabold") {
800
} else if s.contains("black") || s.contains("heavy") {
900
} else if s.contains("bold") {
700
} else {
400
};
(w, italic)
}
/// Resolve a family + style name. Family matching is case-insensitive; a style that the family
/// lacks picks the nearest weight and synthesises bold (≥ 600 requested, ≤ 500 found) or italic.
pub fn resolve(family: &str, style: &str) -> Resolved {
if let Some(r) = resolve_in(family, style) {
return r;
}
if !system_scanned() && !family.is_empty() && !family.eq_ignore_ascii_case(DEFAULT_FAMILY) {
scan_system();
if let Some(r) = resolve_in(family, style) {
return r;
}
}
let mut r = resolve_in(DEFAULT_FAMILY, style).expect("Inter is bundled");
r.missing = !family.is_empty() && !family.eq_ignore_ascii_case(DEFAULT_FAMILY);
r
}
fn resolve_in(family: &str, style: &str) -> Option<Resolved> {
let faces = all_faces();
let fam: Vec<&Arc<Face>> = faces.iter().filter(|f| f.info.family.eq_ignore_ascii_case(family)).collect();
if fam.is_empty() {
return None;
}
if let Some(f) = fam.iter().find(|f| f.info.style.eq_ignore_ascii_case(style)) {
return Some(Resolved { face: f.id, synth_bold: false, synth_italic: false, missing: false });
}
let (w, it) = style_wants(style);
let best = fam
.iter()
.min_by_key(|f| {
let italic_pen = if f.info.italic == it { 0 } else { 1000 };
italic_pen + (f.info.weight as i32 - w as i32).unsigned_abs()
})
.expect("non-empty");
Some(Resolved { face: best.id, synth_bold: w >= 600 && best.info.weight <= 500, synth_italic: it && !best.info.italic, missing: false })
}
/// A face that covers `c`, preferring `prefer`; bundled and already-registered faces are searched.
pub fn fallback_for(c: char, prefer: FaceId) -> FaceId {
let p = face(prefer);
if p.has_char(c) || c.is_control() {
return prefer;
}
let faces = all_faces();
// prefer a face of the same italic-ness that is bundled (cheap), then anything loaded already
for f in faces.iter().filter(|f| f.info.origin == "bundled") {
if f.has_char(c) {
return f.id;
}
}
for f in faces.iter().filter(|f| f.info.origin != "bundled" && f.bytes.get().is_some()) {
if f.has_char(c) {
return f.id;
}
}
prefer
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bundled_families_and_resolution() {
let fams = families();
let inter = fams.iter().find(|(f, _)| f == "Inter").expect("Inter");
for s in ["Regular", "Medium", "SemiBold", "Bold", "Italic"] {
assert!(inter.1.iter().any(|x| x == s), "{s} in {:?}", inter.1);
}
assert!(fams.iter().any(|(f, _)| f == "Noto Serif"));
assert!(fams.iter().any(|(f, _)| f == "JetBrains Mono"));
let b = resolve("inter", "bold");
assert_eq!(face(b.face).info.style, "Bold");
assert!(!b.synth_bold);
// Noto Serif has only Regular: Bold Italic is synthesised
let s = resolve("Noto Serif", "Bold Italic");
assert_eq!(face(s.face).info.family, "Noto Serif");
assert!(s.synth_bold && s.synth_italic);
// Inter Bold Italic: nearest is Italic (400) → synth bold
let bi = resolve("Inter", "Bold Italic");
assert!(face(bi.face).info.italic && bi.synth_bold && !bi.synth_italic);
}
#[test]
fn missing_family_falls_back_to_inter() {
let r = resolve("No Such Font Family 123", "Regular");
assert_eq!(face(r.face).info.family, "Inter");
assert!(r.missing);
}
/// Native: the system scan reads name tables only and resolves families installed there.
#[test]
fn system_scan_finds_fonts() {
let t = std::time::Instant::now();
scan_system(); // (another test may have triggered the scan already)
let n = all_faces().iter().filter(|f| f.info.origin == "system").count();
eprintln!("system fonts: {n} faces in {:?}", t.elapsed());
assert_eq!(scan_system(), 0, "scans once");
if cfg!(target_os = "macos") {
assert!(n > 20, "{n}");
let r = resolve("Helvetica", "Bold");
assert_eq!(face(r.face).info.family, "Helvetica");
let l = crate::layout_text("Helvetica", &crate::TextStyle { family: "Helvetica".into(), size: 30.0, ..Default::default() }, &Default::default());
assert!(!l.missing_font && l.glyphs.iter().all(|g| g.id != 0));
}
}
#[test]
fn metrics_and_coverage() {
let f = face(resolve("Inter", "Regular").face);
let m = f.metrics(100.0);
assert!(m.ascent > 80.0 && m.ascent < 110.0, "{m:?}");
assert!(m.descent > 10.0 && m.descent < 40.0);
assert!(m.cap_height > 60.0 && m.cap_height < 80.0);
assert!(f.has_char('A') && f.has_char('Ж'));
assert!(!f.has_char('\u{5d0}'), "Inter has no Hebrew");
assert!(f.has_feature(b"liga") || f.has_feature(b"calt"));
}
}
+723
View File
@@ -0,0 +1,723 @@
//! Paragraph layout: font fallback, bidi (UAX #9), shaping (OpenType via harfrust: kerning,
//! ligatures, mark positioning, complex scripts), line breaking (UAX #14), alignment, leading,
//! tracking, baseline shift, all caps / small caps, underline. Results are cached.
//!
//! Coordinates are pixels, y down. For **point text** (`ParagraphStyle::width == None`) the
//! origin is the alignment point on the first baseline: x = 0 is the left edge (left / justify),
//! the centre (centre) or the right edge (right). For **area text** (`width == Some(w)`) the
//! origin is the top-left of the text box and lines wrap at `w`.
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::ops::Range;
use std::sync::{Arc, Mutex, OnceLock};
use harfrust::{Direction, Feature, Tag, UnicodeBuffer};
use unicode_bidi::{BidiInfo, Level};
use crate::fonts::{self, FaceId, Resolved};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum Caps {
#[default]
Normal,
All,
Small,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum Align {
#[default]
Left,
Center,
Right,
Justify,
}
/// Character formatting.
#[derive(Clone, Debug, PartialEq)]
pub struct TextStyle {
pub family: String,
pub style: String,
/// Font size in pixels.
pub size: f32,
/// Tracking in 1/1000 em (added after every cluster).
pub tracking: f32,
/// Metric kerning (`kern`).
pub kerning: bool,
/// Standard ligatures (`liga`, `clig`).
pub ligatures: bool,
/// Baseline shift in pixels (positive = up).
pub baseline_shift: f32,
pub faux_bold: bool,
pub faux_italic: bool,
pub caps: Caps,
pub underline: bool,
}
impl Default for TextStyle {
fn default() -> Self {
Self {
family: fonts::DEFAULT_FAMILY.into(),
style: "Regular".into(),
size: 100.0,
tracking: 0.0,
kerning: true,
ligatures: true,
baseline_shift: 0.0,
faux_bold: false,
faux_italic: false,
caps: Caps::Normal,
underline: false,
}
}
}
/// Paragraph formatting.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ParagraphStyle {
pub align: Align,
/// Extra line spacing in pixels added to the font's natural line height (Leading).
pub leading: f32,
/// Wrap width (area text); None = point text.
pub width: Option<f32>,
/// Base direction: None = from the first strong character.
pub rtl: Option<bool>,
}
fn hf(h: &mut impl Hasher, v: f32) {
v.to_bits().hash(h);
}
impl Hash for TextStyle {
fn hash<H: Hasher>(&self, h: &mut H) {
self.family.hash(h);
self.style.hash(h);
hf(h, self.size);
hf(h, self.tracking);
hf(h, self.baseline_shift);
(self.kerning, self.ligatures, self.faux_bold, self.faux_italic, self.caps, self.underline).hash(h);
}
}
impl Hash for ParagraphStyle {
fn hash<H: Hasher>(&self, h: &mut H) {
self.align.hash(h);
hf(h, self.leading);
self.width.map(f32::to_bits).hash(h);
self.rtl.hash(h);
}
}
/// A positioned glyph. `(x, y)` is the glyph origin on its baseline.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Glyph {
pub face: FaceId,
pub id: u32,
pub x: f32,
pub y: f32,
pub size: f32,
/// Byte offset of the cluster in the source text.
pub cluster: usize,
pub synth_bold: bool,
pub synth_italic: bool,
}
/// One laid-out line.
#[derive(Clone, Debug, PartialEq)]
pub struct Line {
/// Byte range in the source text (without the terminating newline).
pub range: Range<usize>,
pub baseline: f32,
/// Left edge and width of the line's content (trailing spaces excluded).
pub x: f32,
pub width: f32,
pub ascent: f32,
pub descent: f32,
pub glyphs: Range<usize>,
/// Caret stops `(byte offset, x)` for every character boundary in the line, by byte offset.
pub carets: Vec<(usize, f32)>,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Layout {
pub glyphs: Vec<Glyph>,
pub lines: Vec<Line>,
/// Underline rectangles `[x0, y0, x1, y1]`.
pub underlines: Vec<[f32; 4]>,
/// Logical bounds `[x0, y0, x1, y1]` (line boxes; area text spans the box width).
pub bounds: [f32; 4],
/// The face requested was missing (substituted).
pub missing_font: bool,
pub text_len: usize,
}
impl Layout {
/// Line index for a caret at byte `pos`.
pub fn line_of(&self, pos: usize) -> usize {
let mut li = 0;
for (i, l) in self.lines.iter().enumerate() {
if l.range.start <= pos {
li = i;
}
}
li
}
/// Caret position `(x, baseline, line)` for byte offset `pos`.
pub fn caret(&self, pos: usize) -> (f32, f32, usize) {
let li = self.line_of(pos);
let Some(l) = self.lines.get(li) else { return (0.0, 0.0, 0) };
let x = l.carets.iter().min_by_key(|(b, _)| b.abs_diff(pos)).map_or(l.x, |c| c.1);
(x, l.baseline, li)
}
/// Nearest caret byte offset to point `(x, y)`.
pub fn hit(&self, x: f32, y: f32) -> usize {
let Some(l) = self.lines.iter().min_by(|a, b| line_dist(a, y).total_cmp(&line_dist(b, y))) else {
return 0;
};
l.carets.iter().min_by(|a, b| (a.1 - x).abs().total_cmp(&(b.1 - x).abs())).map_or(l.range.start, |c| c.0)
}
/// Selection highlight rectangles `[x0, y0, x1, y1]` for the byte range `a..b`.
pub fn selection_rects(&self, a: usize, b: usize) -> Vec<[f32; 4]> {
let (a, b) = (a.min(b), a.max(b));
let mut out = Vec::new();
for l in &self.lines {
let s = a.max(l.range.start);
let e = b.min(l.range.end);
if s > e || (s == e && !(a < l.range.start && b > l.range.end)) {
continue;
}
let xs: Vec<f32> = l.carets.iter().filter(|(p, _)| *p >= s && *p <= e).map(|c| c.1).collect();
if xs.len() < 2 && b <= l.range.end {
continue;
}
let x0 = xs.iter().copied().fold(f32::MAX, f32::min);
let mut x1 = xs.iter().copied().fold(f32::MIN, f32::max);
if b > l.range.end {
x1 += l.ascent * 0.25; // selected newline
}
out.push([x0.min(x1), l.baseline - l.ascent, x1, l.baseline + l.descent]);
}
out
}
}
fn line_dist(l: &Line, y: f32) -> f32 {
let (t, b) = (l.baseline - l.ascent, l.baseline + l.descent);
if y < t {
t - y
} else if y > b {
y - b
} else {
0.0
}
}
struct ShapedGlyph {
face: FaceId,
id: u32,
cluster: usize,
adv: f32,
dx: f32,
dy: f32,
size: f32,
}
struct Item {
range: Range<usize>,
rtl: bool,
glyphs: Vec<ShapedGlyph>,
}
fn upper_single(c: char) -> char {
let mut u = c.to_uppercase();
match (u.next(), u.next()) {
(Some(x), None) => x,
_ => c,
}
}
const SMALL_CAPS_SCALE: f32 = 0.78;
fn shape_item(text: &str, chars: &[(usize, char, char)], rtl: bool, face: FaceId, size: f32, style: &TextStyle) -> Vec<ShapedGlyph> {
let f = fonts::face(face);
let (Some(font), Some(data)) = (f.font(), f.shaper_data()) else { return Vec::new() };
let shaper = data.shaper(&font).build();
let mut buf = UnicodeBuffer::new();
for &(b, _, sc) in chars {
buf.add(if sc == '\t' { ' ' } else { sc }, b as u32);
}
buf.set_direction(if rtl { Direction::RightToLeft } else { Direction::LeftToRight });
buf.guess_segment_properties();
let mut feats = Vec::new();
if !style.kerning {
feats.push(Feature::new(Tag::new(b"kern"), 0, ..));
}
if !style.ligatures {
feats.push(Feature::new(Tag::new(b"liga"), 0, ..));
feats.push(Feature::new(Tag::new(b"clig"), 0, ..));
}
let out = shaper.shape(buf, harfrust::ShapeOptions::new().features(&feats));
let k = size / f.units_per_em();
let _ = text;
out.glyph_infos()
.iter()
.zip(out.glyph_positions())
.map(|(i, p)| ShapedGlyph {
face,
id: i.glyph_id,
cluster: i.cluster as usize,
adv: p.x_advance as f32 * k,
dx: p.x_offset as f32 * k,
dy: p.y_offset as f32 * k,
size,
})
.collect()
}
struct ParaLine {
range: Range<usize>,
glyphs: Vec<Glyph>,
carets: Vec<(usize, f32)>,
content: f32,
left_trim: f32,
ascent: f32,
descent: f32,
}
/// Lay out one paragraph (no newlines) whose text starts at byte `base` of the whole string.
fn paragraph(text: &str, base: usize, style: &TextStyle, para: &ParagraphStyle, primary: Resolved) -> Vec<ParaLine> {
let pm = fonts::face(primary.face).metrics(style.size);
if text.is_empty() {
return vec![ParaLine {
range: base..base,
glyphs: vec![],
carets: vec![(base, 0.0)],
content: 0.0,
left_trim: 0.0,
ascent: pm.ascent,
descent: pm.descent,
}];
}
let default_level = para.rtl.map(|r| if r { Level::rtl() } else { Level::ltr() });
let bidi = BidiInfo::new(text, default_level);
let pinfo = &bidi.paragraphs[0];
let base_rtl = pinfo.level.is_rtl();
// per char: (byte, char, shaped char), face, level, small
let chars: Vec<(usize, char, char, FaceId, bool, bool)> = text
.char_indices()
.map(|(b, c)| {
let (sc, small) = match style.caps {
Caps::Normal => (c, false),
Caps::All => (upper_single(c), false),
Caps::Small if c.is_lowercase() => (upper_single(c), true),
Caps::Small => (c, false),
};
let face = if sc.is_whitespace() { primary.face } else { fonts::fallback_for(sc, primary.face) };
(b, c, sc, face, bidi.levels[b].is_rtl(), small)
})
.collect();
// items: runs of equal (face, rtl, small)
let mut items: Vec<Item> = Vec::new();
let mut i = 0;
while i < chars.len() {
let (face, rtl, small) = (chars[i].3, chars[i].4, chars[i].5);
let mut j = i + 1;
while j < chars.len() && chars[j].3 == face && chars[j].4 == rtl && chars[j].5 == small {
j += 1;
}
let sub: Vec<(usize, char, char)> = chars[i..j].iter().map(|c| (c.0, c.1, c.2)).collect();
let size = if small { style.size * SMALL_CAPS_SCALE } else { style.size };
let mut glyphs = shape_item(text, &sub, rtl, face, size, style);
// tracking after each cluster
if style.tracking != 0.0 {
let t = style.tracking * style.size / 1000.0;
for k in 0..glyphs.len() {
if k + 1 == glyphs.len() || glyphs[k + 1].cluster != glyphs[k].cluster {
glyphs[k].adv += t;
}
}
}
let end = if j < chars.len() { chars[j].0 } else { text.len() };
items.push(Item { range: chars[i].0..end, rtl, glyphs });
i = j;
}
// advance per char byte (cluster start)
let mut char_adv = vec![0.0f32; text.len() + 1];
for it in &items {
for g in &it.glyphs {
char_adv[g.cluster.min(text.len())] += g.adv;
}
}
let range_w = |r: Range<usize>| -> f32 { char_adv[r].iter().sum() };
let trailing_ws = |r: &Range<usize>| -> (usize, f32) {
let mut end = r.end;
let mut w = 0.0;
for (b, c) in text[r.clone()].char_indices().rev() {
if !c.is_whitespace() {
break;
}
end = r.start + b;
w += char_adv[r.start + b];
}
(end, w)
};
// line breaking
let mut ranges: Vec<Range<usize>> = Vec::new();
match para.width {
None => ranges.push(0..text.len()),
Some(maxw) => {
let mut start = 0usize;
let mut last_ok: Option<usize> = None;
for (p, _) in unicode_linebreak::linebreaks(text) {
loop {
let r = start..p;
let (ce, _) = trailing_ws(&r);
let w = range_w(start..ce);
if w <= maxw || last_ok.is_none() {
last_ok = Some(p);
break;
}
let b = last_ok.take().expect("checked");
ranges.push(start..b);
start = b;
}
}
ranges.push(start..text.len());
ranges.retain(|r| !r.is_empty());
if ranges.is_empty() {
ranges.push(0..text.len());
}
}
}
let nlines = ranges.len();
let mut out = Vec::new();
for (li, r) in ranges.into_iter().enumerate() {
let (ce, tw) = trailing_ws(&r);
let content = range_w(r.start..ce);
// justification
let mut space_extra = 0.0;
if para.align == Align::Justify
&& li + 1 < nlines
&& let Some(maxw) = para.width
{
let spaces = text[r.start..ce].chars().filter(|c| *c == ' ').count();
if spaces > 0 {
space_extra = ((maxw - content) / spaces as f32).max(0.0);
}
}
let (levels, runs) = bidi.visual_runs(pinfo, r.clone());
let mut pen = 0.0f32;
let mut glyphs = Vec::new();
let mut extents: HashMap<usize, (f32, f32, bool)> = HashMap::new();
for run in runs {
let rtl = levels[run.start].is_rtl();
let mut idx: Vec<usize> = (0..items.len()).filter(|&k| items[k].range.start < run.end && items[k].range.end > run.start).collect();
if rtl {
idx.reverse();
}
for k in idx {
for g in items[k].glyphs.iter().filter(|g| run.contains(&g.cluster)) {
let x0 = pen;
glyphs.push(Glyph {
face: g.face,
id: g.id,
x: pen + g.dx,
y: -g.dy - style.baseline_shift,
size: g.size,
cluster: base + g.cluster,
synth_bold: primary.synth_bold || style.faux_bold,
synth_italic: primary.synth_italic || style.faux_italic,
});
pen += g.adv;
if space_extra > 0.0 && text[g.cluster..].starts_with(' ') && g.cluster < ce {
pen += space_extra;
}
let e = extents.entry(g.cluster).or_insert((x0, pen, items[k].rtl));
e.0 = e.0.min(x0);
e.1 = e.1.max(pen);
}
}
}
// caret stops for each char boundary in the line
let mut carets = Vec::new();
let mut cluster_starts: Vec<usize> = extents.keys().copied().collect();
cluster_starts.sort_unstable();
let char_bytes: Vec<usize> = text[r.clone()].char_indices().map(|(b, _)| r.start + b).collect();
for (ci, &cs) in cluster_starts.iter().enumerate() {
let (x0, x1, rtl) = extents[&cs];
let next = cluster_starts.get(ci + 1).copied().unwrap_or(r.end);
let members: Vec<usize> = char_bytes.iter().copied().filter(|b| *b >= cs && *b < next).collect();
let n = members.len().max(1) as f32;
for (mi, b) in members.iter().enumerate() {
let f = mi as f32 / n;
let x = if rtl { x1 - (x1 - x0) * f } else { x0 + (x1 - x0) * f };
carets.push((base + b, x));
}
}
// end of line caret
let end_x = match chars.iter().rev().find(|c| c.0 < r.end && c.0 >= r.start) {
Some(c) => {
let cs = cluster_starts.iter().rev().find(|s| **s <= c.0).copied();
match cs.and_then(|s| extents.get(&s)) {
Some(&(x0, x1, rtl)) => {
if rtl {
x0
} else {
x1
}
}
None => pen,
}
}
None => 0.0,
};
carets.push((base + r.end, end_x));
carets.sort_by_key(|c| c.0);
carets.dedup_by_key(|c| c.0);
let mut asc = pm.ascent;
let mut desc = pm.descent;
for g in &glyphs {
if g.face != primary.face {
let m = fonts::face(g.face).metrics(g.size);
asc = asc.max(m.ascent);
desc = desc.max(m.descent);
}
}
let content_w = if space_extra > 0.0 { para.width.unwrap_or(content) } else { content };
out.push(ParaLine {
range: base + r.start..base + r.end,
glyphs,
carets,
content: content_w,
left_trim: if base_rtl { tw } else { 0.0 },
ascent: asc,
descent: desc,
});
}
out
}
/// Lay out `text` (paragraphs separated by `\n`).
pub fn layout_uncached(text: &str, style: &TextStyle, para: &ParagraphStyle) -> Layout {
let primary = fonts::resolve(&style.family, &style.style);
let pm = fonts::face(primary.face).metrics(style.size);
let line_h = (pm.ascent + pm.descent + pm.line_gap).max(style.size * 0.5) + para.leading;
let mut lay = Layout { missing_font: primary.missing, text_len: text.len(), ..Default::default() };
let mut base = 0usize;
let mut plines = Vec::new();
for p in text.split('\n') {
let p_clean = p.strip_suffix('\r').unwrap_or(p);
plines.extend(paragraph(p_clean, base, style, para, primary));
base += p.len() + 1;
}
let first_baseline = if para.width.is_some() { pm.ascent } else { 0.0 };
for (i, pl) in plines.into_iter().enumerate() {
let baseline = first_baseline + line_h * i as f32;
let shift = match (para.width, para.align) {
(None, Align::Left | Align::Justify) => 0.0,
(None, Align::Center) => -pl.content / 2.0,
(None, Align::Right) => -pl.content,
(Some(_), Align::Left | Align::Justify) => 0.0,
(Some(w), Align::Center) => (w - pl.content) / 2.0,
(Some(w), Align::Right) => w - pl.content,
} - pl.left_trim;
let g0 = lay.glyphs.len();
lay.glyphs.extend(pl.glyphs.into_iter().map(|mut g| {
g.x += shift;
g.y += baseline;
g
}));
let x = shift + pl.left_trim;
if style.underline && pl.content > 0.0 {
lay.underlines.push([x, baseline + pm.underline_pos, x + pl.content, baseline + pm.underline_pos + pm.underline_thickness]);
}
lay.lines.push(Line {
range: pl.range,
baseline,
x,
width: pl.content,
ascent: pl.ascent,
descent: pl.descent,
glyphs: g0..lay.glyphs.len(),
carets: pl.carets.into_iter().map(|(b, cx)| (b, cx + shift)).collect(),
});
}
let (mut x0, mut x1) = (f32::MAX, f32::MIN);
for l in &lay.lines {
x0 = x0.min(l.x);
x1 = x1.max(l.x + l.width);
}
if let Some(w) = para.width {
x0 = 0.0;
x1 = x1.max(w);
}
let first = &lay.lines[0];
let last = lay.lines.last().expect("at least one line");
lay.bounds = [x0, first.baseline - first.ascent, x1.max(x0), last.baseline + last.descent];
lay
}
fn cache() -> &'static Mutex<HashMap<u64, (u64, Arc<Layout>)>> {
static C: OnceLock<Mutex<HashMap<u64, (u64, Arc<Layout>)>>> = OnceLock::new();
C.get_or_init(Default::default)
}
/// Lay out `text` (cached by text and styles).
pub fn layout(text: &str, style: &TextStyle, para: &ParagraphStyle) -> Arc<Layout> {
let mut h = std::collections::hash_map::DefaultHasher::new();
text.hash(&mut h);
style.hash(&mut h);
para.hash(&mut h);
let key = h.finish();
static CLOCK: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let now = CLOCK.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
if let Some(e) = cache().lock().unwrap_or_else(|e| e.into_inner()).get_mut(&key) {
e.0 = now;
return e.1.clone();
}
let l = Arc::new(layout_uncached(text, style, para));
let mut c = cache().lock().unwrap_or_else(|e| e.into_inner());
if c.len() >= 512 {
// evict the oldest quarter
let mut ages: Vec<u64> = c.values().map(|v| v.0).collect();
ages.sort_unstable();
let cut = ages[ages.len() / 4];
c.retain(|_, v| v.0 > cut);
}
c.insert(key, (now, l.clone()));
l
}
/// Width of a single line of `text` (no wrapping).
pub fn measure(text: &str, style: &TextStyle) -> f32 {
let l = layout(text, style, &ParagraphStyle::default());
l.lines.iter().map(|l| l.width).fold(0.0, f32::max)
}
#[cfg(test)]
mod tests {
use super::*;
fn st(size: f32) -> TextStyle {
TextStyle { size, ..Default::default() }
}
#[test]
fn kerning_and_ligatures_change_advances() {
let kern = measure("AVAVAV", &st(100.0));
let nokern = measure("AVAVAV", &TextStyle { kerning: false, ..st(100.0) });
assert!(kern < nokern - 5.0, "kerning tightens AV: {kern} vs {nokern}");
// tracking adds 1/1000 em per cluster
let tracked = measure("AVAVAV", &TextStyle { tracking: 100.0, ..st(100.0) });
assert!((tracked - kern - 6.0 * 10.0).abs() < 0.5, "{tracked} {kern}");
}
#[test]
fn point_text_alignment() {
let l = layout("Hello", &st(50.0), &ParagraphStyle { align: Align::Center, ..Default::default() });
let line = &l.lines[0];
assert!((line.x + line.width / 2.0).abs() < 0.01);
let r = layout("Hello", &st(50.0), &ParagraphStyle { align: Align::Right, ..Default::default() });
assert!((r.lines[0].x + r.lines[0].width).abs() < 0.01);
assert_eq!(l.glyphs.len(), 5);
assert!(l.bounds[1] < -30.0 && l.bounds[3] > 5.0, "{:?}", l.bounds);
}
#[test]
fn wraps_area_text_and_justifies() {
let p = ParagraphStyle { width: Some(300.0), align: Align::Justify, ..Default::default() };
let l = layout("the quick brown fox jumps over the lazy dog again and again", &st(40.0), &p);
assert!(l.lines.len() >= 3, "{}", l.lines.len());
for line in &l.lines[..l.lines.len() - 1] {
assert!((line.width - 300.0).abs() < 0.5, "justified to the box: {}", line.width);
}
// the lines tile the text
assert_eq!(l.lines[0].range.start, 0);
for w in l.lines.windows(2) {
assert_eq!(w[0].range.end, w[1].range.start);
assert!(w[1].baseline > w[0].baseline);
}
let left = layout("the quick brown fox jumps over the lazy dog", &st(40.0), &ParagraphStyle { width: Some(300.0), ..Default::default() });
assert!(left.lines.iter().all(|l| l.width <= 300.0 + 0.01));
}
#[test]
fn newlines_make_paragraphs_and_leading_adds() {
let l = layout("one\ntwo\n\nfour", &st(30.0), &ParagraphStyle::default());
assert_eq!(l.lines.len(), 4);
assert_eq!(l.lines[1].range, 4..7);
assert_eq!(l.lines[2].range, 8..8);
let gap = l.lines[1].baseline - l.lines[0].baseline;
let l2 = layout("one\ntwo", &st(30.0), &ParagraphStyle { leading: 10.0, ..Default::default() });
assert!((l2.lines[1].baseline - l2.lines[0].baseline - gap - 10.0).abs() < 1e-3);
}
#[test]
fn carets_and_hit_testing() {
let l = layout("abc\nde", &st(40.0), &ParagraphStyle::default());
let (x0, _, li0) = l.caret(0);
let (x1, _, _) = l.caret(1);
let (x3, _, _) = l.caret(3);
assert!(x0.abs() < 1e-3 && x1 > x0 && x3 > x1);
assert_eq!(li0, 0);
let (_, b4, li4) = l.caret(4);
assert_eq!(li4, 1);
assert!(b4 > 0.0);
assert_eq!(l.hit(x1 + 1.0, 0.0), 1);
assert_eq!(l.hit(1000.0, b4), 6);
let rects = l.selection_rects(1, 5);
assert_eq!(rects.len(), 2);
}
#[test]
fn bidi_reorders_rtl_runs() {
// Hebrew letters are laid out right-to-left: the first logical letter is rightmost.
let l = layout("ab \u{5d0}\u{5d1}\u{5d2} cd", &st(40.0), &ParagraphStyle::default());
let x_of = |byte: usize| l.glyphs.iter().find(|g| g.cluster == byte).map(|g| g.x).unwrap();
let alef = "ab ".len();
let gimel = alef + 4;
assert!(x_of(alef) > x_of(gimel), "alef right of gimel");
assert!(x_of(0) < x_of(gimel) && x_of(alef) < x_of("ab \u{5d0}\u{5d1}\u{5d2} ".len()));
}
#[test]
fn caps_and_small_caps() {
let s = st(50.0);
let up = measure("HELLO", &s);
let all = measure("hello", &TextStyle { caps: Caps::All, ..s.clone() });
assert!((up - all).abs() < 0.01);
let small = measure("hello", &TextStyle { caps: Caps::Small, ..s.clone() });
assert!(small < up * 0.9 && small > up * 0.6, "{small} {up}");
let l = layout("Hi", &TextStyle { baseline_shift: 10.0, underline: true, ..s }, &ParagraphStyle::default());
assert!((l.glyphs[0].y + 10.0).abs() < 1e-3);
assert_eq!(l.underlines.len(), 1);
}
#[test]
fn ligature_caret_interpolates() {
// Inter has an "fi"-like ligature only via calt in some versions; use "ffi" which most
// fonts ligate. Whatever shaping does, every char boundary has a caret.
let l = layout("office", &st(40.0), &ParagraphStyle::default());
let stops: Vec<usize> = l.lines[0].carets.iter().map(|c| c.0).collect();
assert_eq!(stops, vec![0, 1, 2, 3, 4, 5, 6]);
let xs: Vec<f32> = l.lines[0].carets.iter().map(|c| c.1).collect();
assert!(xs.windows(2).all(|w| w[1] >= w[0]), "{xs:?}");
}
#[test]
fn fallback_font_for_missing_glyphs() {
// Inter lacks Devanagari/Hebrew; Noto Serif lacks Hebrew too → glyphs still produced (notdef)
let l = layout("A\u{3b1}", &TextStyle { family: "Noto Serif".into(), ..st(40.0) }, &ParagraphStyle::default());
assert_eq!(l.glyphs.len(), 2);
assert!(l.glyphs.iter().all(|g| g.id != 0));
}
#[test]
fn empty_text_has_a_caret() {
let l = layout("", &st(40.0), &ParagraphStyle::default());
assert_eq!(l.lines.len(), 1);
assert_eq!(l.caret(0).0, 0.0);
assert!(l.bounds[3] > l.bounds[1]);
}
}
+228
View File
@@ -0,0 +1,228 @@
//! EffectCraft text engine.
//!
//! - [`fonts`], [`sfnt`], [`layout`]: font database, shaping (harfrust), bidi, line breaking and
//! paragraph layout (shared design with FilmCraft's text engine).
//! - This module turns a layer's [`TextDoc`] into per-character glyph outlines (Bezier paths) with
//! character / word / line indices, which is what text animators and selectors work on.
pub mod fonts;
pub mod layout;
pub mod sfnt;
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
use effectcraft_keyframe::{Justify, TextDoc};
pub use fonts::{FaceId, Resolved, families, resolve};
use kurbo::{Affine, BezPath, Point};
pub use layout::{Align, Caps, Glyph, Layout, Line, ParagraphStyle, TextStyle, layout as layout_text, measure};
use skrifa::instance::{LocationRef, Size};
use skrifa::outline::{DrawSettings, OutlinePen};
use skrifa::{GlyphId, MetadataProvider};
struct Pen(BezPath);
impl OutlinePen for Pen {
fn move_to(&mut self, x: f32, y: f32) {
self.0.move_to((x as f64, y as f64));
}
fn line_to(&mut self, x: f32, y: f32) {
self.0.line_to((x as f64, y as f64));
}
fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) {
self.0.quad_to((cx0 as f64, cy0 as f64), (x as f64, y as f64));
}
fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) {
self.0.curve_to((cx0 as f64, cy0 as f64), (cx1 as f64, cy1 as f64), (x as f64, y as f64));
}
fn close(&mut self) {
self.0.close_path();
}
}
/// Glyph outline in font units (y up), cached.
fn outline_units(face: FaceId, gid: u32) -> Option<Arc<BezPath>> {
static C: OnceLock<Mutex<HashMap<(FaceId, u32), Option<Arc<BezPath>>>>> = OnceLock::new();
let c = C.get_or_init(Default::default);
if let Some(v) = c.lock().unwrap_or_else(|e| e.into_inner()).get(&(face, gid)) {
return v.clone();
}
let f = fonts::face(face);
let v = f.font().and_then(|font| {
let g = font.outline_glyphs().get(GlyphId::new(gid))?;
let mut pen = Pen(BezPath::new());
g.draw(DrawSettings::unhinted(Size::unscaled(), LocationRef::default()), &mut pen).ok()?;
Some(Arc::new(pen.0))
});
let mut m = c.lock().unwrap_or_else(|e| e.into_inner());
if m.len() > 50_000 {
m.clear();
}
m.insert((face, gid), v.clone());
v
}
/// The outline of a laid-out glyph in pixels, origin on its baseline (y down).
pub fn glyph_outline(g: &Glyph) -> BezPath {
let Some(units) = outline_units(g.face, g.id) else { return BezPath::new() };
let k = g.size as f64 / fonts::face(g.face).units_per_em() as f64;
let slant = if g.synth_italic { 0.21 } else { 0.0 };
Affine::new([k, 0.0, slant * k, -k, 0.0, 0.0]) * (*units).clone()
}
/// One character of laid-out text.
#[derive(Clone, Debug)]
pub struct CharGlyph {
/// Outline relative to `origin`.
pub path: BezPath,
/// Baseline origin in layer space.
pub origin: Point,
pub advance: f64,
/// Indices for selectors (characters, characters excluding spaces, words, lines).
pub char_index: usize,
pub char_index_no_space: usize,
pub word_index: usize,
pub line_index: usize,
pub is_space: bool,
pub synth_bold: bool,
pub size: f64,
}
#[derive(Clone, Debug, Default)]
pub struct TextLayout {
pub glyphs: Vec<CharGlyph>,
pub chars: usize,
pub chars_no_space: usize,
pub words: usize,
pub lines: usize,
/// Bounds in layer space (x0, y0, x1, y1).
pub bounds: [f64; 4],
/// Line baselines (layer space) and widths for the viewer's text cursor.
pub line_boxes: Vec<[f64; 4]>,
}
/// Lay out a Source Text value in layer space. Point text: the origin is the start of the first
/// baseline (left), its centre (centre) or end (right). Paragraph text fills `box_size` from
/// `box_pos`.
pub fn layout_doc(doc: &TextDoc) -> TextLayout {
let text = if doc.all_caps { doc.text.to_uppercase() } else { doc.text.clone() };
let style = TextStyle {
family: doc.font.clone(),
style: doc.style.clone(),
size: doc.size as f32,
tracking: doc.tracking as f32,
baseline_shift: doc.baseline_shift as f32,
faux_bold: doc.faux_bold,
faux_italic: doc.faux_italic,
caps: if doc.small_caps { Caps::Small } else { Caps::Normal },
..Default::default()
};
let align = match doc.justify {
Justify::Left | Justify::JustifyLastLeft => Align::Left,
Justify::Center | Justify::JustifyLastCenter => Align::Center,
Justify::Right | Justify::JustifyLastRight => Align::Right,
Justify::JustifyAll => Align::Justify,
};
let natural = doc.size * 1.2;
let leading = doc.leading.map(|l| (l - natural) as f32).unwrap_or(0.0);
let para = ParagraphStyle { align, leading, width: doc.box_size.map(|b| b[0] as f32), rtl: None };
let lay = layout_text(&text, &style, &para);
let first_base = lay.lines.first().map(|l| l.baseline as f64).unwrap_or(0.0);
// Point text origin: (0, 0) at the first baseline; alignment pivots around x = 0.
let (dx, dy) = match doc.box_size {
Some(_) => (doc.box_pos[0], doc.box_pos[1]),
// The layout already pivots point text around x = 0 by alignment.
None => (0.0, -first_base),
};
let hs = doc.h_scale / 100.0;
let vs = doc.v_scale / 100.0;
let mut out = TextLayout { lines: lay.lines.len(), ..Default::default() };
// Map byte clusters → char/word indices.
let mut char_of_byte = vec![0usize; text.len() + 1];
let mut word_of_char = Vec::new();
let mut nospace_of_char = Vec::new();
let mut words = 0usize;
let mut in_word = false;
let mut nospace = 0usize;
for (ci, (b, ch)) in text.char_indices().enumerate() {
for k in b..b + ch.len_utf8() {
char_of_byte[k] = ci;
}
let space = ch.is_whitespace();
if !space && !in_word {
words += 1;
}
in_word = !space;
word_of_char.push(words.saturating_sub(1));
nospace_of_char.push(nospace);
if !space {
nospace += 1;
}
}
out.chars = text.chars().count();
out.chars_no_space = nospace;
out.words = words;
let mut b = [f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY];
for (li, line) in lay.lines.iter().enumerate() {
out.line_boxes.push([line.x as f64 + dx, line.baseline as f64 + dy, line.width as f64, (line.ascent + line.descent) as f64]);
for gi in line.glyphs.clone() {
let g = &lay.glyphs[gi];
let ci = char_of_byte.get(g.cluster).copied().unwrap_or(0);
let ch = text[g.cluster..].chars().next().unwrap_or(' ');
let mut path = glyph_outline(g);
if hs != 1.0 || vs != 1.0 {
path = Affine::scale_non_uniform(hs, vs) * path;
}
let next_x = lay.glyphs.get(gi + 1).filter(|_| line.glyphs.contains(&(gi + 1))).map(|n| n.x).unwrap_or(line.x + line.width);
let origin = Point::new(g.x as f64 * hs + dx, g.y as f64 + dy);
if let Some(r) = (!path.elements().is_empty()).then(|| kurbo::Shape::bounding_box(&path)) {
b[0] = b[0].min(origin.x + r.x0);
b[1] = b[1].min(origin.y + r.y0);
b[2] = b[2].max(origin.x + r.x1);
b[3] = b[3].max(origin.y + r.y1);
}
out.glyphs.push(CharGlyph {
path,
origin,
advance: (next_x - g.x) as f64 * hs,
char_index: ci,
char_index_no_space: nospace_of_char.get(ci).copied().unwrap_or(0),
word_index: word_of_char.get(ci).copied().unwrap_or(0),
line_index: li,
is_space: ch.is_whitespace(),
synth_bold: g.synth_bold,
size: g.size as f64,
});
}
}
if b[0].is_finite() {
out.bounds = b;
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn layout_counts_and_bounds() {
let doc = TextDoc { text: "Hello big world".into(), size: 100.0, ..Default::default() };
let l = layout_doc(&doc);
assert_eq!(l.chars, 15);
assert_eq!(l.words, 3);
assert_eq!(l.chars_no_space, 13);
assert!(l.bounds[2] > 500.0, "{:?}", l.bounds);
// Baseline at y = 0: caps rise above it.
assert!(l.bounds[1] < -60.0 && l.bounds[3] < 30.0, "{:?}", l.bounds);
assert!(l.glyphs.iter().filter(|g| !g.is_space).all(|g| !g.path.elements().is_empty()));
}
#[test]
fn centered_point_text_straddles_origin() {
let doc = TextDoc { text: "CENTER".into(), size: 80.0, justify: Justify::Center, ..Default::default() };
let l = layout_doc(&doc);
assert!(l.bounds[0] < -100.0 && l.bounds[2] > 100.0, "{:?}", l.bounds);
assert!((l.bounds[0] + l.bounds[2]).abs() < 20.0);
}
}
+140
View File
@@ -0,0 +1,140 @@
//! Minimal SFNT (TrueType / OpenType / collection) header reader for font discovery: reads only
//! the table directory, `name` and `OS/2` of each face, so scanning a system font folder does not
//! load whole files.
use std::io::{Read, Seek, SeekFrom};
/// Names and style attributes of one face in a font file.
#[derive(Clone, Debug, PartialEq)]
pub struct FaceNames {
pub index: u32,
pub family: String,
pub style: String,
pub full_name: String,
pub weight: u16,
pub italic: bool,
}
fn be16(b: &[u8], o: usize) -> Option<u16> {
Some(u16::from_be_bytes([*b.get(o)?, *b.get(o + 1)?]))
}
fn be32(b: &[u8], o: usize) -> Option<u32> {
Some(u32::from_be_bytes([*b.get(o)?, *b.get(o + 1)?, *b.get(o + 2)?, *b.get(o + 3)?]))
}
fn read_at<R: Read + Seek>(r: &mut R, off: u64, len: usize) -> Option<Vec<u8>> {
if len > 4 << 20 {
return None;
}
r.seek(SeekFrom::Start(off)).ok()?;
let mut v = vec![0u8; len];
r.read_exact(&mut v).ok()?;
Some(v)
}
/// Read the names of every face in a font file (a single font or a collection).
pub fn read_faces<R: Read + Seek>(r: &mut R) -> Vec<FaceNames> {
let Some(head) = read_at(r, 0, 12) else { return Vec::new() };
let offsets: Vec<u32> = if &head[0..4] == b"ttcf" {
let n = be32(&head, 8).unwrap_or(0).min(256) as usize;
let Some(t) = read_at(r, 12, n * 4) else { return Vec::new() };
(0..n).filter_map(|i| be32(&t, i * 4)).collect()
} else {
vec![0]
};
offsets.iter().enumerate().filter_map(|(i, &o)| read_face(r, o as u64, i as u32)).collect()
}
/// Names from in-memory font data.
pub fn read_faces_bytes(data: &[u8]) -> Vec<FaceNames> {
read_faces(&mut std::io::Cursor::new(data))
}
fn read_face<R: Read + Seek>(r: &mut R, off: u64, index: u32) -> Option<FaceNames> {
let h = read_at(r, off, 12)?;
let tag = &h[0..4];
if tag != [0, 1, 0, 0] && tag != b"OTTO" && tag != b"true" {
return None;
}
let n = be16(&h, 4)? as usize;
let dir = read_at(r, off + 12, n * 16)?;
let mut name = None;
let mut os2 = None;
for i in 0..n {
let rec = &dir[i * 16..i * 16 + 16];
let (o, l) = (be32(rec, 8)? as u64, be32(rec, 12)? as usize);
match &rec[0..4] {
b"name" => name = Some((o, l)),
b"OS/2" => os2 = Some((o, l)),
_ => {}
}
}
let (no, nl) = name?;
let nb = read_at(r, no, nl)?;
let get = |id: u16| name_string(&nb, id);
let family = get(16).or_else(|| get(1))?;
let style = get(17).or_else(|| get(2)).unwrap_or_else(|| "Regular".into());
let full_name = get(4).unwrap_or_else(|| format!("{family} {style}"));
let (mut weight, mut italic) = (400u16, false);
if let Some((oo, ol)) = os2
&& let Some(ob) = read_at(r, oo, ol.min(78))
{
weight = be16(&ob, 4).unwrap_or(400);
italic = be16(&ob, 62).is_some_and(|fs| fs & 1 != 0);
}
let sl = style.to_ascii_lowercase();
italic |= sl.contains("italic") || sl.contains("oblique");
Some(FaceNames { index, family, style, full_name, weight, italic })
}
/// A name-table string by id, preferring Windows Unicode English, then any Unicode, then Mac Roman.
fn name_string(b: &[u8], id: u16) -> Option<String> {
let count = be16(b, 2)? as usize;
let storage = be16(b, 4)? as usize;
let mut best: Option<(u8, String)> = None;
for i in 0..count {
let r = 6 + i * 12;
let (plat, enc, lang, nid, len, off) =
(be16(b, r)?, be16(b, r + 2)?, be16(b, r + 4)?, be16(b, r + 6)?, be16(b, r + 8)? as usize, be16(b, r + 10)? as usize);
if nid != id {
continue;
}
let raw = b.get(storage + off..storage + off + len)?;
let (rank, s) = match plat {
3 if enc == 1 || enc == 10 => {
let u: Vec<u16> = raw.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect();
(if lang == 0x409 { 0 } else { 1 }, String::from_utf16_lossy(&u))
}
0 => {
let u: Vec<u16> = raw.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect();
(2, String::from_utf16_lossy(&u))
}
1 if enc == 0 => (3, raw.iter().map(|&c| if c < 128 { c as char } else { '?' }).collect()),
_ => continue,
};
if best.as_ref().is_none_or(|(r0, _)| rank < *r0) {
best = Some((rank, s));
}
}
best.map(|(_, s)| s).filter(|s| !s.trim().is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reads_bundled_names() {
let f = read_faces_bytes(crate::fonts::INTER_BOLD);
assert_eq!(f.len(), 1);
assert_eq!(f[0].family, "Inter");
assert_eq!(f[0].style, "Bold");
assert_eq!(f[0].weight, 700);
assert!(!f[0].italic);
let i = read_faces_bytes(crate::fonts::INTER_ITALIC);
assert!(i[0].italic, "{:?}", i[0]);
let n = read_faces_bytes(crate::fonts::NOTO_SERIF_REGULAR);
assert_eq!(n[0].family, "Noto Serif");
assert!(read_faces_bytes(b"not a font").is_empty());
}
}
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "effectcraft-time"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[lints]
workspace = true
[dependencies]
serde = { workspace = true }
[dev-dependencies]
proptest = { workspace = true }
+518
View File
@@ -0,0 +1,518 @@
//! Exact media time for EffectCraft (shared design with FilmCraft).
//!
//! All time is an integer number of [`Tick`]s at [`TICKS_PER_SECOND`] = 254 016 000 000/s. That
//! rate divides evenly into every broadcast frame duration (23.976, 24, 25, 29.97, 30, 48, 50,
//! 59.94, 60, 120 …) and every common audio sample duration (8 k … 192 kHz, including the 44.1 k
//! family), so edits, frame math and audio alignment never drift.
//!
//! Timecode (SMPTE drop/non-drop, frames, feet+frames, samples) is only a *display* of ticks.
use serde::{Deserialize, Serialize};
use std::fmt;
use std::ops::{Add, AddAssign, Neg, Sub, SubAssign};
/// Ticks per second.
pub const TICKS_PER_SECOND: i64 = 254_016_000_000;
/// A point or duration in time, in ticks.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Tick(pub i64);
impl Tick {
pub const ZERO: Tick = Tick(0);
pub const MAX: Tick = Tick(i64::MAX / 4);
pub const MIN: Tick = Tick(i64::MIN / 4);
pub fn from_seconds_f64(s: f64) -> Tick {
Tick((s * TICKS_PER_SECOND as f64).round() as i64)
}
pub fn seconds(self) -> f64 {
self.0 as f64 / TICKS_PER_SECOND as f64
}
/// Exact conversion from a count of `units` at `per_second` (e.g. samples at 48 000).
pub fn from_units(units: i64, per_second: i64) -> Tick {
Tick(((units as i128 * TICKS_PER_SECOND as i128) / per_second as i128) as i64)
}
/// Floor conversion to a count of units at `per_second`.
pub fn to_units_floor(self, per_second: i64) -> i64 {
(self.0 as i128 * per_second as i128).div_euclid(TICKS_PER_SECOND as i128) as i64
}
/// Conversion from a rational timestamp `pts * num / den` seconds (container timebases).
pub fn from_rational(pts: i64, num: i64, den: i64) -> Tick {
let t = pts as i128 * num as i128 * TICKS_PER_SECOND as i128;
Tick(t.div_euclid(den as i128) as i64)
}
/// Inverse of [`Tick::from_rational`], floored to the timebase.
pub fn to_rational_floor(self, num: i64, den: i64) -> i64 {
(self.0 as i128 * den as i128).div_euclid(num as i128 * TICKS_PER_SECOND as i128) as i64
}
pub fn abs(self) -> Tick {
Tick(self.0.abs())
}
pub fn min(self, o: Tick) -> Tick {
Tick(self.0.min(o.0))
}
pub fn max(self, o: Tick) -> Tick {
Tick(self.0.max(o.0))
}
pub fn clamp(self, lo: Tick, hi: Tick) -> Tick {
Tick(self.0.clamp(lo.0, hi.0))
}
/// Scale by a rational factor (`num/den`), flooring.
pub fn mul_ratio(self, num: i64, den: i64) -> Tick {
Tick((self.0 as i128 * num as i128).div_euclid(den as i128) as i64)
}
}
impl Add for Tick {
type Output = Tick;
fn add(self, o: Tick) -> Tick {
Tick(self.0 + o.0)
}
}
impl Sub for Tick {
type Output = Tick;
fn sub(self, o: Tick) -> Tick {
Tick(self.0 - o.0)
}
}
impl Neg for Tick {
type Output = Tick;
fn neg(self) -> Tick {
Tick(-self.0)
}
}
impl AddAssign for Tick {
fn add_assign(&mut self, o: Tick) {
self.0 += o.0;
}
}
impl SubAssign for Tick {
fn sub_assign(&mut self, o: Tick) {
self.0 -= o.0;
}
}
/// A half-open time range `[start, start + duration)`.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct TimeRange {
pub start: Tick,
pub duration: Tick,
}
impl TimeRange {
pub fn new(start: Tick, duration: Tick) -> Self {
Self { start, duration }
}
pub fn from_bounds(start: Tick, end: Tick) -> Self {
Self { start, duration: end - start }
}
pub fn end(&self) -> Tick {
self.start + self.duration
}
pub fn contains(&self, t: Tick) -> bool {
t >= self.start && t < self.end()
}
pub fn overlaps(&self, o: &TimeRange) -> bool {
self.start < o.end() && o.start < self.end()
}
pub fn intersect(&self, o: &TimeRange) -> Option<TimeRange> {
let s = self.start.max(o.start);
let e = self.end().min(o.end());
(e > s).then(|| TimeRange::from_bounds(s, e))
}
pub fn is_empty(&self) -> bool {
self.duration.0 <= 0
}
}
/// A frame rate as an exact rational (`num / den` frames per second).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct FrameRate {
pub num: i64,
pub den: i64,
}
impl Default for FrameRate {
fn default() -> Self {
FrameRate::FPS_23_976
}
}
impl FrameRate {
pub const FPS_23_976: FrameRate = FrameRate { num: 24000, den: 1001 };
pub const FPS_24: FrameRate = FrameRate { num: 24, den: 1 };
pub const FPS_25: FrameRate = FrameRate { num: 25, den: 1 };
pub const FPS_29_97: FrameRate = FrameRate { num: 30000, den: 1001 };
pub const FPS_30: FrameRate = FrameRate { num: 30, den: 1 };
pub const FPS_48: FrameRate = FrameRate { num: 48, den: 1 };
pub const FPS_50: FrameRate = FrameRate { num: 50, den: 1 };
pub const FPS_59_94: FrameRate = FrameRate { num: 60000, den: 1001 };
pub const FPS_60: FrameRate = FrameRate { num: 60, den: 1 };
pub const FPS_119_88: FrameRate = FrameRate { num: 120000, den: 1001 };
pub const FPS_120: FrameRate = FrameRate { num: 120, den: 1 };
/// Rates offered in sequence settings (common composition presets).
pub const COMMON: [FrameRate; 11] = [
Self::FPS_23_976,
Self::FPS_24,
Self::FPS_25,
Self::FPS_29_97,
Self::FPS_30,
Self::FPS_48,
Self::FPS_50,
Self::FPS_59_94,
Self::FPS_60,
Self::FPS_119_88,
Self::FPS_120,
];
pub fn new(num: i64, den: i64) -> Self {
let g = gcd(num.abs(), den.abs()).max(1);
FrameRate { num: num / g, den: den / g }
}
/// Closest standard rate for a float (e.g. from a container's average rate).
pub fn from_f64(fps: f64) -> Self {
for r in Self::COMMON {
if (r.as_f64() - fps).abs() < 0.005 {
return r;
}
}
FrameRate::new((fps * 1000.0).round() as i64, 1000)
}
pub fn as_f64(self) -> f64 {
self.num as f64 / self.den as f64
}
/// Exact duration of one frame (rounded down only for exotic rates).
pub fn frame_duration(self) -> Tick {
Tick(((TICKS_PER_SECOND as i128 * self.den as i128) / self.num as i128) as i64)
}
/// Index of the frame containing `t` (floor).
pub fn frame_at(self, t: Tick) -> i64 {
(t.0 as i128 * self.num as i128).div_euclid(TICKS_PER_SECOND as i128 * self.den as i128) as i64
}
/// Start tick of frame `f`.
pub fn tick_of(self, f: i64) -> Tick {
let n = f as i128 * TICKS_PER_SECOND as i128 * self.den as i128;
Tick(n.div_euclid(self.num as i128) as i64)
}
/// Snap `t` down to a frame boundary.
pub fn snap(self, t: Tick) -> Tick {
self.tick_of(self.frame_at(t))
}
/// Snap `t` to the nearest frame boundary.
pub fn snap_nearest(self, t: Tick) -> Tick {
let a = self.snap(t);
let b = self.tick_of(self.frame_at(t) + 1);
if (t - a) <= (b - t) { a } else { b }
}
/// Timecode base (frames counted per timecode second): 30 for 29.97, 24 for 23.976.
pub fn timecode_base(self) -> i64 {
((self.num + self.den - 1) / self.den).max(1)
}
/// NTSC (x/1001) rates can use drop-frame timecode.
pub fn is_ntsc(self) -> bool {
self.den == 1001
}
/// Whether drop-frame counting applies (29.97 / 59.94 / 119.88).
pub fn supports_drop_frame(self) -> bool {
self.is_ntsc() && self.timecode_base() % 30 == 0
}
pub fn label(self) -> String {
if self.den == 1 {
format!("{}", self.num)
} else {
let v = self.as_f64();
let s = format!("{v:.3}");
let s = s.trim_end_matches('0').trim_end_matches('.');
s.to_string()
}
}
}
impl fmt::Display for FrameRate {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} fps", self.label())
}
}
fn gcd(mut a: i64, mut b: i64) -> i64 {
while b != 0 {
(a, b) = (b, a % b);
}
a
}
/// How time is displayed (Timecode, Feet+Frames 16mm/35mm, Frames, Audio Samples).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum TimeDisplay {
#[default]
Timecode,
Frames,
Feet16,
Feet35,
AudioSamples,
Seconds,
}
impl TimeDisplay {
pub const ALL: [TimeDisplay; 6] =
[TimeDisplay::Timecode, TimeDisplay::Feet35, TimeDisplay::Feet16, TimeDisplay::Frames, TimeDisplay::AudioSamples, TimeDisplay::Seconds];
pub fn label(self) -> &'static str {
match self {
TimeDisplay::Timecode => "Timecode",
TimeDisplay::Frames => "Frames",
TimeDisplay::Feet16 => "Feet + Frames 16mm",
TimeDisplay::Feet35 => "Feet + Frames 35mm",
TimeDisplay::AudioSamples => "Audio Samples",
TimeDisplay::Seconds => "Seconds",
}
}
}
/// Drop-frame parameters: frames dropped per minute and nominal base.
fn df_params(rate: FrameRate) -> (i64, i64) {
let base = rate.timecode_base();
(base / 15, base) // 30 → 2, 60 → 4, 120 → 8
}
/// Convert a frame count to SMPTE fields `(negative, h, m, s, f)`.
pub fn frames_to_fields(frame: i64, rate: FrameRate, drop_frame: bool) -> (bool, i64, i64, i64, i64) {
let neg = frame < 0;
let mut n = frame.abs();
let (drop, base) = df_params(rate);
if drop_frame && rate.supports_drop_frame() {
let per_min = base * 60 - drop;
let per_10 = per_min * 10 + drop;
let d = n / per_10;
let m = n % per_10;
n += drop * 9 * d;
if m > drop {
n += drop * ((m - drop) / per_min);
}
}
let f = n % base;
let s = (n / base) % 60;
let mi = (n / (base * 60)) % 60;
let h = n / (base * 3600);
(neg, h, mi, s, f)
}
/// Convert SMPTE fields back to a frame count.
pub fn fields_to_frames(h: i64, m: i64, s: i64, f: i64, rate: FrameRate, drop_frame: bool) -> i64 {
let (drop, base) = df_params(rate);
let mut n = ((h * 3600) + m * 60 + s) * base + f;
if drop_frame && rate.supports_drop_frame() {
let total_min = h * 60 + m;
n -= drop * (total_min - total_min / 10);
}
n
}
/// Format a frame count as SMPTE timecode (`HH:MM:SS:FF`, or `HH;MM;SS;FF` for drop-frame).
pub fn format_timecode_frames(frame: i64, rate: FrameRate, drop_frame: bool) -> String {
let df = drop_frame && rate.supports_drop_frame();
let (neg, h, m, s, f) = frames_to_fields(frame, rate, df);
let sep = if df { ';' } else { ':' };
let fw = if rate.timecode_base() >= 100 { 3 } else { 2 };
format!("{}{h:02}{sep}{m:02}{sep}{s:02}{sep}{f:0fw$}", if neg { "-" } else { "" })
}
/// Format a tick according to a display mode.
pub fn format_time(t: Tick, rate: FrameRate, drop_frame: bool, display: TimeDisplay, sample_rate: i64) -> String {
let frame = rate.frame_at(t);
match display {
TimeDisplay::Timecode => format_timecode_frames(frame, rate, drop_frame),
TimeDisplay::Frames => format!("{frame}"),
TimeDisplay::Feet35 | TimeDisplay::Feet16 => {
let per_ft = if display == TimeDisplay::Feet35 { 16 } else { 40 };
let neg = frame < 0;
let a = frame.abs();
format!("{}{}+{:02}", if neg { "-" } else { "" }, a / per_ft, a % per_ft)
}
TimeDisplay::AudioSamples => {
let secs = t.0.div_euclid(TICKS_PER_SECOND);
let rem = Tick(t.0.rem_euclid(TICKS_PER_SECOND)).to_units_floor(sample_rate);
format!("{:02}:{:02}:{:02}:{rem:05}", secs / 3600, (secs / 60) % 60, secs % 60)
}
TimeDisplay::Seconds => format!("{:.3}", t.seconds()),
}
}
/// Error from [`parse_timecode`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParseError(pub String);
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for ParseError {}
/// Parse user-typed timecode into a frame count, the way NLE timecode fields do:
/// - `01:02:03:04`, `01;02;03;04`, `1.2.3.4` (any of `:;.,` separators),
/// - bare digits are read right-aligned as `HHMMSSFF` (`1000` = 00:00:10:00),
/// - a leading `+`/`-` makes the value relative to `current` (`+15` = 15 frames later, `-1.00` = 1 s earlier).
pub fn parse_timecode(input: &str, rate: FrameRate, drop_frame: bool, current: i64) -> Result<i64, ParseError> {
let s = input.trim();
if s.is_empty() {
return Err(ParseError("empty timecode".into()));
}
let (rel, body) = match s.as_bytes()[0] {
b'+' => (Some(1), &s[1..]),
b'-' => (Some(-1), &s[1..]),
_ => (None, s),
};
let parts: Vec<&str> = body.split([':', ';', '.', ',']).collect();
let nums: Vec<i64> = if parts.len() == 1 {
let digits = parts[0];
if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
return Err(ParseError(format!("not a timecode: `{input}`")));
}
// Right-aligned pairs: "12345" -> [1, 23, 45]; relative bare numbers are frames.
if rel.is_some() {
vec![digits.parse().map_err(|_| ParseError("number too large".into()))?]
} else {
let mut v = Vec::new();
let b = digits.as_bytes();
let mut end = b.len();
while end > 0 {
let start = end.saturating_sub(2);
v.push(digits[start..end].parse::<i64>().unwrap_or(0));
end = start;
}
v.reverse();
v
}
} else {
parts
.iter()
.map(|p| if p.is_empty() { Ok(0) } else { p.parse::<i64>().map_err(|_| ParseError(format!("bad field `{p}`"))) })
.collect::<Result<_, _>>()?
};
if nums.len() > 4 {
return Err(ParseError("too many timecode fields".into()));
}
let mut f4 = [0i64; 4];
let off = 4 - nums.len();
f4[off..].copy_from_slice(&nums);
let [h, m, sec, fr] = f4;
// Overflowing fields (e.g. 90 frames) are allowed, as in common NLE timecode fields.
let base = rate.timecode_base();
let frames = if nums.len() == 1 && rel.is_some() {
fr
} else if drop_frame && rate.supports_drop_frame() && m < 60 && sec < 60 && fr < base {
fields_to_frames(h, m, sec, fr, rate, true)
} else {
((h * 3600) + m * 60 + sec) * base + fr
};
Ok(match rel {
Some(sign) => current + sign * frames,
None => frames,
})
}
/// Common audio sample rates offered in sequence settings.
pub const SAMPLE_RATES: [i64; 6] = [32_000, 44_100, 48_000, 88_200, 96_000, 192_000];
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
#[test]
fn every_common_rate_is_exact() {
for r in FrameRate::COMMON {
let d = r.frame_duration();
assert_eq!(d.0 as i128 * r.num as i128, TICKS_PER_SECOND as i128 * r.den as i128, "{r}");
}
for sr in SAMPLE_RATES.iter().chain(&[8000, 11025, 16000, 22050, 176_400]) {
assert_eq!(TICKS_PER_SECOND % sr, 0, "{sr}");
}
}
#[test]
fn drop_frame_known_values() {
let r = FrameRate::FPS_29_97;
assert_eq!(format_timecode_frames(0, r, true), "00;00;00;00");
assert_eq!(format_timecode_frames(1799, r, true), "00;00;59;29");
assert_eq!(format_timecode_frames(1800, r, true), "00;01;00;02");
assert_eq!(format_timecode_frames(17982, r, true), "00;10;00;00");
assert_eq!(format_timecode_frames(107892, r, true), "01;00;00;00");
assert_eq!(format_timecode_frames(1800, r, false), "00:01:00:00");
let r60 = FrameRate::FPS_59_94;
assert_eq!(format_timecode_frames(3600, r60, true), "00;01;00;04");
}
#[test]
fn parse_forms() {
let r = FrameRate::FPS_25;
assert_eq!(parse_timecode("00:00:10:00", r, false, 0).unwrap(), 250);
assert_eq!(parse_timecode("1000", r, false, 0).unwrap(), 250);
assert_eq!(parse_timecode("1.00", r, false, 0).unwrap(), 25);
assert_eq!(parse_timecode("+15", r, false, 100).unwrap(), 115);
assert_eq!(parse_timecode("-1.00", r, false, 100).unwrap(), 75);
assert_eq!(parse_timecode("00;01;00;02", FrameRate::FPS_29_97, true, 0).unwrap(), 1800);
assert!(parse_timecode("abc", r, false, 0).is_err());
}
#[test]
fn display_modes() {
let r = FrameRate::FPS_24;
let t = r.tick_of(40);
assert_eq!(format_time(t, r, false, TimeDisplay::Feet35, 48000), "2+08");
assert_eq!(format_time(t, r, false, TimeDisplay::Feet16, 48000), "1+00");
assert_eq!(format_time(t, r, false, TimeDisplay::Frames, 48000), "40");
assert_eq!(format_time(Tick::from_units(48_001, 48_000), r, false, TimeDisplay::AudioSamples, 48000), "00:00:01:00001");
}
#[test]
fn rational_conversions() {
// 90 kHz MPEG timebase
let t = Tick::from_rational(90_000, 1, 90_000);
assert_eq!(t.0, TICKS_PER_SECOND);
assert_eq!(t.to_rational_floor(1, 90_000), 90_000);
assert_eq!(FrameRate::from_f64(29.97), FrameRate::FPS_29_97);
assert_eq!(FrameRate::FPS_23_976.label(), "23.976");
}
proptest! {
#[test]
fn frame_tick_roundtrip(f in -1_000_000i64..10_000_000, ri in 0usize..11) {
let r = FrameRate::COMMON[ri];
prop_assert_eq!(r.frame_at(r.tick_of(f)), f);
prop_assert_eq!(r.frame_at(r.tick_of(f) + r.frame_duration() - Tick(1)), f);
}
#[test]
fn drop_frame_bijection(f in 0i64..(24 * 107_892)) {
let r = FrameRate::FPS_29_97;
let (_, h, m, s, fr) = frames_to_fields(f, r, true);
// dropped labels never appear
prop_assert!(!(s == 0 && fr < 2 && m % 10 != 0));
prop_assert_eq!(fields_to_frames(h, m, s, fr, r, true), f);
let txt = format_timecode_frames(f, r, true);
prop_assert_eq!(parse_timecode(&txt, r, true, 0).unwrap(), f);
}
#[test]
fn ndf_parse_roundtrip(f in 0i64..10_000_000, ri in 0usize..11) {
let r = FrameRate::COMMON[ri];
let txt = format_timecode_frames(f, r, false);
prop_assert_eq!(parse_timecode(&txt, r, false, 0).unwrap(), f);
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-4 -4 1038.05 108" role="img" aria-label="ArtCraft">
<title>ArtCraft</title>
<g fill="#f2f1ee">
<path transform="scale(1.02564)" d="M104.28,49.49L81.55,0h-31.23l-3.17,4.76L14.75,53.63,0,75.85l21.55,21.55,63.79-36.94,16.99,37.04,14.01-21.74-12.06-26.27ZM32.89,65.66l32.42-48.87,10.91,23.77-43.32,25.09Z"/>
<path d="M142.07824124466492 100.0V-0.20598030646411303H224.5093337988741Q235.3915640626314 -0.20598030646411303 242.47102827716765 4.2448305180051875Q249.55049249170392 8.695641342474488 253.08388530613996 15.97633917894725Q256.617278120576 23.25703701542001 256.617278120576 31.917827817510926Q256.617278120576 41.29161779108965 252.08564909759403 49.09045161033163Q247.55402007461208 56.88928542957361 238.35448280295117 61.38287869556304L259.0352045196513 100.0H227.60565795214035L210.562880671027 66.09194563707179H169.9554082596294V100.0ZM169.9554082596294 45.01525167126002H215.67753779224603Q221.38490322307234 45.01525167126002 224.71075426312564 41.60858243269406Q228.03660530317896 38.201913194128096 228.03660530317896 32.65618416032707Q228.03660530317896 28.910440606208994 226.55352218432876 26.28334868406067Q225.07043906547852 23.656256761912346 222.3149998187698 22.30626765215014Q219.55956057206106 20.956278542387935 215.67753779224603 20.956278542387935H169.9554082596294Z M312.0332229234414 100.0V22.638971534484327H268.35528177627936V-0.20598030646411303H383.5883310855678V22.638971534484327H339.91038993840584V100.0Z M458.27161556705016 101.74927113702624Q437.40714777937706 101.74927113702624 423.46369302582383 95.8454810495627Q409.5202382722706 89.94169096209913 402.57861475134996 78.38287353515625Q395.6369912304293 66.82405610821337 395.6369912304293 49.85422740524781Q395.6369912304293 25.012678585664176 411.6814700222251 11.52871357108691Q427.7259488140208 -1.9552514434903543 458.3064661074337 -1.9552514434903543Q475.456917945535 -1.9552514434903543 488.5083118603833 3.126178886035433Q501.55970577523163 8.20760921556122 508.8958646388887 17.516444792900415Q516.2320235025458 26.82528037023961 516.2320235025458 39.59306099672011H489.03300511868554Q489.03300511868554 33.14733922307762 485.1161317984869 28.595099713294914Q481.1992584782883 24.042860203512205 474.2052968660206 21.62493380443695Q467.21133525375296 19.207007405361693 457.80894061146523 19.207007405361693Q447.110456418681 19.207007405361693 439.60629072478065 22.77368929226266Q432.1021250308803 26.340371179163625 428.1899983952093 32.79558632186133Q424.2778717595383 39.25080146455903 424.2778717595383 47.95918367346938V51.60349854227405Q424.2778717595383 60.27703021080084 428.1899983952093 66.80513165087463Q432.1021250308803 73.33323309094843 439.5587615987329 76.9601226895613Q447.0153981665855 80.5870122881742 457.5934669359459 80.5870122881742Q467.61380249762595 80.5870122881742 474.8010658306936 78.272076042331Q481.98832916376114 75.9571397964878 485.8925238982956 71.46510799841701Q489.79671863283005 66.97307620034621 489.79671863283005 60.61291930974399H516.3175883855861Q516.3175883855861 73.0289470350082 509.2048350985849 82.30767875649144Q502.0920818115837 91.58641047797467 489.08347033825555 96.66784080750045Q476.07485886492736 101.74927113702624 458.27161556705016 101.74927113702624Z M537.413518212595 100.0V-0.20598030646411303H619.8446107668042Q630.7268410305614 -0.20598030646411303 637.8063052450977 4.2448305180051875Q644.885769459634 8.695641342474488 648.41916227407 15.97633917894725Q651.9525550885061 23.25703701542001 651.9525550885061 31.917827817510926Q651.9525550885061 41.29161779108965 647.4209260655241 49.09045161033163Q642.8892970425421 56.88928542957361 633.6897597708812 61.38287869556304L654.3704814875814 100.0H622.9409349200704L605.8981576389571 66.09194563707179H565.2906852275595V100.0ZM565.2906852275595 45.01525167126002H611.0128147601761Q616.7201801910024 45.01525167126002 620.0460312310557 41.60858243269406Q623.371882271109 38.201913194128096 623.371882271109 32.65618416032707Q623.371882271109 28.910440606208994 621.8887991522588 26.28334868406067Q620.4057160334086 23.656256761912346 617.6502767866998 22.30626765215014Q614.8948375399912 20.956278542387935 611.0128147601761 20.956278542387935H565.2906852275595Z M662.6163133024502 100.0 712.0998010683712 -0.20598030646411303H743.193458536939L792.6769463028602 100.0H762.4325672314772L754.402333653671 83.1409687898597H699.2874274093653L691.317401543271 100.0ZM708.9368987701752 62.32096947316873H744.7528622928611L735.0177014874674 40.96197456382106Q734.4092539371304 39.53909915668276 733.2953489896883 36.886650063206076Q732.1814440422461 34.2342009697294 731.007331383092 31.285460002220745Q729.833218723938 28.33671903471209 728.8603396867204 25.901367365445054Q727.8874606495028 23.46601569617802 727.500857207903 22.53754284917092H726.3948841615974Q725.2635539439634 25.272371609078903 723.7978960953051 28.821628225788082Q722.3322382466466 32.37088484249726 720.9822491368843 35.68880398141399Q719.6322600271221 39.00672312033072 718.6720595755688 41.04753944686133Z M807.9674540726534 100.0V-0.20598030646411303H905.8314549324839V20.956278542387935H835.8446210876178V42.14414368565506H898.3527086906836V63.30640253450711H835.8446210876178V100.0Z M958.534680649389 100.0V22.638971534484327H914.856739502227V-0.20598030646411303H1030.0897888115155V22.638971534484327H986.41184Line truncated
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-4 -4 1038.05 108" role="img" aria-label="ArtCraft">
<title>ArtCraft</title>
<g fill="#0b0b0c">
<path transform="scale(1.02564)" d="M104.28,49.49L81.55,0h-31.23l-3.17,4.76L14.75,53.63,0,75.85l21.55,21.55,63.79-36.94,16.99,37.04,14.01-21.74-12.06-26.27ZM32.89,65.66l32.42-48.87,10.91,23.77-43.32,25.09Z"/>
<path d="M142.07824124466492 100.0V-0.20598030646411303H224.5093337988741Q235.3915640626314 -0.20598030646411303 242.47102827716765 4.2448305180051875Q249.55049249170392 8.695641342474488 253.08388530613996 15.97633917894725Q256.617278120576 23.25703701542001 256.617278120576 31.917827817510926Q256.617278120576 41.29161779108965 252.08564909759403 49.09045161033163Q247.55402007461208 56.88928542957361 238.35448280295117 61.38287869556304L259.0352045196513 100.0H227.60565795214035L210.562880671027 66.09194563707179H169.9554082596294V100.0ZM169.9554082596294 45.01525167126002H215.67753779224603Q221.38490322307234 45.01525167126002 224.71075426312564 41.60858243269406Q228.03660530317896 38.201913194128096 228.03660530317896 32.65618416032707Q228.03660530317896 28.910440606208994 226.55352218432876 26.28334868406067Q225.07043906547852 23.656256761912346 222.3149998187698 22.30626765215014Q219.55956057206106 20.956278542387935 215.67753779224603 20.956278542387935H169.9554082596294Z M312.0332229234414 100.0V22.638971534484327H268.35528177627936V-0.20598030646411303H383.5883310855678V22.638971534484327H339.91038993840584V100.0Z M458.27161556705016 101.74927113702624Q437.40714777937706 101.74927113702624 423.46369302582383 95.8454810495627Q409.5202382722706 89.94169096209913 402.57861475134996 78.38287353515625Q395.6369912304293 66.82405610821337 395.6369912304293 49.85422740524781Q395.6369912304293 25.012678585664176 411.6814700222251 11.52871357108691Q427.7259488140208 -1.9552514434903543 458.3064661074337 -1.9552514434903543Q475.456917945535 -1.9552514434903543 488.5083118603833 3.126178886035433Q501.55970577523163 8.20760921556122 508.8958646388887 17.516444792900415Q516.2320235025458 26.82528037023961 516.2320235025458 39.59306099672011H489.03300511868554Q489.03300511868554 33.14733922307762 485.1161317984869 28.595099713294914Q481.1992584782883 24.042860203512205 474.2052968660206 21.62493380443695Q467.21133525375296 19.207007405361693 457.80894061146523 19.207007405361693Q447.110456418681 19.207007405361693 439.60629072478065 22.77368929226266Q432.1021250308803 26.340371179163625 428.1899983952093 32.79558632186133Q424.2778717595383 39.25080146455903 424.2778717595383 47.95918367346938V51.60349854227405Q424.2778717595383 60.27703021080084 428.1899983952093 66.80513165087463Q432.1021250308803 73.33323309094843 439.5587615987329 76.9601226895613Q447.0153981665855 80.5870122881742 457.5934669359459 80.5870122881742Q467.61380249762595 80.5870122881742 474.8010658306936 78.272076042331Q481.98832916376114 75.9571397964878 485.8925238982956 71.46510799841701Q489.79671863283005 66.97307620034621 489.79671863283005 60.61291930974399H516.3175883855861Q516.3175883855861 73.0289470350082 509.2048350985849 82.30767875649144Q502.0920818115837 91.58641047797467 489.08347033825555 96.66784080750045Q476.07485886492736 101.74927113702624 458.27161556705016 101.74927113702624Z M537.413518212595 100.0V-0.20598030646411303H619.8446107668042Q630.7268410305614 -0.20598030646411303 637.8063052450977 4.2448305180051875Q644.885769459634 8.695641342474488 648.41916227407 15.97633917894725Q651.9525550885061 23.25703701542001 651.9525550885061 31.917827817510926Q651.9525550885061 41.29161779108965 647.4209260655241 49.09045161033163Q642.8892970425421 56.88928542957361 633.6897597708812 61.38287869556304L654.3704814875814 100.0H622.9409349200704L605.8981576389571 66.09194563707179H565.2906852275595V100.0ZM565.2906852275595 45.01525167126002H611.0128147601761Q616.7201801910024 45.01525167126002 620.0460312310557 41.60858243269406Q623.371882271109 38.201913194128096 623.371882271109 32.65618416032707Q623.371882271109 28.910440606208994 621.8887991522588 26.28334868406067Q620.4057160334086 23.656256761912346 617.6502767866998 22.30626765215014Q614.8948375399912 20.956278542387935 611.0128147601761 20.956278542387935H565.2906852275595Z M662.6163133024502 100.0 712.0998010683712 -0.20598030646411303H743.193458536939L792.6769463028602 100.0H762.4325672314772L754.402333653671 83.1409687898597H699.2874274093653L691.317401543271 100.0ZM708.9368987701752 62.32096947316873H744.7528622928611L735.0177014874674 40.96197456382106Q734.4092539371304 39.53909915668276 733.2953489896883 36.886650063206076Q732.1814440422461 34.2342009697294 731.007331383092 31.285460002220745Q729.833218723938 28.33671903471209 728.8603396867204 25.901367365445054Q727.8874606495028 23.46601569617802 727.500857207903 22.53754284917092H726.3948841615974Q725.2635539439634 25.272371609078903 723.7978960953051 28.821628225788082Q722.3322382466466 32.37088484249726 720.9822491368843 35.68880398141399Q719.6322600271221 39.00672312033072 718.6720595755688 41.04753944686133Z M807.9674540726534 100.0V-0.20598030646411303H905.8314549324839V20.956278542387935H835.8446210876178V42.14414368565506H898.3527086906836V63.30640253450711H835.8446210876178V100.0Z M958.534680649389 100.0V22.638971534484327H914.856739502227V-0.20598030646411303H1030.0897888115155V22.638971534484327H986.41184Line truncated
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-10 -19.42 136.34 136.34" role="img" aria-label="ArtCraft">
<title>ArtCraft</title>
<path fill="#0b0b0c" d="M104.28,49.49L81.55,0h-31.23l-3.17,4.76L14.75,53.63,0,75.85l21.55,21.55,63.79-36.94,16.99,37.04,14.01-21.74-12.06-26.27ZM32.89,65.66l32.42-48.87,10.91,23.77-43.32,25.09Z"/>
</svg>

After

Width:  |  Height:  |  Size: 341 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-10 -19.42 136.34 136.34" role="img" aria-label="ArtCraft">
<title>ArtCraft</title>
<path fill="#4e7bfb" d="M104.28,49.49L81.55,0h-31.23l-3.17,4.76L14.75,53.63,0,75.85l21.55,21.55,63.79-36.94,16.99,37.04,14.01-21.74-12.06-26.27ZM32.89,65.66l32.42-48.87,10.91,23.77-43.32,25.09Z"/>
</svg>

After

Width:  |  Height:  |  Size: 341 B

+2
View File
@@ -0,0 +1,2 @@
max_width = 160
use_small_heuristics = "Max"