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>
No files matched your search
@@ -1,2 +1,7 @@
|
||||
*~
|
||||
plan/
|
||||
/target
|
||||
/dist
|
||||
/log
|
||||
.DS_Store
|
||||
.claude/worktrees/
|
||||
@@ -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
|
||||
@@ -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`.
|
||||
@@ -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).
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 }
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -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"]))]),
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -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: ¶ms, 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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: ¶ms, 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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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:?}");
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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, ¶);
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 31 KiB |
@@ -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 |
|
After Width: | Height: | Size: 31 KiB |
@@ -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 |
|
After Width: | Height: | Size: 26 KiB |
@@ -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 |
|
After Width: | Height: | Size: 24 KiB |
@@ -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 |
@@ -0,0 +1,2 @@
|
||||
max_width = 160
|
||||
use_small_heuristics = "Max"
|
||||