diff --git a/.gitignore b/.gitignore index 19cdc639..81d7e8b7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,7 @@ *~ plan/ +/target +/dist +/log +.DS_Store +.claude/worktrees/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..11de2781 --- /dev/null +++ b/AGENTS.md @@ -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: + title: + author: + source: + license: + license-file: + added: by + notes: + ``` + 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 diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md new file mode 100644 index 00000000..9a61c820 --- /dev/null +++ b/ATTRIBUTION.md @@ -0,0 +1,23 @@ +# Attribution + +Every non-code asset in EffectCraft, with its author, source and licence. Each file listed here also has +a `.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`. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..54c15ac0 --- /dev/null +++ b/CLAUDE.md @@ -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-`; keep every `Cargo.toml` valid (the `crates/*` glob). diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..1c73e8d9 --- /dev/null +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..3ac047d4 --- /dev/null +++ b/Cargo.toml @@ -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 diff --git a/LICENSE-APACHE b/LICENSE-APACHE new file mode 100644 index 00000000..1b5ec8b7 --- /dev/null +++ b/LICENSE-APACHE @@ -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 diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 00000000..7d495d41 --- /dev/null +++ b/LICENSE-MIT @@ -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. diff --git a/README.md b/README.md index 8cdcc346..8f0ac588 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,216 @@ -EffectCraft -=========== +

+ + + + ArtCraft + + +

-By ArtCraft +

EffectCraft

+ +

+ Motion graphics and visual effects, in pure Rust. +

+ +

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

+ +

+ Status: early development + Written in Rust + License: MIT or Apache-2.0 +

+ +

+ Join the ArtCraft community on Discord +

+ +

+ EffectCraft on getartcraft.com · + ArtCraft · + All Crafting Apps +

+ +> [!NOTE] +> **ArtCraft is a community of artists from all walks of life.** Painters, photographers, +> filmmakers, illustrators, designers, animators, hobbyists, and people who picked up a pencil +> last week. If you make things, you're one of us. **[Come say hi on Discord](https://discord.gg/artcraft).** + +

+ What it is · + Where it stands · + The plan · + Building · + How it's made · + The Crafting Apps · + License +

+ +## 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 | +|---|---|---|---| +| PhotoCraft | Image editing: layers, masks, type and real PSD files | [GitHub](https://github.com/storytold/photocraft) | [getartcraft.com](https://getartcraft.com/apps/photocraft) | +| VectorCraft | Vector illustration (formerly DrawCraft) | [GitHub](https://github.com/storytold/vectorcraft) | [getartcraft.com](https://getartcraft.com/apps/drawcraft) | +| FilmCraft | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [getartcraft.com](https://getartcraft.com/apps/filmcraft) | +| LightCraft | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [getartcraft.com](https://getartcraft.com/apps/lightcraft) | +| PrintCraft | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/printcraft) | [getartcraft.com](https://getartcraft.com/apps/printcraft) | +| EffectCraft | **Motion graphics and visual effects** · **you are here** | [GitHub](https://github.com/storytold/effectcraft) | [getartcraft.com](https://getartcraft.com/apps/effectcraft) | +| DesignCraft | 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. + +
+ +

+ Join the ArtCraft community on Discord +

+ +

Come make things with us

+ +

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

+ +

+ discord.gg/artcraft · + getartcraft.com · + The Crafting Apps · + EffectCraft +

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

+ ArtCraft
+ Made by the ArtCraft team and community. +

diff --git a/assets/fonts/Inter-Bold.ttf b/assets/fonts/Inter-Bold.ttf new file mode 100644 index 00000000..9fb9b751 Binary files /dev/null and b/assets/fonts/Inter-Bold.ttf differ diff --git a/assets/fonts/Inter-Bold.ttf.attribution b/assets/fonts/Inter-Bold.ttf.attribution new file mode 100644 index 00000000..6bb7738a --- /dev/null +++ b/assets/fonts/Inter-Bold.ttf.attribution @@ -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). diff --git a/assets/fonts/Inter-Italic.ttf b/assets/fonts/Inter-Italic.ttf new file mode 100644 index 00000000..e7ed211d Binary files /dev/null and b/assets/fonts/Inter-Italic.ttf differ diff --git a/assets/fonts/Inter-Italic.ttf.attribution b/assets/fonts/Inter-Italic.ttf.attribution new file mode 100644 index 00000000..dcb36abe --- /dev/null +++ b/assets/fonts/Inter-Italic.ttf.attribution @@ -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). diff --git a/assets/fonts/Inter-Medium.ttf b/assets/fonts/Inter-Medium.ttf new file mode 100644 index 00000000..458cd060 Binary files /dev/null and b/assets/fonts/Inter-Medium.ttf differ diff --git a/assets/fonts/Inter-Medium.ttf.attribution b/assets/fonts/Inter-Medium.ttf.attribution new file mode 100644 index 00000000..521684d7 --- /dev/null +++ b/assets/fonts/Inter-Medium.ttf.attribution @@ -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). diff --git a/assets/fonts/Inter-Regular.ttf b/assets/fonts/Inter-Regular.ttf new file mode 100644 index 00000000..b7aaca8d Binary files /dev/null and b/assets/fonts/Inter-Regular.ttf differ diff --git a/assets/fonts/Inter-Regular.ttf.attribution b/assets/fonts/Inter-Regular.ttf.attribution new file mode 100644 index 00000000..9e16f458 --- /dev/null +++ b/assets/fonts/Inter-Regular.ttf.attribution @@ -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). diff --git a/assets/fonts/Inter-SemiBold.ttf b/assets/fonts/Inter-SemiBold.ttf new file mode 100644 index 00000000..47f8ab1d Binary files /dev/null and b/assets/fonts/Inter-SemiBold.ttf differ diff --git a/assets/fonts/Inter-SemiBold.ttf.attribution b/assets/fonts/Inter-SemiBold.ttf.attribution new file mode 100644 index 00000000..b67d2c67 --- /dev/null +++ b/assets/fonts/Inter-SemiBold.ttf.attribution @@ -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). diff --git a/assets/fonts/JetBrainsMono-Regular.ttf b/assets/fonts/JetBrainsMono-Regular.ttf new file mode 100644 index 00000000..711830ed Binary files /dev/null and b/assets/fonts/JetBrainsMono-Regular.ttf differ diff --git a/assets/fonts/JetBrainsMono-Regular.ttf.attribution b/assets/fonts/JetBrainsMono-Regular.ttf.attribution new file mode 100644 index 00000000..cbc0808a --- /dev/null +++ b/assets/fonts/JetBrainsMono-Regular.ttf.attribution @@ -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). diff --git a/assets/fonts/NotoSerif-Regular.ttf b/assets/fonts/NotoSerif-Regular.ttf new file mode 100644 index 00000000..9fe4b298 Binary files /dev/null and b/assets/fonts/NotoSerif-Regular.ttf differ diff --git a/assets/fonts/NotoSerif-Regular.ttf.attribution b/assets/fonts/NotoSerif-Regular.ttf.attribution new file mode 100644 index 00000000..4d3d2005 --- /dev/null +++ b/assets/fonts/NotoSerif-Regular.ttf.attribution @@ -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). diff --git a/assets/fonts/OFL-Inter.txt b/assets/fonts/OFL-Inter.txt new file mode 100644 index 00000000..9b2ca37b --- /dev/null +++ b/assets/fonts/OFL-Inter.txt @@ -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. diff --git a/assets/fonts/OFL-JetBrainsMono.txt b/assets/fonts/OFL-JetBrainsMono.txt new file mode 100644 index 00000000..5ceee002 --- /dev/null +++ b/assets/fonts/OFL-JetBrainsMono.txt @@ -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. diff --git a/assets/fonts/OFL-NotoSerif.txt b/assets/fonts/OFL-NotoSerif.txt new file mode 100644 index 00000000..6843f318 --- /dev/null +++ b/assets/fonts/OFL-NotoSerif.txt @@ -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. diff --git a/crates/color/Cargo.toml b/crates/color/Cargo.toml new file mode 100644 index 00000000..c30e33b5 --- /dev/null +++ b/crates/color/Cargo.toml @@ -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 } diff --git a/crates/color/src/blend.rs b/crates/color/src/blend.rs new file mode 100644 index 00000000..2145bbff --- /dev/null +++ b/crates/color/src/blend.rs @@ -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 { + let k: String = s.chars().filter(|c| c.is_alphanumeric()).collect::().to_ascii_lowercase(); + BlendMode::ALL.into_iter().find(|m| m.label().chars().filter(|c| c.is_alphanumeric()).collect::().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)); + } +} diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs new file mode 100644 index 00000000..e292d917 --- /dev/null +++ b/crates/color/src/lib.rs @@ -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 { + 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