mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-02 14:38:21 -06:00
Merge upstream/main into rfx-masks-shapes
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
|
||||
reviews:
|
||||
auto_review:
|
||||
# Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
|
||||
ignore_usernames:
|
||||
- "dependabot[bot]"
|
||||
@@ -0,0 +1,20 @@
|
||||
name: SonarCloud
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: sonarcloud-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
sonarcloud:
|
||||
# Tracked at @main so org-wide CI improvements propagate without PR churn.
|
||||
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
|
||||
secrets: inherit
|
||||
Generated
+36
-36
@@ -1163,7 +1163,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft"
|
||||
version = "0.12.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"cpal",
|
||||
"effectcraft-engine",
|
||||
@@ -1188,7 +1188,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-automation"
|
||||
version = "0.12.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"effectcraft-engine",
|
||||
"effectcraft-host",
|
||||
@@ -1200,14 +1200,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-av1enc"
|
||||
version = "0.12.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"filmcraft-av1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-cli"
|
||||
version = "0.12.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"cpu-time",
|
||||
"effectcraft-automation",
|
||||
@@ -1223,7 +1223,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-color"
|
||||
version = "0.12.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -1231,7 +1231,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-effects"
|
||||
version = "0.12.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-geom",
|
||||
@@ -1248,7 +1248,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-engine"
|
||||
version = "0.12.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"effectcraft-color",
|
||||
@@ -1282,7 +1282,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-export"
|
||||
version = "0.12.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"effectcraft-av1enc",
|
||||
"effectcraft-color",
|
||||
@@ -1312,7 +1312,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-expr"
|
||||
version = "0.12.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"boa_engine",
|
||||
"effectcraft-color",
|
||||
@@ -1329,7 +1329,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-geom"
|
||||
version = "0.12.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -1337,7 +1337,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-gpu"
|
||||
version = "0.12.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"effectcraft-color",
|
||||
@@ -1359,14 +1359,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-hevcenc"
|
||||
version = "0.12.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"filmcraft-hevc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-host"
|
||||
version = "0.12.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"effectcraft-engine",
|
||||
"effectcraft-export",
|
||||
@@ -1391,7 +1391,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-interchange"
|
||||
version = "0.12.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-keyframe",
|
||||
@@ -1410,7 +1410,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-keyframe"
|
||||
version = "0.12.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-geom",
|
||||
@@ -1422,7 +1422,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-lottie"
|
||||
version = "0.12.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"effectcraft-color",
|
||||
@@ -1440,7 +1440,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-media"
|
||||
version = "0.12.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"effectcraft-model",
|
||||
@@ -1469,7 +1469,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-model"
|
||||
version = "0.12.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"effectcraft-geom",
|
||||
"image",
|
||||
@@ -1481,14 +1481,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-opusenc"
|
||||
version = "0.12.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"filmcraft-opus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-path"
|
||||
version = "0.12.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"effectcraft-geom",
|
||||
"effectcraft-keyframe",
|
||||
@@ -1500,7 +1500,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-pdf"
|
||||
version = "0.12.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"craft-pdf",
|
||||
"effectcraft-raster",
|
||||
@@ -1516,7 +1516,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-plugin"
|
||||
version = "0.12.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"effectcraft-effects",
|
||||
"effectcraft-keyframe",
|
||||
@@ -1528,7 +1528,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-project"
|
||||
version = "0.12.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-geom",
|
||||
@@ -1541,14 +1541,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-psd"
|
||||
version = "0.12.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"miniz_oxide 0.8.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-raster"
|
||||
version = "0.12.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-geom",
|
||||
@@ -1557,7 +1557,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-render"
|
||||
version = "0.12.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-effects",
|
||||
@@ -1579,7 +1579,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-script"
|
||||
version = "0.12.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"boa_engine",
|
||||
"effectcraft-engine",
|
||||
@@ -1596,7 +1596,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-segment"
|
||||
version = "0.12.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"miniz_oxide 0.8.9",
|
||||
"ndarray",
|
||||
@@ -1605,7 +1605,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-svg"
|
||||
version = "0.12.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"effectcraft-geom",
|
||||
"effectcraft-path",
|
||||
@@ -1617,7 +1617,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-text"
|
||||
version = "0.12.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"effectcraft-keyframe",
|
||||
"harfrust",
|
||||
@@ -1629,7 +1629,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-time"
|
||||
version = "0.12.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -1637,7 +1637,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-track"
|
||||
version = "0.12.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"effectcraft-raster",
|
||||
"effectcraft-segment",
|
||||
@@ -1649,7 +1649,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-ui-egui"
|
||||
version = "0.12.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"effectcraft-color",
|
||||
@@ -1681,11 +1681,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-vp9enc"
|
||||
version = "0.12.0"
|
||||
version = "0.14.0"
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-web"
|
||||
version = "0.12.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"effectcraft-engine",
|
||||
"effectcraft-export",
|
||||
@@ -6625,7 +6625,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.12.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/*"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.12.0"
|
||||
version = "0.14.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
||||
@@ -8,14 +8,18 @@
|
||||
</p>
|
||||
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.effectcraft.png" width="128" height="128" alt="EffectCraft icon: an engraved unicorn on a pink tile">
|
||||
</p>
|
||||
|
||||
<h1 align="center">EffectCraft</h1>
|
||||
|
||||
<p align="center">
|
||||
<b>Motion graphics and visual effects; an open-source, clean-room reimplementation of Adobe After Effects, rebuilt in pure Rust.</b>
|
||||
<b>Motion graphics and visual effects; an open-source, professional motion graphics and visual effects studio, built in pure Rust.</b>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
A free, open-source compositor in the spirit of After Effects: compositions, layers,
|
||||
A free, open-source compositor: compositions, layers,
|
||||
keyframes, 306 effects, layer styles, expressions, 3D cameras and lights, and a render queue, native on macOS,
|
||||
Windows and Linux, and in the browser. Young, moving fast, and already usable.
|
||||
</p>
|
||||
@@ -24,6 +28,9 @@
|
||||
<img alt="Status: young and moving fast" src="https://img.shields.io/badge/status-young%20and%20moving%20fast-e0368f?style=flat-square">
|
||||
<img alt="Written in Rust" src="https://img.shields.io/badge/rust-1.95%2B-b0206c?style=flat-square&logo=rust&logoColor=white">
|
||||
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20%2F%20Apache--2.0-555?style=flat-square">
|
||||
<br>
|
||||
<a href="https://sonarcloud.io/project/overview?id=storytold_effectcraft"><img alt="Scanned by SonarCloud" src="https://img.shields.io/badge/Scanned_by-SonarCloud-4b9fd5?style=flat-square&logo=sonarqubecloud&logoColor=white"></a>
|
||||
<a href="https://coderabbit.ai"><img alt="Reviewed by CodeRabbit" src="https://img.shields.io/coderabbit/prs/github/storytold/effectcraft?style=flat-square&label=Reviewed%20by%20CodeRabbit&logo=coderabbit"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -64,13 +71,14 @@
|
||||
## 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,
|
||||
motion designers and VFX artists need. 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:
|
||||
The interface feels natural and responsive, with dedicated panels (Project, Composition, Timeline,
|
||||
Layer, Footage, Effect Controls, Tools, Preview, Effects & Presets, Audio, Info, Paragraph,
|
||||
Character, Align, Progress, Render Queue) and standard motion graphics shortcuts, going 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.
|
||||
@@ -84,7 +92,7 @@ then to go further in a few places where it matters to us:
|
||||
|
||||
## Animate
|
||||
|
||||
The panels, menus and shortcuts follow After Effects, so your muscle memory carries over:
|
||||
The panels, menus and shortcuts follow standard motion design conventions:
|
||||
Project, Composition, Timeline, Effect Controls, Properties, Effects & Presets, Character,
|
||||
Paragraph, Align, Info, Preview, Audio and the Render Queue, docked the way you expect. Drag
|
||||
footage, comps and effects between them: onto the comp viewer, where they land under the pointer,
|
||||
@@ -102,7 +110,7 @@ or into the Timeline, between layers and at the time you point to.
|
||||
feather, expansion and vertex editing in the viewer.
|
||||
- **Text:** point and paragraph text with real shaping in any font installed on your machine, the
|
||||
Character and Paragraph panels, and text animators with range selectors.
|
||||
- **Expressions:** JavaScript with the After Effects object model (`wiggle`, `loopOut`,
|
||||
- **Expressions:** JavaScript with the standard motion expressions model (`wiggle`, `loopOut`,
|
||||
`thisComp.layer("…")`, vector maths on arrays), an inline editor and the pick-whip.
|
||||
- **Timeline like you know it:** twirl layers open to Transform, masks, effects and the rest; drag
|
||||
rows to reorder layers; rename with Enter or a double-click; the property shortcuts (A, P, S,
|
||||
@@ -118,9 +126,9 @@ or into the Timeline, between layers and at the time you point to.
|
||||
|
||||
## Effects
|
||||
|
||||
306 effects (every one of After Effects' 298, and more) across its categories, each with its parameter names, order and
|
||||
defaults: blur and sharpen, channel, color correction (Curves, Levels, Hue/Saturation, Lumetri
|
||||
Color…), distort (Warp, Bulge, Turbulent Displace, CC Power Pin…), generate (Fractal Noise,
|
||||
306 built-in effects across its categories, each with its parameter names, order and
|
||||
defaults: blur and sharpen, channel, color correction (Curves, Levels, Hue/Saturation, a full
|
||||
color-grading panel…), distort (Warp, Bulge, Turbulent Displace, CC Power Pin…), generate (Fractal Noise,
|
||||
Gradient Ramp, Stroke, Write-on, Audio Spectrum…), keying, matte, noise and grain, perspective,
|
||||
**simulation** (CC Particle World, CC Rainfall, Shatter, Card Dance, Caustics, Wave World…),
|
||||
stylize (Glow, CC Glass…), **time** (Echo, Posterize Time, Timewarp, Time Displacement…),
|
||||
@@ -134,7 +142,7 @@ overlays, Stroke) with Global Light. Preview plays audio in sync, with meters an
|
||||
|
||||
## 3D
|
||||
|
||||
Classic 3D the way After Effects does it: 3D layers with orientation and material options,
|
||||
Classic 3D compositing: 3D layers with orientation and material options,
|
||||
one- and two-node **cameras** with depth of field, **lights** (parallel, spot, point, ambient)
|
||||
with soft ray-traced shadows, layers that intersect correctly, orbit, pan and dolly camera
|
||||
tools, and Front, Top, Left and Custom views.
|
||||
@@ -145,7 +153,7 @@ tools, and Front, Top, Left and Custom views.
|
||||
|
||||
## Export
|
||||
|
||||
A Render Queue like After Effects', with Render Settings and Output Modules: **H.264** MP4 and
|
||||
A dedicated Render Queue, with Render Settings and Output Modules: **H.264** MP4 and
|
||||
**ProRes** MOV (Proxy to 4444 XQ with alpha) with audio, **HEVC** (Main / Main 10) and **AV1**
|
||||
MP4, **WebM** (VP9 with inter frames and alpha, or AV1; Opus audio), PNG, JPEG, TIFF and 32-bit EXR sequences, animated GIF and WAV/AIFF. Field
|
||||
rendering with 3:2 pulldown, effect/solo/guide/depth overrides, crop, region of interest and
|
||||
@@ -219,25 +227,26 @@ tests, layering, asset attribution and the WebAssembly build). See [CONTRIBUTING
|
||||
|
||||
## Where it stands
|
||||
|
||||
EffectCraft is young: its first commit was on 1 October 2026. Nearly every After Effects feature
|
||||
exists, including all of its effects, but existing is not the same as behaving exactly like After
|
||||
Effects, and we haven't measured that yet. Today it's a good place to try things and to tell us
|
||||
what breaks. It isn't yet a replacement for After Effects on client work. In particular:
|
||||
EffectCraft is young: its first commit was on 1 October 2026. A comprehensive feature set
|
||||
exists, including 306 effects, but breadth is not the same as full maturity across every edge
|
||||
case. Today it's a good place to try things and to tell us what breaks. It isn't yet recommended
|
||||
for production client work. In particular:
|
||||
|
||||
- **After Effects projects can't be opened.** Projects are saved as `.ecproj`, readable versioned
|
||||
JSON, but EffectCraft can't open `.aep` / `.aepx` files, and After Effects plug-ins don't run.
|
||||
- **Behaviour still differs from After Effects in places.** Nothing yet compares our renders with
|
||||
After Effects automatically, so please report differences.
|
||||
- **Native project format.** Projects are saved as `.ecproj`, readable versioned JSON. Proprietary
|
||||
project formats and After Effects SDK plug-ins are not supported.
|
||||
- **Fidelity is still being refined.** Automated rendering tests currently focus on small
|
||||
compositions, so please report visual discrepancies or unexpected behaviour.
|
||||
- **macOS is the most tested platform.** Linux and Windows users have hit basic interaction
|
||||
bugs, which we're fixing first.
|
||||
- **The trained models are new.** Roto Brush can use MobileSAM (an optional 40 MB download in
|
||||
Settings ▸ Roto Brush) and face tracking can use Google's MediaPipe Face Landmarker (an optional
|
||||
3.8 MB download in Settings ▸ Face Tracking). Neither is compared with After Effects yet.
|
||||
3.8 MB download in Settings ▸ Face Tracking). Both are actively undergoing evaluation and tuning.
|
||||
|
||||
EffectCraft is in **alpha**: about 91% of After Effects' features exist, and we estimate it is
|
||||
about 45% of the way to replacing After Effects for professional work. The [ROADMAP](ROADMAP.md)
|
||||
has the plan, [docs/target-app-parity.md](docs/target-app-parity.md) the measurement and
|
||||
[docs/gaps.md](docs/gaps.md) every known shortfall. The web build ([docs/web.md](docs/web.md)) runs the full app in the browser.
|
||||
EffectCraft is in **alpha**: about 91% of the features tracked in
|
||||
[docs/target-app-parity.md](docs/target-app-parity.md) exist, and we estimate it is about 45% of the
|
||||
way to being ready for full professional production work. The [ROADMAP](ROADMAP.md) has the plan,
|
||||
[docs/target-app-parity.md](docs/target-app-parity.md) the measurement and [docs/gaps.md](docs/gaps.md)
|
||||
every known shortfall. The web build ([docs/web.md](docs/web.md)) runs the full app in the browser.
|
||||
[Bug reports](https://github.com/storytold/effectcraft/issues) are the most useful thing you can
|
||||
send us right now.
|
||||
|
||||
@@ -245,8 +254,8 @@ send us right now.
|
||||
|
||||
- **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
|
||||
- **Clean room.** We work from public documentation and observable behavior, never
|
||||
from proprietary files, icons, presets or code. No code is copied from GPL projects. The full rules
|
||||
are in [AGENTS.md](AGENTS.md).
|
||||
- **Private by default.** No telemetry, and no network access unless you ask for it.
|
||||
- **One family.** EffectCraft shares its time model and text engine design with FilmCraft, and
|
||||
@@ -368,8 +377,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif
|
||||
part of this repository and EffectCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
|
||||
Forks and modified versions must remove them.
|
||||
|
||||
<sub>Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. EffectCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with.</sub>
|
||||
|
||||
<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>
|
||||
|
||||
+11
-1
@@ -141,7 +141,11 @@ pub fn rgb_to_hsv(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
|
||||
}
|
||||
|
||||
pub fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (f32, f32, f32) {
|
||||
let h6 = h.rem_euclid(1.0) * 6.0;
|
||||
let mut h6 = h.rem_euclid(1.0) * 6.0;
|
||||
// In f32, a tiny negative hue wraps to exactly 1.0; treat it as 0.
|
||||
if h6 >= 6.0 {
|
||||
h6 = 0.0;
|
||||
}
|
||||
let i = h6.floor();
|
||||
let f = h6 - i;
|
||||
let p = v * (1.0 - s);
|
||||
@@ -252,6 +256,12 @@ impl Label {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hsv_to_rgb_tiny_negative_hue_is_red() {
|
||||
let (r, g, b) = hsv_to_rgb(-1e-9, 1.0, 1.0);
|
||||
assert!(r > 0.99 && g < 0.01 && b < 0.01, "{r} {g} {b}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn srgb_roundtrip() {
|
||||
for i in 0..=100 {
|
||||
|
||||
@@ -34,9 +34,11 @@ impl Services for Recording {
|
||||
}
|
||||
}
|
||||
|
||||
/// Probes every file as a still.
|
||||
/// Probes every file as a still. Only the Unix part of the test below imports files.
|
||||
#[cfg(unix)]
|
||||
struct Stills;
|
||||
|
||||
#[cfg(unix)]
|
||||
impl crate::Importer for Stills {
|
||||
fn probe(&self, path: &str) -> Result<Footage, String> {
|
||||
if !std::path::Path::new(path).is_file() {
|
||||
|
||||
@@ -102,3 +102,38 @@ fn commands_cap_sizes_at_30000_px() {
|
||||
let f = s.project.items.values().find_map(|i| if let ItemKind::Footage(f) = &i.kind { Some(f.clone()) } else { None }).unwrap();
|
||||
assert_eq!((f.width, f.height, f.frame_rate), (limits::MAX_SIZE, 8, FrameRate::FPS_29_97));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_project_with_a_precomp_cycle_opens_adds_and_renders_without_running_out_of_stack() {
|
||||
// Comps A and B nest each other (a damaged file); C is unrelated. Checking whether A
|
||||
// contains C walked the A ⇄ B cycle until the stack ran out (#1237).
|
||||
let mut s = Session::default();
|
||||
let mut ids = vec![];
|
||||
for name in ["A", "B", "C"] {
|
||||
s.execute("comp.new", json!({"name": name, "width": 32, "height": 32, "frameRate": 30, "duration": 1})).unwrap();
|
||||
ids.push(s.active_comp_id().unwrap());
|
||||
}
|
||||
let (a, b, c) = (ids[0], ids[1], ids[2]);
|
||||
{
|
||||
let proj = std::sync::Arc::make_mut(&mut s.project);
|
||||
for (outer, inner) in [(a, b), (b, a)] {
|
||||
let comp = proj.comp(outer).unwrap().clone();
|
||||
let l = effectcraft_project::build::layer(proj, &comp, "pre", effectcraft_project::LayerSource::Comp { item: inner }, (32, 32), None);
|
||||
let ItemKind::Comp(cc) = &mut proj.item_mut(outer).unwrap().kind else { panic!("comp") };
|
||||
std::sync::Arc::make_mut(cc).layers.push(l);
|
||||
}
|
||||
}
|
||||
let path = tmp("cycle.ecproj");
|
||||
std::fs::write(&path, s.project.to_file_json().unwrap()).unwrap();
|
||||
let mut t = Session::default();
|
||||
t.execute("file.open", json!({"path": path})).unwrap();
|
||||
t.open_comp(c);
|
||||
// Adding A into C asks whether A contains C: it doesn't, so the layer goes in.
|
||||
let r = t.execute("layer.addItem", json!({"comp": c.0, "item": a.0})).unwrap();
|
||||
assert!(r["layer"].as_u64().is_some(), "{r}");
|
||||
// Adding C into A would nest C in itself through A: refused, as before.
|
||||
assert!(t.execute("layer.addItem", json!({"comp": a.0, "item": c.0})).is_err());
|
||||
// C renders: inside A the renderer skips B's copy of A instead of looping.
|
||||
let img = t.render(c, Tick::ZERO, RenderOpts { scale: 1.0, ..Default::default() });
|
||||
assert_eq!((img.width, img.height), (32, 32));
|
||||
}
|
||||
|
||||
@@ -99,8 +99,8 @@ pub fn sample_image(ctx: EvalCtx, comp: ItemId, layer: &Layer, point: [f64; 2],
|
||||
};
|
||||
let mut acc = [0.0f64; 4];
|
||||
let mut n = 0.0;
|
||||
for y in y0..=y1.min(y0 + 4096) {
|
||||
for x in x0..=x1.min(x0 + 4096) {
|
||||
for y in y0..=y1.min(y0.saturating_add(4096)) {
|
||||
for x in x0..=x1.min(x0.saturating_add(4096)) {
|
||||
let p = b.img.get(x, y);
|
||||
for k in 0..4 {
|
||||
acc[k] += p[k] as f64;
|
||||
|
||||
@@ -1166,6 +1166,13 @@ fn sample_image_reads_rendered_pixels() {
|
||||
assert_close!(f.num(&code("thisComp.layer(\"B\").sampleImage([-50, -50])"), 0.0), 0.0);
|
||||
}
|
||||
|
||||
/// A huge sample point saturates the pixel coordinates; it must not overflow the loop bounds.
|
||||
#[test]
|
||||
fn sample_image_huge_point_is_transparent() {
|
||||
let f = fx();
|
||||
assert_close!(f.num("var c = thisComp.layer(\"B\").sampleImage([1e300, 1e300]); c[3]", 0.0), 0.0);
|
||||
}
|
||||
|
||||
fn data_item(p: &mut Project, name: &str, text: &str) {
|
||||
let f = effectcraft_project::Footage {
|
||||
path: format!("/data/{name}"),
|
||||
|
||||
@@ -82,7 +82,18 @@ impl ShapePath {
|
||||
if i == 0 && f.segment == 0 && !self.closed {
|
||||
return false;
|
||||
}
|
||||
if i > 0 && f.segment == i - 1 {
|
||||
if i == 0 && self.closed && segs > 0 {
|
||||
// The old closing segment and segment 0 merge into the new closing one.
|
||||
if f.segment == segs {
|
||||
f.segment = segs - 1;
|
||||
f.t *= 0.5;
|
||||
} else if f.segment == 0 {
|
||||
f.segment = segs - 1;
|
||||
f.t = 0.5 + f.t * 0.5;
|
||||
} else {
|
||||
f.segment -= 1;
|
||||
}
|
||||
} else if i > 0 && f.segment == i - 1 {
|
||||
f.t *= 0.5;
|
||||
} else if f.segment == i && i > 0 {
|
||||
f.segment = i - 1;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
use effectcraft_keyframe::{FeatherPoint, ShapePath};
|
||||
|
||||
#[test]
|
||||
fn removing_first_vertex_of_closed_path_merges_closing_segment() {
|
||||
let mut p = ShapePath::rect([0.0, 0.0], 10.0, 10.0);
|
||||
let fp = |segment| FeatherPoint { segment, t: 0.5, radius: 1.0, tension: 0.0 };
|
||||
p.feather = vec![fp(3), fp(0)];
|
||||
p.vertices.remove(0);
|
||||
p.in_tangents.remove(0);
|
||||
p.out_tangents.remove(0);
|
||||
p.feather_after_remove(0);
|
||||
let got: Vec<(usize, f64)> = p.feather.iter().map(|f| (f.segment, f.t)).collect();
|
||||
assert_eq!(got, vec![(2, 0.25), (2, 0.75)]);
|
||||
}
|
||||
@@ -40,7 +40,11 @@ fn ffmpeg_fixture(name: &str, args: &[&str]) -> Option<PathBuf> {
|
||||
let tmp = out.with_file_name(format!("tmp-{}-{:?}.{ext}", std::process::id(), std::thread::current().id()).replace(['(', ')'], ""));
|
||||
let st = Command::new("ffmpeg").args(["-hide_banner", "-loglevel", "error", "-y"]).args(args).arg(&tmp).status().expect("run ffmpeg");
|
||||
assert!(st.success(), "ffmpeg failed for {name}");
|
||||
std::fs::rename(&tmp, &out).expect("rename");
|
||||
if std::fs::rename(&tmp, &out).is_err() {
|
||||
// Another test made it first; on Windows the rename fails while that test has it open.
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
assert!(out.exists(), "rename {} to {name} failed", tmp.display());
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
|
||||
@@ -137,7 +137,9 @@ pub fn from_kurbo(path: &BezPath) -> Vec<ShapePath> {
|
||||
pub fn rect(size: [f64; 2], pos: [f64; 2], roundness: f64) -> BezPath {
|
||||
let (w, h) = (size[0].abs(), size[1].abs());
|
||||
let (x0, y0, x1, y1) = (pos[0] - w / 2.0, pos[1] - h / 2.0, pos[0] + w / 2.0, pos[1] + h / 2.0);
|
||||
let rad = if roundness.is_finite() { roundness.clamp(0.0, w.min(h) / 2.0) } else { 0.0 };
|
||||
let max_rad = w.min(h) / 2.0;
|
||||
// `f64::clamp` panics on a NaN bound (both size components NaN): fall back to no roundness.
|
||||
let rad = if roundness.is_finite() && !max_rad.is_nan() { roundness.clamp(0.0, max_rad) } else { 0.0 };
|
||||
let mut p = BezPath::new();
|
||||
if rad <= 0.0 {
|
||||
p.move_to((x1, y0));
|
||||
@@ -410,6 +412,11 @@ mod tests {
|
||||
assert!(near(a, (80.0, 150.0)) && near(b, (-200.0, -130.0)), "{a:?} {b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rect_with_nan_size_does_not_panic() {
|
||||
let _ = rect([f64::NAN, f64::NAN], [0.0, 0.0], 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_vertex_model() {
|
||||
let sp = ShapePath::ellipse([10.0, 20.0], 40.0, 30.0);
|
||||
|
||||
@@ -1478,11 +1478,32 @@ impl Project {
|
||||
}
|
||||
/// Whether comp `inner` is (transitively) nested inside comp `outer` (to prevent cycles).
|
||||
pub fn comp_contains(&self, outer: ItemId, inner: ItemId) -> bool {
|
||||
// A walk with a stack and a visited set rather than recursion (#1237): a damaged project
|
||||
// may hold a precomp cycle that doesn't pass `inner` (recursion never ended), a long
|
||||
// chain must not use up the call stack, and comps shared by many paths are visited once
|
||||
// (recursion re-walked them for every path).
|
||||
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)))
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut stack = vec![outer];
|
||||
while let Some(id) = stack.pop() {
|
||||
if !seen.insert(id) {
|
||||
continue;
|
||||
}
|
||||
let Some(c) = self.comp(id) else { continue };
|
||||
for l in &c.layers {
|
||||
if let LayerSource::Comp { item } = l.source {
|
||||
if item == inner {
|
||||
return true;
|
||||
}
|
||||
if !seen.contains(&item) {
|
||||
stack.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
/// Make sure `next_id` exceeds every id in the project (after loading or merging).
|
||||
pub fn fix_next_id(&mut self) {
|
||||
|
||||
@@ -212,3 +212,58 @@ fn item_users_count_layers_in_every_comp() {
|
||||
assert!(p.item_users(unused).is_empty());
|
||||
assert!(p.item_users(main).is_empty());
|
||||
}
|
||||
|
||||
/// A project of `n` empty comps; `links` adds a precomp layer of comp `b` inside comp `a`
|
||||
/// (indexes into the returned ids).
|
||||
fn comp_graph(n: usize, links: &[(usize, usize)]) -> (Project, Vec<crate::ItemId>) {
|
||||
let mut p = Project::default();
|
||||
let ids: Vec<crate::ItemId> = (0..n)
|
||||
.map(|i| {
|
||||
p.add_item(
|
||||
&format!("C{i}"),
|
||||
effectcraft_color::Label::None,
|
||||
None,
|
||||
ItemKind::Comp(Comp::new(8, 8, FrameRate::FPS_25, Tick::from_seconds_f64(1.0)).into()),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
for &(a, b) in links {
|
||||
let outer = p.comp(ids[a]).unwrap().clone();
|
||||
let l = build::layer(&mut p, &outer, "pre", LayerSource::Comp { item: ids[b] }, (8, 8), None);
|
||||
let ItemKind::Comp(c) = &mut p.item_mut(ids[a]).unwrap().kind else { panic!("comp") };
|
||||
std::sync::Arc::make_mut(c).layers.push(l);
|
||||
}
|
||||
(p, ids)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comp_contains_ends_on_cycles_long_chains_and_shared_precomps() {
|
||||
// A ⇄ B cycle, C unrelated: the walk used to recurse until the stack ran out (#1237).
|
||||
let (p, c) = comp_graph(3, &[(0, 1), (1, 0)]);
|
||||
assert!(!p.comp_contains(c[0], c[2]));
|
||||
assert!(p.comp_contains(c[0], c[1]) && p.comp_contains(c[1], c[0]));
|
||||
// A comp nesting itself.
|
||||
let (p, c) = comp_graph(2, &[(0, 0)]);
|
||||
assert!(!p.comp_contains(c[0], c[1]));
|
||||
assert!(p.comp_contains(c[0], c[0]));
|
||||
// A target reached through a cycle.
|
||||
let (p, c) = comp_graph(3, &[(0, 1), (1, 0), (1, 2)]);
|
||||
assert!(p.comp_contains(c[0], c[2]));
|
||||
assert!(!p.comp_contains(c[2], c[0]));
|
||||
// 40 levels of diamonds: 2^40 paths, each comp visited once.
|
||||
let mut links = vec![];
|
||||
for i in 0..40 {
|
||||
let (top, l, r, bottom) = (i * 3, i * 3 + 1, i * 3 + 2, i * 3 + 3);
|
||||
links.extend([(top, l), (top, r), (l, bottom), (r, bottom)]);
|
||||
}
|
||||
let (p, c) = comp_graph(121, &links);
|
||||
let t0 = std::time::Instant::now();
|
||||
assert!(p.comp_contains(c[0], c[120]));
|
||||
assert!(!p.comp_contains(c[120], c[0]));
|
||||
assert!(t0.elapsed() < std::time::Duration::from_secs(1), "{:?}", t0.elapsed());
|
||||
// A chain 20 000 comps deep (a deep recursion's stack frames).
|
||||
let n = 20_000;
|
||||
let (p, c) = comp_graph(n, &(0..n - 1).map(|i| (i, i + 1)).collect::<Vec<_>>());
|
||||
assert!(p.comp_contains(c[0], c[n - 1]));
|
||||
assert!(!p.comp_contains(c[n - 1], c[0]));
|
||||
}
|
||||
|
||||
@@ -869,8 +869,11 @@ Property.prototype.property = function () { return null; };
|
||||
}
|
||||
if (info.dims > 1) {
|
||||
if (!(v instanceof Array)) throw __err("value must be an array of " + info.dims + " numbers");
|
||||
// After Effects rejects an array of the wrong length instead of truncating or broadcasting it
|
||||
// (#1936). Two values for a 3-D property stay legal, as they are in After Effects: z is 0.
|
||||
if (v.length !== info.dims && !(info.dims === 3 && v.length === 2)) throw __err("Array is not of the correct dimension");
|
||||
var o = [];
|
||||
for (var d = 0; d < info.dims; d++) o.push(d < v.length ? __num(v[d]) : (d === 2 ? 0 : __num(v[v.length - 1])));
|
||||
for (var d = 0; d < info.dims; d++) o.push(d < v.length ? __num(v[d]) : 0);
|
||||
return o;
|
||||
}
|
||||
if (v instanceof Array) throw __err("value must be a number, not an array");
|
||||
|
||||
@@ -1585,3 +1585,41 @@ fn layer_property_resolves_transform_and_source_text_shortcuts() {
|
||||
let (_, c) = comp_named(&s, "x");
|
||||
assert_eq!(layer(c, "s").props.prop("transform/opacity").unwrap().value, KV::Scalar(25.0));
|
||||
}
|
||||
|
||||
/// #1936: an array whose length doesn't match the property's dimension is an error in After
|
||||
/// Effects, not a value to truncate or broadcast. Two values for a 3-D property stay legal.
|
||||
#[test]
|
||||
fn set_value_rejects_an_array_of_the_wrong_dimension() {
|
||||
let mut s = session();
|
||||
let o = ok(
|
||||
&mut s,
|
||||
r#"
|
||||
var c = app.project.items.addComp("Dims", 640, 360, 1, 2, 30);
|
||||
var l = c.layers.addSolid([1, 0, 0], "s", 640, 360, 1);
|
||||
var ramp = l.property("ADBE Effect Parade").addProperty("ADBE Ramp").property("ADBE Ramp-0001");
|
||||
function attempt(f) { try { f(); return "no error"; } catch (e) { return e.message; } }
|
||||
var before = "" + ramp.value;
|
||||
var tooMany = attempt(function () { ramp.setValue([1, 2, 3]); });
|
||||
var tooFew = attempt(function () { ramp.setValue([1]); });
|
||||
var untouched = "" + ramp.value;
|
||||
ramp.setValue([7, 8]);
|
||||
var keyed = attempt(function () { ramp.setValueAtTime(0, [1, 2, 3, 4]); });
|
||||
// A 3-D property still takes two values, as it does in After Effects: z stays 0.
|
||||
l.property("Position").setValue([120, 34]);
|
||||
[tooMany, tooFew, untouched === before, "" + ramp.value, keyed, "" + l.property("Position").value]
|
||||
"#,
|
||||
);
|
||||
assert_eq!(
|
||||
o.result,
|
||||
json!([
|
||||
"Array is not of the correct dimension",
|
||||
"Array is not of the correct dimension",
|
||||
true,
|
||||
"7,8",
|
||||
"Array is not of the correct dimension",
|
||||
"120,34,0"
|
||||
])
|
||||
);
|
||||
let (_, c) = comp_named(&s, "Dims");
|
||||
assert_eq!(layer(c, "s").props.prop("transform/position").unwrap().value, KV::Vec3([120.0, 34.0, 0.0]));
|
||||
}
|
||||
|
||||
@@ -46,6 +46,14 @@ echo "==> EffectCraft $VERSION for FreeBSD $ARCH"
|
||||
|
||||
# The release VM has 12 GB; full parallelism on the biggest crates runs it out of memory.
|
||||
export CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-4}"
|
||||
# The aarch64 release VM is emulated (no rustup toolchain for this tier-3 target, so it builds
|
||||
# natively under QEMU). With one codegen unit and thin LTO the largest crates each optimize on a
|
||||
# single emulated core and the job runs past its 5-hour limit, so trade a little runtime speed for
|
||||
# parallel codegen there. x86_64 keeps the workspace's release profile.
|
||||
if [ "$ARCH" = aarch64 ]; then
|
||||
export CARGO_PROFILE_RELEASE_CODEGEN_UNITS="${CARGO_PROFILE_RELEASE_CODEGEN_UNITS:-16}"
|
||||
export CARGO_PROFILE_RELEASE_LTO="${CARGO_PROFILE_RELEASE_LTO:-off}"
|
||||
fi
|
||||
if [ "$SKIP_BUILD" = 0 ]; then
|
||||
(cd "$ROOT" && cargo build --release --locked -p effectcraft -p effectcraft-cli)
|
||||
fi
|
||||
|
||||
@@ -115,7 +115,7 @@ if has appimage; then
|
||||
if [ -z "$TOOL" ]; then
|
||||
TOOL="$CARGO_TARGET_DIR/appimagetool-$ARCH.AppImage"
|
||||
if [ ! -x "$TOOL" ]; then
|
||||
curl -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage"
|
||||
curl --proto '=https' -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage"
|
||||
chmod +x "$TOOL"
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
sonar.projectKey=storytold_effectcraft
|
||||
sonar.organization=storytold
|
||||
Reference in New Issue
Block a user