6 Commits

Author SHA1 Message Date
Marcos Paulo 7838dd4189 chore: release 0.8.1 2026-10-12 02:07:14 -03:00
Marcos Paulo f7d8065119 feat(ui): Catppuccin themes, View > Preferences > Theme and a custom title bar
Adds the four Catppuccin themes, the View > Preferences > Theme menu, and a window-drawn
title bar on Windows and Linux (Settings > Appearance > Use System Title Bar opts out).
macOS gets the traffic lights over the top row.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 00:37:34 -03:00
Marcos Paulo eb86a40890 build: use the masi/craft-libs fork
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 00:37:34 -03:00
Marcos Paulo 31e66b0606 build(windows): set working directory to repo root in package.ps1 2026-10-11 22:38:51 -03:00
Marcos Paulo 40b97b359a build(windows): package standalone effectcraft-cli zip and auto-detect rc.exe 2026-10-11 22:37:49 -03:00
Marcos Paulo a6b63eed82 chore: debrand ArtCraft and Discord references
Remove Discord button, Help menu links, sibling-app links and brand assets; rewrite docs and packaging URLs. Add .debrand rules.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-11 21:25:31 -03:00
327 changed files with 2068 additions and 25253 deletions
+24
View File
@@ -0,0 +1,24 @@
# Strip upstream (ArtCraft / Discord) branding. Run: perl tools/craft-skills/skills/debrand/debrand.pl . (--check in CI)
delete docs/brand
# MIT/Apache texts stay verbatim. The copyright holder line stays everywhere (legal requirement).
keep ^(LICENSE-(MIT|APACHE)|\.debrand)$
dropline ^ATTRIBUTION\.md$ :: \|\s*`docs/brand/
sub (?s)\nThe ArtCraft name, wordmark and logos in docs/brand/.*?LICENSE-brand\.txt\.\n =>
sub drawn in ArtCraft, vectorised => drawn by hand, vectorised
sub drawing by the project owner in ArtCraft\) => drawing by the project owner)
sub made for this project by the project owner in ArtCraft, then => made for this project by the project owner by hand, then
sub the project owner drew it in ArtCraft => the project owner drew it by hand
sub owner's ArtCraft unicorn drawing => owner's unicorn drawing
sub The owner's ArtCraft drawing => The owner's drawing
sub https://getartcraft\.com/apps/effectcraft => https://github.com/storytold/effectcraft
sub https://discord\.gg/artcraft => https://github.com/storytold/effectcraft/issues
sub https://getartcraft\.com => https://example.com
sub \[Discord\]\(https://github\.com/storytold/effectcraft/issues\) and in\nGitHub issues => GitHub issues
sub Come tell us what matters most to you on \[Discord\]\(https://github\.com/storytold/effectcraft/issues\)\. => Tell us what matters most to you in [GitHub issues](https://github.com/storytold/effectcraft/issues).
sub links \(Help menu, Discord, Settings\) => links (Help menu, Settings)
sub the Discord and other links do nothing => the links do nothing
sub \(6 October, on Discord\) => (6 October)
sub the Tools bar and About dialog carry the ArtCraft mark => the Tools bar and About dialog carry the app mark
forbid (?i)artcraft(?! Team and the EffectCraft contributors)
forbid (?i)discord
sub ffmpeg export, Discord presence, => ffmpeg export,
+4 -8
View File
@@ -2,19 +2,15 @@
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
name: FreeBSD
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every push
# or dependency change: a drifted dependency still shows up within a day, and always before a
# release ships. PRs that change the FreeBSD packaging itself still run it.
on:
schedule:
- cron: "47 2 * * *"
push:
branches: [release]
branches: [main]
pull_request:
paths:
- Cargo.toml
- Cargo.lock
- "**/Cargo.toml"
- .github/workflows/freebsd.yml
- packaging/freebsd/**
- packaging/env.sh
workflow_dispatch:
concurrency:
-6
View File
@@ -34,9 +34,3 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
+7 -5
View File
@@ -4,17 +4,19 @@
# out for now: PdfCraft saw an intermittent access violation in egui UI tests there.)
name: Windows ARM64
# Nightly on main and on pushes to `release` (dependency and runner-image drift shows up within a
# day, and always before a release ships), and on PRs that change the Windows packaging itself.
on:
schedule:
- cron: "13 5 * * *"
push:
branches: [release]
branches: [main]
pull_request:
paths:
- Cargo.toml
- Cargo.lock
- "**/Cargo.toml"
- "**/build.rs"
- .github/workflows/windows-arm64.yml
- packaging/windows/**
schedule:
- cron: "41 4 * * 1" # weekly, so drift in the runner images shows up
workflow_dispatch:
concurrency:
+3
View File
@@ -0,0 +1,3 @@
[submodule "tools/craft-skills"]
path = tools/craft-skills
url = https://git.marcospaulo.dev.br/storytold/craft-skills.git
-6
View File
@@ -76,12 +76,6 @@ reproduce an image (for example, point lists traced from someone else's icon).
`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.
8. **The one exception: first-party ArtCraft brand marks.** The ArtCraft name and logos in `docs/brand/`
are trademarks of the ArtCraft Team, not open source, usable only unmodified and only in the context of
EffectCraft under `docs/brand/LICENSE-brand.txt` (forks and modified versions must remove them). They still
need a sidecar and an `ATTRIBUTION.md` row (licence `LicenseRef-ArtCraft-Trademark`). No other
non-open asset is allowed, and this exception never covers third-party marks (Adobe, Discord, GitHub
and other logos stay out; draw a generic icon instead).
## 2. Clean-room code
+16 -36
View File
@@ -18,21 +18,21 @@ iconography, images or other Adobe assets; open licences only; every asset attri
| `assets/fonts/JetBrainsMono-Regular.ttf` | The JetBrains Mono Project Authors | https://github.com/JetBrains/JetBrainsMono | OFL-1.1 (`assets/fonts/OFL-JetBrainsMono.txt`) |
| `apps/effectcraft-web/web/favicon.svg` | EffectCraft contributors | Original work: web app icon, a copy of `assets/app-icon/effectcraft-small.svg` (the unicorn app icon) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `packaging/macos/dmg/background.svg` | @XusBadia | Original work: macOS DMG Finder window background, derived from the EffectCraft app icon (`assets/app-icon/effectcraft.svg`); rendered to a TIFF by `packaging/macos/package.sh` at package time | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) |
| `assets/app-icon/effectcraft-1024.png` | EffectCraft contributors | Original work: effectCraft app icon, 1024 px (owner's ArtCraft unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/effectcraft-macos-512.png` | EffectCraft contributors | Original work: effectCraft runtime Dock icon (macOS) (owner's ArtCraft unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/effectcraft-small.svg` | EffectCraft contributors | Original work: effectCraft app icon, light vector (owner's ArtCraft unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/effectcraft.icns` | EffectCraft contributors | Original work: effectCraft macOS app icon (owner's ArtCraft unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/effectcraft.ico` | EffectCraft contributors | Original work: effectCraft Windows app icon (owner's ArtCraft unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/effectcraft.svg` | EffectCraft contributors | Original work: effectCraft app icon, master vector (owner's ArtCraft unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/hicolor/128x128/apps/ai.storyteller.effectcraft.png` | EffectCraft contributors | Original work: effectCraft app icon, Linux hicolor 128x128 (owner's ArtCraft unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/hicolor/16x16/apps/ai.storyteller.effectcraft.png` | EffectCraft contributors | Original work: effectCraft app icon, Linux hicolor 16x16 (owner's ArtCraft unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/hicolor/24x24/apps/ai.storyteller.effectcraft.png` | EffectCraft contributors | Original work: effectCraft app icon, Linux hicolor 24x24 (owner's ArtCraft unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/hicolor/256x256/apps/ai.storyteller.effectcraft.png` | EffectCraft contributors | Original work: effectCraft app icon, Linux hicolor 256x256 (owner's ArtCraft unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/hicolor/32x32/apps/ai.storyteller.effectcraft.png` | EffectCraft contributors | Original work: effectCraft app icon, Linux hicolor 32x32 (owner's ArtCraft unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/hicolor/48x48/apps/ai.storyteller.effectcraft.png` | EffectCraft contributors | Original work: effectCraft app icon, Linux hicolor 48x48 (owner's ArtCraft unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/hicolor/512x512/apps/ai.storyteller.effectcraft.png` | EffectCraft contributors | Original work: effectCraft app icon, Linux hicolor 512x512 (owner's ArtCraft unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png` | EffectCraft contributors | Original work: effectCraft app icon, Linux hicolor 64x64 (owner's ArtCraft unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/hicolor/scalable/apps/ai.storyteller.effectcraft.svg` | EffectCraft contributors | Original work: effectCraft app icon, Linux hicolor scalable (owner's ArtCraft unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/effectcraft-1024.png` | EffectCraft contributors | Original work: effectCraft app icon, 1024 px (owner's unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/effectcraft-macos-512.png` | EffectCraft contributors | Original work: effectCraft runtime Dock icon (macOS) (owner's unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/effectcraft-small.svg` | EffectCraft contributors | Original work: effectCraft app icon, light vector (owner's unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/effectcraft.icns` | EffectCraft contributors | Original work: effectCraft macOS app icon (owner's unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/effectcraft.ico` | EffectCraft contributors | Original work: effectCraft Windows app icon (owner's unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/effectcraft.svg` | EffectCraft contributors | Original work: effectCraft app icon, master vector (owner's unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/hicolor/128x128/apps/ai.storyteller.effectcraft.png` | EffectCraft contributors | Original work: effectCraft app icon, Linux hicolor 128x128 (owner's unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/hicolor/16x16/apps/ai.storyteller.effectcraft.png` | EffectCraft contributors | Original work: effectCraft app icon, Linux hicolor 16x16 (owner's unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/hicolor/24x24/apps/ai.storyteller.effectcraft.png` | EffectCraft contributors | Original work: effectCraft app icon, Linux hicolor 24x24 (owner's unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/hicolor/256x256/apps/ai.storyteller.effectcraft.png` | EffectCraft contributors | Original work: effectCraft app icon, Linux hicolor 256x256 (owner's unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/hicolor/32x32/apps/ai.storyteller.effectcraft.png` | EffectCraft contributors | Original work: effectCraft app icon, Linux hicolor 32x32 (owner's unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/hicolor/48x48/apps/ai.storyteller.effectcraft.png` | EffectCraft contributors | Original work: effectCraft app icon, Linux hicolor 48x48 (owner's unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/hicolor/512x512/apps/ai.storyteller.effectcraft.png` | EffectCraft contributors | Original work: effectCraft app icon, Linux hicolor 512x512 (owner's unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png` | EffectCraft contributors | Original work: effectCraft app icon, Linux hicolor 64x64 (owner's unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `assets/app-icon/hicolor/scalable/apps/ai.storyteller.effectcraft.svg` | EffectCraft contributors | Original work: effectCraft app icon, Linux hicolor scalable (owner's unicorn drawing, vectorised) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
| `crates/lottie/tests/fixtures/bouncing-ball.json` | EffectCraft contributors | Original work: hand-written Lottie test fixture (bouncing ball, eased position keys) | MIT OR Apache-2.0 |
| `crates/lottie/tests/fixtures/shapes-gradient.json` | EffectCraft contributors | Original work: hand-written Lottie test fixture (star, rectangle, gradient fill, dashed stroke, trim paths) | MIT OR Apache-2.0 |
| `crates/lottie/tests/fixtures/precomp-matte-text.json` | EffectCraft contributors | Original work: hand-written Lottie test fixture (precomp, track matte, parented text) | MIT OR Apache-2.0 |
@@ -58,15 +58,13 @@ Screenshots of EffectCraft itself, rendered headlessly, showing only procedurall
## Code-drawn assets (original work, MIT OR Apache-2.0)
- UI icons drawn in code: `crates/ui-egui/src/icons.rs`, including the original filled favorite star (2026-10-11, EffectCraft contributors).
- UI icons drawn in code: `crates/ui-egui/src/icons.rs`.
- Procedural demo content (solids, shapes, gradients, text animations): `crates/engine/src/demo.rs`.
- Built-in project templates (Lower Third, Title Card, Logo Reveal, Kinetic Type, Social Square, Vertical Story,
Slideshow, 3D Text Orbit) built from engine commands, and their thumbnails rendered by our own renderer:
`crates/engine/src/templates.rs`.
- Text animator presets (Typewriter, Fade Up Characters, Bounce In Words, Tracking In, Scramble, Blur In, Jitter,
Drop In Lines): `crates/engine/presets/text_animators.json` (sidecar `.attribution`).
- Transition animation presets (23: wipes, clock and fan reveals, blinds, dissolves, card flips, CC wipes, fades):
`crates/engine/presets/transitions.json` (sidecar `.attribution`).
- Sample scripts (File ▸ Scripts) and ScriptUI panel: `crates/engine/scripts/Rename Layers.jsx`,
`crates/engine/scripts/Sort Layers by In Point.jsx`, `crates/engine/scripts/Create Null at Selected Layers.jsx`,
`crates/engine/scripts/Render Queue Batch.jsx`, `crates/engine/scripts/ScriptUI Panels/Layer Tools.jsx`
@@ -103,22 +101,4 @@ configuration from these Apache-2.0 projects:
- `crates/segment/src/mobilesam.rs` follows the MobileSAM, TinyViT and Segment Anything
architectures and their Apache-2.0 reference code.
## First-party brand marks
The ArtCraft name, wordmark and logos are trademarks of the ArtCraft Team. They are not open source and are not
covered by the project licence; they may be used only unmodified and only as part of this repository and EffectCraft,
under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt). Forks and modified versions must remove them
(AGENTS.md §1.8).
| File | Author | Source | Licence |
|---|---|---|---|
| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | Original work: first-party ArtCraft brand mark (ArtCraft wordmark, light ink for dark backgrounds) | ArtCraft brand terms, not open source (`docs/brand/LICENSE-brand.txt`) |
| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | Original work: first-party ArtCraft brand mark (ArtCraft wordmark, light ink for dark backgrounds) | ArtCraft brand terms, not open source (`docs/brand/LICENSE-brand.txt`) |
| `docs/brand/artcraft-logo.png` | ArtCraft Team | Original work: first-party ArtCraft brand mark (ArtCraft wordmark (symbol + "ARTCRAFT"), dark ink for light backgrounds) | ArtCraft brand terms, not open source (`docs/brand/LICENSE-brand.txt`) |
| `docs/brand/artcraft-logo.svg` | ArtCraft Team | Original work: first-party ArtCraft brand mark (ArtCraft wordmark (symbol + "ARTCRAFT"), dark ink for light backgrounds) | ArtCraft brand terms, not open source (`docs/brand/LICENSE-brand.txt`) |
| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | Original work: first-party ArtCraft brand mark (ArtCraft symbol ("A" mark), black) | ArtCraft brand terms, not open source (`docs/brand/LICENSE-brand.txt`) |
| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | Original work: first-party ArtCraft brand mark (ArtCraft symbol ("A" mark), black) | ArtCraft brand terms, not open source (`docs/brand/LICENSE-brand.txt`) |
| `docs/brand/artcraft-mark.png` | ArtCraft Team | Original work: first-party ArtCraft brand mark (ArtCraft symbol ("A" mark), blue) | ArtCraft brand terms, not open source (`docs/brand/LICENSE-brand.txt`) |
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | Original work: first-party ArtCraft brand mark (ArtCraft symbol ("A" mark), blue) | ArtCraft brand terms, not open source (`docs/brand/LICENSE-brand.txt`) |
- Appearance theme previews, drawn procedurally from original shapes: `crates/ui-egui/src/panels/settings.rs` (original work, MIT OR Apache-2.0).
+1 -1
View File
@@ -19,7 +19,7 @@ EffectCraft is a clean-room, open-source, pure-Rust motion graphics and visual e
- **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.
- **Project links** (Help menu, About): 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.
+1 -2
View File
@@ -42,5 +42,4 @@ cargo xtask ci # what every commit must pass
## Talk to us
Questions, ideas and bug reports are welcome on [Discord](https://discord.gg/artcraft) and in
GitHub issues.
Questions, ideas and bug reports are welcome on GitHub issues.
Generated
+37 -42
View File
@@ -965,7 +965,7 @@ dependencies = [
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675"
source = "git+https://git.marcospaulo.dev.br/masi/craft-libs?rev=22544b6d619d4f16779b8bdfa3f0b642a86fc065#22544b6d619d4f16779b8bdfa3f0b642a86fc065"
dependencies = [
"egui",
"serde",
@@ -1143,7 +1143,7 @@ dependencies = [
[[package]]
name = "effectcraft"
version = "0.9.2"
version = "0.8.1"
dependencies = [
"cpal",
"effectcraft-engine",
@@ -1159,7 +1159,6 @@ dependencies = [
"objc2-foundation 0.3.2",
"pollster 0.4.0",
"rfd",
"serde",
"serde_json",
"winit",
"winresource",
@@ -1168,7 +1167,7 @@ dependencies = [
[[package]]
name = "effectcraft-automation"
version = "0.9.2"
version = "0.8.1"
dependencies = [
"effectcraft-engine",
"effectcraft-host",
@@ -1180,14 +1179,14 @@ dependencies = [
[[package]]
name = "effectcraft-av1enc"
version = "0.9.2"
version = "0.8.1"
dependencies = [
"filmcraft-av1",
]
[[package]]
name = "effectcraft-cli"
version = "0.9.2"
version = "0.8.1"
dependencies = [
"cpu-time",
"effectcraft-automation",
@@ -1203,7 +1202,7 @@ dependencies = [
[[package]]
name = "effectcraft-color"
version = "0.9.2"
version = "0.8.1"
dependencies = [
"proptest",
"serde",
@@ -1211,7 +1210,7 @@ dependencies = [
[[package]]
name = "effectcraft-effects"
version = "0.9.2"
version = "0.8.1"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@@ -1228,7 +1227,7 @@ dependencies = [
[[package]]
name = "effectcraft-engine"
version = "0.9.2"
version = "0.8.1"
dependencies = [
"effectcraft-color",
"effectcraft-effects",
@@ -1261,7 +1260,7 @@ dependencies = [
[[package]]
name = "effectcraft-export"
version = "0.9.2"
version = "0.8.1"
dependencies = [
"effectcraft-av1enc",
"effectcraft-color",
@@ -1291,7 +1290,7 @@ dependencies = [
[[package]]
name = "effectcraft-expr"
version = "0.9.2"
version = "0.8.1"
dependencies = [
"boa_engine",
"effectcraft-color",
@@ -1308,7 +1307,7 @@ dependencies = [
[[package]]
name = "effectcraft-geom"
version = "0.9.2"
version = "0.8.1"
dependencies = [
"proptest",
"serde",
@@ -1316,7 +1315,7 @@ dependencies = [
[[package]]
name = "effectcraft-gpu"
version = "0.9.2"
version = "0.8.1"
dependencies = [
"bytemuck",
"effectcraft-color",
@@ -1338,14 +1337,14 @@ dependencies = [
[[package]]
name = "effectcraft-hevcenc"
version = "0.9.2"
version = "0.8.1"
dependencies = [
"filmcraft-hevc",
]
[[package]]
name = "effectcraft-host"
version = "0.9.2"
version = "0.8.1"
dependencies = [
"effectcraft-engine",
"effectcraft-export",
@@ -1369,7 +1368,7 @@ dependencies = [
[[package]]
name = "effectcraft-interchange"
version = "0.9.2"
version = "0.8.1"
dependencies = [
"effectcraft-color",
"effectcraft-keyframe",
@@ -1388,7 +1387,7 @@ dependencies = [
[[package]]
name = "effectcraft-keyframe"
version = "0.9.2"
version = "0.8.1"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@@ -1400,7 +1399,7 @@ dependencies = [
[[package]]
name = "effectcraft-lottie"
version = "0.9.2"
version = "0.8.1"
dependencies = [
"effectcraft-color",
"effectcraft-effects",
@@ -1417,7 +1416,7 @@ dependencies = [
[[package]]
name = "effectcraft-media"
version = "0.9.2"
version = "0.8.1"
dependencies = [
"effectcraft-model",
"effectcraft-pdf",
@@ -1431,7 +1430,6 @@ dependencies = [
"filmcraft-codecs",
"filmcraft-color",
"filmcraft-frame",
"filmcraft-isobmff",
"filmcraft-media",
"filmcraft-time",
"gif",
@@ -1444,11 +1442,10 @@ dependencies = [
[[package]]
name = "effectcraft-model"
version = "0.9.2"
version = "0.8.1"
dependencies = [
"effectcraft-geom",
"image",
"miniz_oxide 0.8.9",
"serde",
"serde_json",
"thiserror 2.0.21",
@@ -1456,14 +1453,14 @@ dependencies = [
[[package]]
name = "effectcraft-opusenc"
version = "0.9.2"
version = "0.8.1"
dependencies = [
"filmcraft-opus",
]
[[package]]
name = "effectcraft-path"
version = "0.9.2"
version = "0.8.1"
dependencies = [
"effectcraft-geom",
"effectcraft-keyframe",
@@ -1475,7 +1472,7 @@ dependencies = [
[[package]]
name = "effectcraft-pdf"
version = "0.9.2"
version = "0.8.1"
dependencies = [
"effectcraft-raster",
"effectcraft-svg",
@@ -1489,7 +1486,7 @@ dependencies = [
[[package]]
name = "effectcraft-plugin"
version = "0.9.2"
version = "0.8.1"
dependencies = [
"effectcraft-effects",
"effectcraft-keyframe",
@@ -1501,7 +1498,7 @@ dependencies = [
[[package]]
name = "effectcraft-project"
version = "0.9.2"
version = "0.8.1"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@@ -1514,14 +1511,14 @@ dependencies = [
[[package]]
name = "effectcraft-psd"
version = "0.9.2"
version = "0.8.1"
dependencies = [
"miniz_oxide 0.8.9",
]
[[package]]
name = "effectcraft-raster"
version = "0.9.2"
version = "0.8.1"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@@ -1530,7 +1527,7 @@ dependencies = [
[[package]]
name = "effectcraft-render"
version = "0.9.2"
version = "0.8.1"
dependencies = [
"effectcraft-color",
"effectcraft-effects",
@@ -1552,7 +1549,7 @@ dependencies = [
[[package]]
name = "effectcraft-script"
version = "0.9.2"
version = "0.8.1"
dependencies = [
"boa_engine",
"effectcraft-engine",
@@ -1560,16 +1557,14 @@ dependencies = [
"effectcraft-keyframe",
"effectcraft-path",
"effectcraft-project",
"effectcraft-psd",
"effectcraft-render",
"effectcraft-time",
"serde_json",
"web-time",
]
[[package]]
name = "effectcraft-segment"
version = "0.9.2"
version = "0.8.1"
dependencies = [
"miniz_oxide 0.8.9",
"ndarray",
@@ -1578,7 +1573,7 @@ dependencies = [
[[package]]
name = "effectcraft-svg"
version = "0.9.2"
version = "0.8.1"
dependencies = [
"effectcraft-geom",
"effectcraft-path",
@@ -1590,7 +1585,7 @@ dependencies = [
[[package]]
name = "effectcraft-text"
version = "0.9.2"
version = "0.8.1"
dependencies = [
"effectcraft-keyframe",
"harfrust",
@@ -1602,7 +1597,7 @@ dependencies = [
[[package]]
name = "effectcraft-time"
version = "0.9.2"
version = "0.8.1"
dependencies = [
"proptest",
"serde",
@@ -1610,7 +1605,7 @@ dependencies = [
[[package]]
name = "effectcraft-track"
version = "0.9.2"
version = "0.8.1"
dependencies = [
"effectcraft-raster",
"effectcraft-segment",
@@ -1622,7 +1617,7 @@ dependencies = [
[[package]]
name = "effectcraft-ui-egui"
version = "0.9.2"
version = "0.8.1"
dependencies = [
"craft-ui",
"effectcraft-color",
@@ -1654,11 +1649,11 @@ dependencies = [
[[package]]
name = "effectcraft-vp9enc"
version = "0.9.2"
version = "0.8.1"
[[package]]
name = "effectcraft-web"
version = "0.9.2"
version = "0.8.1"
dependencies = [
"effectcraft-engine",
"effectcraft-export",
@@ -6468,7 +6463,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.9.2"
version = "0.8.1"
dependencies = [
"serde_json",
]
+2 -2
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*"]
[workspace.package]
version = "0.9.2"
version = "0.8.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -99,7 +99,7 @@ pollster = "0.4"
# ui
# Shared frontend components; keep this dependency above the engine boundary.
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
craft-ui = { version = "0.1.0", git = "https://git.marcospaulo.dev.br/masi/craft-libs", rev = "22544b6d619d4f16779b8bdfa3f0b642a86fc065", features = ["serde"] }
egui = "0.36"
eframe = { version = "0.36", default-features = false }
egui_kittest = "0.36"
-4
View File
@@ -3,10 +3,6 @@ Copyright (c) 2026 ArtCraft Team and the EffectCraft contributors.
Licensed under the MIT License or the Apache License, Version 2.0, at your option
(LICENSE-MIT, LICENSE-APACHE).
The ArtCraft name, wordmark and logos in docs/brand/ are trademarks of the ArtCraft Team. They are
not open source and are not covered by the license above; their terms are in
docs/brand/LICENSE-brand.txt.
Third-party material
This product bundles third-party material under its own licenses, listed below. The per-file
+11 -70
View File
@@ -1,13 +1,3 @@
<p align="center">
<a href="https://getartcraft.com/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/brand/artcraft-logo-white.svg">
<img alt="ArtCraft" src="docs/brand/artcraft-logo.svg" width="200">
</picture>
</a>
</p>
<h1 align="center">EffectCraft</h1>
<p align="center">
@@ -26,26 +16,12 @@
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20%2F%20Apache--2.0-555?style=flat-square">
</p>
<p align="center">
<a href="https://discord.gg/artcraft"><img alt="Join the ArtCraft community on Discord" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
</p>
<p align="center">
<a href="https://getartcraft.com/apps/effectcraft"><b>EffectCraft on getartcraft.com</b></a> ·
<a href="https://getartcraft.com/">ArtCraft</a> ·
<a href="https://getartcraft.com/apps">All Crafting Apps</a>
</p>
<br>
<p align="center">
<img src="docs/images/effectcraft-hero.png" alt="EffectCraft's main window: the animated demo composition in the Composition panel, the Project panel, a Timeline with text, shape and solid layers, and the Properties panel showing the selected text layer's transform, font and paragraph settings" width="100%">
</p>
> [!NOTE]
> **ArtCraft is a community of artists from all walks of life.** Digital, generative, music,
> games &mdash; if you make things, you're one of us. **[Come say hi on Discord](https://discord.gg/artcraft).**
<p align="center">
<a href="#what-effectcraft-is">What it is</a> ·
<a href="#animate">Animate</a> ·
@@ -254,7 +230,7 @@ send us right now.
## Downloads
**New to EffectCraft?** Download it from the [EffectCraft page on getartcraft.com](https://getartcraft.com/apps/effectcraft). That's the easiest way to install it.
**New to EffectCraft?** Download it from the [latest release](https://github.com/storytold/effectcraft/releases/latest). That's the easiest way to install it.
**Want a specific build or format?** On GitHub, the [latest release](https://github.com/storytold/effectcraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/effectcraft/releases) has earlier versions and their notes. `<ver>` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file.
@@ -317,43 +293,18 @@ File ▸ Import fallback instead.
## 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
EffectCraft is one of the **Crafting Apps**: free, open-source creative tools, each written from scratch in Rust and each able to
stand on its own.
| | App | What it's for | Code | Learn more |
|:-:|---|---|---|---|
| <img src="https://raw.githubusercontent.com/storytold/photocraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.photocraft.png" alt="" width="32" height="32"> | **PhotoCraft** | Image editing: layers, masks, type and real PSD files | [GitHub](https://github.com/storytold/photocraft) | [Website](https://getartcraft.com/apps/photocraft) |
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) |
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) |
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) |
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | **Motion graphics and visual effects · you are here** | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
<br>
<p align="center">
<a href="https://discord.gg/artcraft"><img alt="Join the ArtCraft community on Discord" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
</p>
<h3 align="center">Come make things with us</h3>
<p align="center">
Our Discord is where artists of every kind hang out: people who paint, shoot, draw, cut film,
set type, and people still figuring out what they like to make. Share what you're working on,
ask for help, tell us what's broken, or tell us what you wish these tools could do.
Whatever your medium and however long you've been at it, you're welcome here.
</p>
<p align="center">
<a href="https://discord.gg/artcraft"><b>discord.gg/artcraft</b></a> ·
<a href="https://getartcraft.com/">getartcraft.com</a> ·
<a href="https://getartcraft.com/apps">The Crafting Apps</a> ·
<a href="https://getartcraft.com/apps/effectcraft">EffectCraft</a>
</p>
| | App | What it's for | Code |
|:-:|---|---|---|
| <img src="https://raw.githubusercontent.com/storytold/photocraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.photocraft.png" alt="" width="32" height="32"> | **PhotoCraft** | Image editing: layers, masks, type and real PSD files | [GitHub](https://github.com/storytold/photocraft) |
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) |
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) |
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) |
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) |
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | **Motion graphics and visual effects · you are here** | [GitHub](https://github.com/storytold/effectcraft) |
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) |
## License and credits
@@ -363,18 +314,8 @@ Copyright (c) 2026 ArtCraft Team and the EffectCraft contributors. Required noti
Bundled fonts, icons, images and other assets keep their own open licenses; each one is listed
with its author, source and license in [ATTRIBUTION.md](ATTRIBUTION.md).
The ArtCraft name, wordmark and logos in [`docs/brand/`](docs/brand/) are trademarks of the
ArtCraft Team and are not covered by this license. They may be used only unmodified, and only as
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>
</p>
## Star history
[![Star History Chart](https://api.star-history.com/svg?repos=storytold/effectcraft&type=Date&legend=top-left)](https://www.star-history.com/?repos=storytold%2Feffectcraft&type=date&legend=top-left)
+2 -9
View File
@@ -97,7 +97,7 @@ UI strings translated out of ≈ 3,900 (detail: [localization-parity.md](docs/lo
| Korean | none | 0% |
| Vietnamese | none | 0% |
Also shipped: Traditional Chinese (partial, 28%) and Ukrainian (partial, 29%). No language has had
Also shipped: Traditional Chinese (partial, 28%) and Ukrainian (partial, 26%). No language has had
native-speaker review.
## Upcoming
@@ -113,17 +113,10 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
| 5 | Performance: benchmarks at 1080p / 4K, real-time cached playback, hardware video (G5) | 50–100 |
| 6 | Localization depth: effect names, all panels, French / German / Korean (G9) | 60–110 |
Come tell us what matters most to you on [Discord](https://discord.gg/artcraft).
Tell us what matters most to you in [GitHub issues](https://github.com/storytold/effectcraft/issues).
## Progress log
- 2026-10-11: Ukrainian panels caught up with the Chinese catalogs. The 95 rows the panel
catalog gained since 9 October are translated: the Home screen (recent files, their Kind and
Opened columns, the empty and no-match states), the viewer's toolbar tooltips, magnification,
resolution, grid, channel and Fast Previews popups, the Appearance settings, the solid dialog
and the project, import, export and colour-management forms. `uk` now carries 495 of the 496
panel rows — `RGB` reads the same in Ukrainian, so it falls back to the English source by
design — alongside its 618/618 menus.
- 2026-10-10: Full parity re-measure against After Effects 2026 26.5 (installed bundle, 26.2–26.5
release notes, repository counts): breadth ≈ 91%, ready ≈ 45%, stage alpha. Progress docs
follow the shared standard: `docs/parity.md` became `docs/target-app-parity.md`; new
@@ -1,6 +1,6 @@
asset: favicon.svg
title: EffectCraft web app icon (favicon)
author: EffectCraft contributors (drawing by the project owner in ArtCraft)
author: EffectCraft contributors (drawing by the project owner)
source: original work
license: MIT OR Apache-2.0
license-file: assets/app-icon/LICENSE.txt
+3 -5
View File
@@ -14,13 +14,9 @@ effectcraft-engine = { workspace = true }
effectcraft-host = { workspace = true }
effectcraft-ui-egui = { workspace = true }
egui = { workspace = true }
# `links`: links (Help menu, Discord, Settings) open in the system browser.
# `links`: links (Help menu, Settings) open in the system browser.
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "links", "wayland", "x11"] }
serde = { workspace = true }
serde_json = { workspace = true }
# The window's monitors, size and position (src/window.rs), and macOS event-loop options (no app
# activation when driven by an agent). The same winit eframe runs on.
winit = { version = "0.30", default-features = false }
rfd = { workspace = true }
pollster = { workspace = true }
cpal = { workspace = true }
@@ -36,6 +32,8 @@ zbus = "5.19"
winresource = "0.1"
[target.'cfg(target_os = "macos")'.dependencies]
# Same winit as eframe: macOS event-loop options (no app activation when driven by an agent).
winit = { version = "0.30", default-features = false }
# Keep the control channel responsive when the window is covered (App Nap / occlusion throttling).
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSProcessInfo", "NSString", "NSObject"] }
# The native menu bar (NSMenu). MIT OR Apache-2.0; macOS only, never in the web build.
+2 -2
View File
@@ -117,7 +117,7 @@ fn run<T: SizedSample + FromSample<f32>>(
let dev_lat = ts.playback.duration_since(&ts.callback).map(|d| d.as_secs_f64()).unwrap_or(0.0);
latency.store((dev_lat * rate) as u64 + frames as u64, Ordering::Relaxed);
},
|e| log::warn!("audio output: {e}"),
|e| eprintln!("effectcraft: audio output: {e}"),
None,
)
.map_err(|e| e.to_string())
@@ -187,7 +187,7 @@ impl AudioDevice for CpalOut {
return;
}
if let Err(e) = stream.play() {
log::warn!("audio output: {e}");
eprintln!("effectcraft: audio output: {e}");
return;
}
let _ = stop_rx.recv();
+2 -2
View File
@@ -27,11 +27,11 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
let listener = match TcpListener::bind(("127.0.0.1", port)) {
Ok(l) => l,
Err(e) => {
log::error!("control server failed to bind 127.0.0.1:{port}: {e}");
eprintln!("effectcraft: control server failed to bind 127.0.0.1:{port}: {e}");
return rx;
}
};
log::info!("control server listening on 127.0.0.1:{port}");
eprintln!("effectcraft: control server listening on 127.0.0.1:{port}");
std::thread::spawn(move || accept_loop(listener, tx, ctx));
rx
}
+45 -34
View File
@@ -24,7 +24,6 @@ mod native_menu;
// Built everywhere so it stays checked; only the macOS app uses it.
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
mod sheet_dialogs;
mod window;
use effectcraft_ui_egui::EffectcraftApp;
use serde_json::json;
@@ -45,6 +44,21 @@ fn app_icon() -> egui::IconData {
/// Both native JSON projects and Save a Copy As XML projects are openable on launch.
/// Keep this classification shared by project opening and the subsequent media-import pass.
/// Windows and Linux: no OS title bar; the menu bar row is the title bar
/// (`effectcraft_ui_egui::titlebar`). macOS keeps its traffic lights over the integrated top row.
const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
/// Whether this start draws its own title bar: Windows and Linux do, unless Settings ▸ Appearance ▸
/// Use System Title Bar asks for the system's. Read from the saved settings before the window
/// opens; a missing or unreadable file keeps the default.
fn custom_titlebar(config_dir: Option<&std::path::Path>) -> bool {
let prefs = config_dir
.and_then(|d| std::fs::read_to_string(d.join(effectcraft_engine::prefs::PREFS_FILE)).ok())
.map(|t| effectcraft_engine::prefs::Prefs::from_json(&t))
.unwrap_or_default();
CUSTOM_TITLEBAR && !prefs.appearance.system_title_bar
}
fn is_project_file(path: &str) -> bool {
std::path::Path::new(path)
.extension()
@@ -89,24 +103,13 @@ fn parse_args(args: impl IntoIterator<Item = String>, env_port: Option<String>)
}
fn main() -> eframe::Result {
// First, so the panic hook and every start-up warning are recorded. Records go to stderr
// and the session log; Help ▸ Enable Logging writes through this logger too, and warnings
// feed the compatibility report.
// Help ▸ Enable Logging writes through this logger; warnings feed the compatibility report.
effectcraft_engine::logging::install();
effectcraft_engine::logging::install_panic_hook();
let Some(Args { control_port, files, demo, home }) = parse_args(std::env::args().skip(1), std::env::var("EFFECTCRAFT_CONTROL_PORT").ok()) else {
println!("effectcraft {}", env!("CARGO_PKG_VERSION"));
return Ok(());
};
// The session log lives in the settings folder (`Logs/effectcraft.log`, the previous two runs
// kept): a launch from the Dock or a menu has no terminal. Opened after the arguments, so
// `--version` leaves no file behind; records logged until now are written to it first.
if let Some(dir) = effectcraft_host::config_dir() {
match effectcraft_engine::logging::attach_session_log(&effectcraft_engine::logging::log_dir(Some(&dir))) {
Ok(path) => log::info!("effectcraft {}, log file {}", env!("CARGO_PKG_VERSION"), path.display()),
Err(e) => log::warn!("no log file: {e}"),
}
}
// The font menus list the installed fonts: read their names while the window opens.
effectcraft_engine::text::fonts::scan_system_in_background();
// Wayland shows the window's icon from its desktop entry: an AppImage brings its own.
@@ -117,12 +120,17 @@ fn main() -> eframe::Result {
let mut launch = launch_guard::Launch::begin(effectcraft_host::config_dir().as_deref(), std::env::var_os("WGPU_BACKEND").is_some());
let on_gl = launch.backends == Some(eframe::wgpu::Backends::GL);
let notice = launch.notice.take();
let custom_titlebar = custom_titlebar(effectcraft_host::config_dir().as_deref());
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("EffectCraft")
.with_inner_size(window::DEFAULT_SIZE)
.with_min_inner_size(window::MIN_SIZE)
.with_inner_size([1680.0, 1020.0])
.with_min_inner_size([960.0, 600.0])
.with_drag_and_drop(true)
.with_decorations(!custom_titlebar)
.with_fullsize_content_view(true)
.with_titlebar_shown(false)
.with_title_shown(false)
// Started for an agent (`--control`): open without taking the user's keyboard focus.
.with_active(control_port.is_none())
.with_icon(app_icon())
@@ -132,8 +140,8 @@ fn main() -> eframe::Result {
wgpu_options: wgpu_options(launch.backends),
..Default::default()
};
// Start-up milestones go to stderr and the session log, so a window that never appears
// shows how far start-up got (#234).
// Start-up milestones: with `RUST_LOG=info` they go to stderr, so a window that never
// appears shows how far start-up got (#234).
log::info!("effectcraft {}: opening the window", env!("CARGO_PKG_VERSION"));
// Registered before the event loop starts, so it catches the Finder event that launched us as
// well as later ones. Lives until the event loop returns; the app creator only borrows it.
@@ -182,14 +190,11 @@ fn main() -> eframe::Result {
session.config = Some(std::sync::Arc::new(effectcraft_engine::config::DirConfig::new(dir)));
}
session.load_settings();
// Fit the window to its monitor, or put it back where it was (still hidden).
let saved_window = session.config.as_deref().and_then(window::load);
let opened = cc.winit_window().map(|w| window::restore(w, saved_window));
let recovery = if control_port.is_none() { session.begin_recovery() } else { None };
let project = files.iter().find(|f| is_project_file(f)).cloned();
if let Some(p) = project {
if let Err(e) = session.execute("file.open", json!({"path": p})) {
log::warn!("{p}: {e}");
eprintln!("effectcraft: {e}");
}
} else if demo {
let _ = session.execute("file.openDemoProject", json!({}));
@@ -198,13 +203,15 @@ fn main() -> eframe::Result {
if !media.is_empty()
&& let Err(e) = session.execute("file.import", json!({"paths": media}))
{
log::warn!("import: {e}");
eprintln!("effectcraft: {e}");
}
let show_home = home.unwrap_or(session.prefs.startup.show_home_on_launch && files.is_empty() && control_port.is_none());
// The ScriptUI panels open when the app last quit open again (a failing one is
// reported in a toast and forgotten).
let _ = session.execute("window.restoreScriptPanels", json!({}));
let mut app = EffectcraftApp::new(session);
app.custom_titlebar = custom_titlebar;
app.integrated_titlebar = cfg!(target_os = "macos");
app.set_gpu_failure_bridge(gpu_failures);
app.ui.start_screen = show_home;
if let Some(n) = notice {
@@ -279,7 +286,6 @@ fn main() -> eframe::Result {
#[cfg(target_os = "macos")]
apple_events: apple_events.connect(&cc.egui_ctx),
app,
window: window::Tracker::new(opened, saved_window, control_port.is_none()),
launch,
drawn: false,
}))
@@ -331,12 +337,8 @@ fn startup_failed(e: &eframe::Error, gl_next: bool) {
let message = format!(
"EffectCraft couldn't start its window: {e}\n\n{next}Updating the graphics driver often helps. If it keeps happening, please report it at https://github.com/storytold/effectcraft/issues with this message."
);
// Standard error and the session log; standard error alone when RUST_LOG turned errors off.
if log::log_enabled!(log::Level::Error) {
log::error!("{message}");
} else {
eprintln!("effectcraft: {message}");
}
log::error!("{message}");
eprintln!("effectcraft: {message}");
let _ = rfd::MessageDialog::new()
.set_level(rfd::MessageLevel::Error)
.set_title("EffectCraft")
@@ -353,8 +355,6 @@ struct Desktop {
/// Documents opened from Finder, the Dock or `open` (macOS).
#[cfg(target_os = "macos")]
apple_events: apple_events::Opened,
/// The window's size and position, saved for the next launch.
window: window::Tracker,
launch: launch_guard::Launch,
/// The first frame was drawn (logged once).
drawn: bool,
@@ -369,7 +369,6 @@ impl eframe::App for Desktop {
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
self.app.ui(ui, frame);
self.window.frame(ui.ctx(), self.app.session.config.as_deref());
if !self.drawn {
self.drawn = true;
log::info!("first frame drawn");
@@ -383,7 +382,6 @@ impl eframe::App for Desktop {
fn on_exit(&mut self) {
self.launch.exited();
self.window.save(self.app.session.config.as_deref());
self.app.on_exit();
}
@@ -424,7 +422,20 @@ fn disable_app_nap() {
#[cfg(test)]
mod tests {
use super::{Args, parse_args};
use super::{Args, custom_titlebar, parse_args};
#[test]
fn the_system_title_bar_setting_keeps_the_window_decorations() {
let dir = std::env::temp_dir().join(format!("ec-titlebar-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join(effectcraft_engine::prefs::PREFS_FILE);
assert_eq!(custom_titlebar(None), super::CUSTOM_TITLEBAR, "no settings folder: the default");
std::fs::write(&file, "not json").unwrap();
assert_eq!(custom_titlebar(Some(&dir)), super::CUSTOM_TITLEBAR, "an unreadable file: the default");
std::fs::write(&file, r#"{"appearance":{"systemTitleBar":true}}"#).unwrap();
assert!(!custom_titlebar(Some(&dir)));
std::fs::remove_dir_all(&dir).ok();
}
fn parse(args: &[&str]) -> Option<Args> {
parse_args(args.iter().map(|a| a.to_string()), None)
-371
View File
@@ -1,371 +0,0 @@
//! The main window's size and position. The first launch fits the window to its monitor, opening
//! it maximized where the default size leaves no room for a taskbar or Dock. Later launches restore
//! the size, position and maximized state saved in the config directory (`window.json`), moved
//! onto a monitor that is still connected.
use effectcraft_engine::config::ConfigStore;
use serde::{Deserialize, Serialize};
use winit::dpi::{LogicalSize, PhysicalPosition, PhysicalSize};
const FILE: &str = "window.json";
const MAX_BYTES: usize = 4096;
/// Seconds a window opened maximized gets to say so before its size is tracked.
const MAXIMIZE_WAIT: f64 = 2.0;
/// The window's geometry between launches.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct WindowGeometry {
/// The outer top-left corner in physical pixels; `None` where the system doesn't say (Wayland).
pub pos: Option<[i32; 2]>,
/// The inner size in points; while maximized, the size to return to.
pub size: [f32; 2],
pub maximized: bool,
}
/// The first window's size, in points.
pub const DEFAULT_SIZE: [f32; 2] = [1680.0, 1020.0];
/// The smallest window, in points.
pub const MIN_SIZE: [f32; 2] = [960.0, 600.0];
/// Room the first window leaves around itself for a taskbar, Dock or menu bar (the system doesn't
/// say how much of the screen they take): a monitor with less room opens it maximized.
const SCREEN_ROOM: [f32; 2] = [64.0, 128.0];
/// A monitor: its top-left corner and size in physical pixels, and its scale factor.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Monitor {
pos: [i64; 2],
size: [i64; 2],
scale: f32,
}
impl Monitor {
pub fn new(pos: PhysicalPosition<i32>, size: PhysicalSize<u32>, scale: f64) -> Self {
let scale = scale as f32;
let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
Self { pos: [pos.x.into(), pos.y.into()], size: [size.width.into(), size.height.into()], scale }
}
/// Its size in points.
fn points(&self) -> [f32; 2] {
self.size.map(|s| s as f32 / self.scale)
}
/// Its size in points less room for a taskbar, Dock or menu bar.
fn usable(&self) -> [f32; 2] {
let points = self.points();
std::array::from_fn(|i| points[i] - SCREEN_ROOM[i])
}
/// A window of `size` points on this monitor, in physical pixels.
fn pixels(&self, size: [f32; 2]) -> [i64; 2] {
size.map(|s| (s * self.scale) as i64)
}
/// How much of a window at `pos` of `size` points this monitor shows, in square pixels.
fn overlap(&self, pos: [i32; 2], size: [f32; 2]) -> i64 {
let px = self.pixels(size);
let span = |i: usize| {
let start = i64::from(pos[i]).max(self.pos[i]);
let end = i64::from(pos[i]).saturating_add(px[i]).min(self.pos[i] + self.size[i]);
(end - start).max(0)
};
span(0).saturating_mul(span(1))
}
/// The top-left corner nearest to `pos` (centred when none) that keeps a window of `size`
/// points on this monitor.
fn keep_on(&self, pos: Option<[i32; 2]>, size: [f32; 2]) -> [i32; 2] {
let px = self.pixels(size);
std::array::from_fn(|i| {
let lo = self.pos[i];
let hi = lo + self.size[i].saturating_sub(px[i]).max(0);
let want = pos.map_or(lo + (hi - lo) / 2, |p| i64::from(p[i]));
i32::try_from(want.max(lo).min(hi)).unwrap_or_default()
})
}
}
/// Where the window opens: `saved` (the last session's geometry) kept on a connected monitor, else
/// the default size on `home` (the monitor the system opens it on). Without monitors (the system
/// doesn't list them, so nothing to check a saved geometry against) the default size.
pub fn place(saved: Option<WindowGeometry>, monitors: &[Monitor], home: Option<usize>) -> WindowGeometry {
let default = WindowGeometry { pos: None, size: DEFAULT_SIZE, maximized: false };
let Some(home) = home.and_then(|i| monitors.get(i)).or(monitors.first()) else {
return default;
};
let Some(saved) = saved else {
let usable = home.usable();
if (0..2).all(|i| DEFAULT_SIZE[i] <= usable[i]) {
return default;
}
// A small screen: maximized, with a size that fits once un-maximized.
let size = fit(DEFAULT_SIZE, usable);
return WindowGeometry { pos: Some(home.keep_on(None, size)), size, maximized: true };
};
// The monitor showing most of the window; none when it was on a monitor that is gone.
let shown = saved.pos.and_then(|p| {
let (area, m) = monitors.iter().map(|m| (m.overlap(p, saved.size), m)).max_by_key(|(area, _)| *area)?;
(area > 0).then_some(m)
});
let m = shown.unwrap_or(home);
// Bigger than its monitor (a smaller screen than last time): maximized, like the first window.
let room = m.points();
let too_big = (0..2).any(|i| saved.size[i] > room[i]);
let size = fit(saved.size, if too_big { m.usable() } else { room });
WindowGeometry {
// Moved fully onto its monitor, or centred on the home monitor.
pos: saved.pos.map(|p| m.keep_on(shown.map(|_| p), size)),
size,
maximized: saved.maximized || too_big,
}
}
/// `size` no bigger than `room`, and no smaller than the smallest window unless `room` is.
fn fit(size: [f32; 2], room: [f32; 2]) -> [f32; 2] {
std::array::from_fn(|i| size[i].min(room[i]).max(MIN_SIZE[i].min(room[i])).max(1.0))
}
/// Size and place the new window (eframe shows it after drawing the first frame) and return its
/// geometry.
pub fn restore(window: &winit::window::Window, saved: Option<WindowGeometry>) -> WindowGeometry {
let handles: Vec<_> = window.available_monitors().collect();
let monitors: Vec<Monitor> = handles.iter().map(|m| Monitor::new(m.position(), m.size(), m.scale_factor())).collect();
let home = window.current_monitor().or_else(|| window.primary_monitor()).and_then(|c| handles.iter().position(|m| *m == c));
let g = place(saved, &monitors, home);
if let Some([x, y]) = g.pos {
window.set_outer_position(PhysicalPosition::new(x, y));
}
// Returns the new size when it applies at once; either way the window gets it.
let _ = window.request_inner_size(LogicalSize::new(g.size[0], g.size[1]));
if g.maximized {
window.set_maximized(true);
}
g
}
/// Per frame: remember the window's geometry for the next launch. Minimized or full screen keeps
/// the last one; maximized only sets its flag, so un-maximizing next time restores the last size.
pub fn track(ctx: &egui::Context, geometry: &mut Option<WindowGeometry>) {
let zoom = ctx.zoom_factor();
ctx.input(|i| {
let v = i.viewport();
if v.minimized == Some(true) || v.fullscreen == Some(true) {
return;
}
if v.maximized == Some(true) {
if let Some(g) = geometry {
g.maximized = true;
}
return;
}
let Some(inner) = v.inner_rect else { return };
let pos = v.outer_rect.map(|r| [r.min.x, r.min.y].map(|c| (c * i.pixels_per_point).round() as i32));
*geometry = Some(WindowGeometry { pos, size: [inner.width() * zoom, inner.height() * zoom], maximized: false });
});
}
/// The geometry saved by the last session; `None` when there is none or it can't be read.
pub fn load(config: &dyn ConfigStore) -> Option<WindowGeometry> {
let text = config.read_bounded(FILE, MAX_BYTES).ok()??;
serde_json::from_str(&text).ok()
}
/// Follows the window and saves its geometry for the next launch: when it has changed (at most
/// once a second, so a session that ends badly still has it) and on exit.
pub struct Tracker {
geometry: Option<WindowGeometry>,
saved: Option<WindowGeometry>,
last_attempt: f64,
/// Opened maximized, and the system hasn't reported it so yet (when the wait ends): the
/// window's first frames report the maximized size as an ordinary one, which would replace
/// the size to return to.
maximizing: Option<f64>,
/// Agent-driven runs (`--control`) leave the user's saved window alone.
save: bool,
}
impl Tracker {
/// `geometry`: what the window opened with.
pub fn new(geometry: Option<WindowGeometry>, saved: Option<WindowGeometry>, save: bool) -> Self {
let maximizing = geometry.is_some_and(|g| g.maximized).then_some(f64::INFINITY);
Self { geometry, saved, last_attempt: f64::NEG_INFINITY, maximizing, save }
}
/// Per frame.
pub fn frame(&mut self, ctx: &egui::Context, config: Option<&dyn ConfigStore>) {
let (now, maximized) = ctx.input(|i| (i.time, i.viewport().maximized == Some(true)));
if let Some(until) = self.maximizing {
// A system that never maximizes the window is believed after a moment.
let until = until.min(now + MAXIMIZE_WAIT);
self.maximizing = (!maximized && now < until).then_some(until);
}
if self.maximizing.is_none() {
track(ctx, &mut self.geometry);
}
if now - self.last_attempt >= 1.0 {
self.last_attempt = now;
self.save(config);
}
}
/// Write the geometry when it differs from the saved one.
pub fn save(&mut self, config: Option<&dyn ConfigStore>) {
let (Some(config), Some(g)) = (config, self.geometry) else { return };
if !self.save || self.saved == Some(g) {
return;
}
match serde_json::to_string(&g).map_err(|e| e.to_string()).and_then(|text| config.write(FILE, &text).map_err(|e| e.to_string())) {
Ok(()) => self.saved = Some(g),
Err(e) => log::warn!("could not save the window geometry: {e}"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use effectcraft_engine::config::MemoryConfig;
fn monitor(x: i32, y: i32, w: u32, h: u32, scale: f64) -> Monitor {
Monitor::new(PhysicalPosition::new(x, y), PhysicalSize::new(w, h), scale)
}
fn saved(pos: Option<[i32; 2]>, size: [f32; 2], maximized: bool) -> Option<WindowGeometry> {
Some(WindowGeometry { pos, size, maximized })
}
#[test]
fn the_first_window_fits_its_screen() {
// 2560 × 1440: the default size, where the system puts it.
let big = [monitor(0, 0, 2560, 1440, 1.0)];
assert_eq!(place(None, &big, Some(0)), WindowGeometry { pos: None, size: DEFAULT_SIZE, maximized: false });
// 1920 × 1080, 1600 × 900, 1366 × 768 and 1920 × 1080 at 150% (1280 × 720 points):
// maximized, and a size that fits the screen with room for the taskbar once un-maximized.
for (m, size) in [
(monitor(0, 0, 1920, 1080, 1.0), [1680.0, 952.0]),
(monitor(0, 0, 1600, 900, 1.0), [1536.0, 772.0]),
(monitor(0, 0, 1366, 768, 1.0), [1302.0, 640.0]),
(monitor(0, 0, 1920, 1080, 1.5), [1216.0, 592.0]),
] {
let g = place(None, &[m], Some(0));
assert!(g.maximized, "{m:?}");
assert_eq!(g.size, size, "{m:?}");
let [x, y] = g.pos.unwrap();
let px = m.pixels(g.size);
assert!(x >= 0 && y >= 0 && i64::from(x) + px[0] <= m.size[0] && i64::from(y) + px[1] <= m.size[1], "{m:?}: {g:?}");
}
// No monitor information: the default size, whatever was saved.
assert_eq!(place(None, &[], None), WindowGeometry { pos: None, size: DEFAULT_SIZE, maximized: false });
assert_eq!(place(saved(Some([9000, 9000]), [1e9, 1e9], true), &[], None).size, DEFAULT_SIZE);
}
#[test]
fn the_saved_window_comes_back_where_it_was() {
let two = [monitor(0, 0, 2560, 1440, 1.0), monitor(2560, 0, 1920, 1080, 1.0)];
for g in [saved(Some([100, 80]), [1200.0, 800.0], false), saved(Some([2700, 50]), [1000.0, 700.0], true), saved(None, [1000.0, 700.0], false)] {
assert_eq!(Some(place(g, &two, Some(0))), g);
}
}
#[test]
fn a_saved_window_stays_on_a_connected_monitor() {
let laptop = [monitor(0, 0, 1600, 900, 1.0)];
// Last time on a second monitor that is gone: centred on the remaining one.
let g = place(saved(Some([2700, 50]), [1000.0, 700.0], false), &laptop, Some(0));
assert_eq!(g, WindowGeometry { pos: Some([300, 100]), size: [1000.0, 700.0], maximized: false });
// Half off the monitor's right and bottom edges: moved fully onto it.
let g = place(saved(Some([1200, 600]), [1000.0, 700.0], false), &laptop, Some(0));
assert_eq!(g.pos, Some([600, 200]));
// Bigger than the screen now (it was on a larger one): it fits, and opens maximized.
let g = place(saved(Some([0, 0]), [2400.0, 1300.0], false), &laptop, Some(0));
assert_eq!(g, WindowGeometry { pos: Some([0, 0]), size: [1536.0, 772.0], maximized: true });
// A monitor left of the primary one (negative coordinates) still counts.
let left = [monitor(0, 0, 1920, 1080, 1.0), monitor(-1920, 0, 1920, 1080, 1.0)];
assert_eq!(place(saved(Some([-1800, 40]), [1000.0, 700.0], false), &left, Some(0)).pos, Some([-1800, 40]));
// Nonsense sizes from a damaged preferences file are kept within the screen.
let g = place(saved(Some([i32::MAX, i32::MIN]), [-5.0, f32::MAX], false), &laptop, Some(0));
assert_eq!(g, WindowGeometry { pos: Some([320, 64]), size: [960.0, 772.0], maximized: true });
let g = place(saved(Some([5, 5]), [f32::NEG_INFINITY, f32::INFINITY], false), &laptop, Some(0));
assert_eq!(g, WindowGeometry { pos: Some([320, 64]), size: [960.0, 772.0], maximized: true });
}
#[test]
fn the_window_geometry_is_tracked_for_the_next_launch() {
let ctx = egui::Context::default();
let mut geometry = None;
let mut frame = |info: egui::ViewportInfo| {
let mut input = egui::RawInput::default();
input.viewports.insert(egui::ViewportId::ROOT, info);
ctx.run_ui(input, |ui| track(ui.ctx(), &mut geometry)).textures_delta.clear();
geometry
};
let rect = egui::Rect::from_min_size(egui::pos2(20.0, 30.0), egui::vec2(1000.0, 600.0));
let normal = egui::ViewportInfo {
native_pixels_per_point: Some(1.5),
inner_rect: Some(rect),
outer_rect: Some(rect),
maximized: Some(false),
..Default::default()
};
// Position in physical pixels, size in points.
let g = WindowGeometry { pos: Some([30, 45]), size: [1000.0, 600.0], maximized: false };
assert_eq!(frame(normal.clone()), Some(g));
// Maximized or minimized: the size to restore stays.
let big = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 700.0));
let maximized = egui::ViewportInfo { inner_rect: Some(big), outer_rect: Some(big), maximized: Some(true), ..normal.clone() };
assert_eq!(frame(maximized), Some(WindowGeometry { maximized: true, ..g }));
let minimized = egui::ViewportInfo { inner_rect: None, outer_rect: None, minimized: Some(true), ..normal.clone() };
assert_eq!(frame(minimized), Some(WindowGeometry { maximized: true, ..g }));
assert_eq!(frame(normal), Some(g));
}
/// A window restored maximized keeps the size it returns to, though its first frames report
/// the maximized size as an ordinary one.
#[test]
fn opening_maximized_keeps_the_size_to_return_to() {
let ctx = egui::Context::default();
let g = WindowGeometry { pos: Some([40, 30]), size: [1000.0, 600.0], maximized: true };
let mut tracker = Tracker::new(Some(g), Some(g), true);
let big = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1728.0, 1052.0));
let small = egui::Rect::from_min_size(egui::pos2(40.0, 30.0), egui::vec2(900.0, 500.0));
let frame = |tracker: &mut Tracker, time: f64, rect: egui::Rect, maximized: bool| {
let info = egui::ViewportInfo { inner_rect: Some(rect), outer_rect: Some(rect), maximized: Some(maximized), ..Default::default() };
let mut input = egui::RawInput { time: Some(time), ..Default::default() };
input.viewports.insert(egui::ViewportId::ROOT, info);
ctx.run_ui(input, |ui| tracker.frame(ui.ctx(), None)).textures_delta.clear();
tracker.geometry
};
assert_eq!(frame(&mut tracker, 0.0, big, false), Some(g));
assert_eq!(frame(&mut tracker, 0.5, big, true), Some(g));
// Un-maximized by the user afterwards: tracked as usual.
let restored = WindowGeometry { pos: Some([40, 30]), size: [900.0, 500.0], maximized: false };
assert_eq!(frame(&mut tracker, 0.6, small, false), Some(restored));
// A system that doesn't maximize: the window's real size is tracked after the wait.
let mut tracker = Tracker::new(Some(g), Some(g), true);
assert_eq!(frame(&mut tracker, 10.0, small, false), Some(g));
assert_eq!(frame(&mut tracker, 10.0 + MAXIMIZE_WAIT, small, false), Some(restored));
}
/// The reported bug: the window opened at the default size and place on every launch.
#[test]
fn the_window_geometry_is_saved_and_loaded() {
let config = MemoryConfig::default();
assert_eq!(load(&config), None);
let g = WindowGeometry { pos: Some([30, 45]), size: [1000.0, 600.0], maximized: true };
let mut tracker = Tracker::new(Some(g), None, true);
tracker.save(Some(&config));
assert_eq!(load(&config), Some(g));
// Positions the system doesn't report (Wayland) round-trip too.
tracker.geometry = Some(WindowGeometry { pos: None, ..g });
tracker.save(Some(&config));
assert_eq!(load(&config), Some(WindowGeometry { pos: None, ..g }));
// An agent-driven run leaves the saved window alone.
Tracker::new(Some(g), None, false).save(Some(&config));
assert_eq!(load(&config).and_then(|g| g.pos), None);
// A damaged or oversized file is ignored: the window opens at its default.
for text in ["{", "null", "{\"pos\":[1],\"size\":[1,2],\"maximized\":false}", &" ".repeat(MAX_BYTES + 1)] {
config.write(FILE, text).unwrap();
assert_eq!(load(&config), None, "{text:.20}");
}
}
}
+1 -1
View File
@@ -2,7 +2,7 @@ EffectCraft app icon (effectcraft.svg, effectcraft-small.svg and every file deri
this directory: effectcraft-1024.png, effectcraft-macos-512.png, effectcraft.icns, effectcraft.ico,
hicolor/), and the web favicon apps/effectcraft-web/web/favicon.svg.
Original artwork made for this project by the project owner in ArtCraft, then vectorised (no
Original artwork made for this project by the project owner by hand, then vectorised (no
third-party material). Licensed like the rest of EffectCraft, under either of
* Apache License, Version 2.0 (https://www.apache.org/licenses/LICENSE-2.0)
+1 -1
View File
@@ -15,7 +15,7 @@ full-bleed colour field, tight head-and-shoulders crop, the body running off the
| Paper | `#efe9dc` | the figure |
| Ink | `#0b0b0c` | line work and contour |
**Provenance:** the project owner drew it in ArtCraft (a 2880 px engraving-style image, kept in
**Provenance:** the project owner drew it by hand (a 2880 px engraving-style image, kept in
craftrules at `assets/app-icons/effectcraft/source.png`). It was vectorised with craftrules
`assets/logo-options/_tools/vectorize_tile.py`, which keys out the field and traces the ink with potrace.
Licence: see [LICENSE.txt](LICENSE.txt) (MIT OR Apache-2.0, like the repo).
@@ -1,6 +1,6 @@
asset: effectcraft-1024.png
title: EffectCraft app icon, 1024 px
author: EffectCraft contributors (drawing by the project owner in ArtCraft)
author: EffectCraft contributors (drawing by the project owner)
source: original work
license: MIT OR Apache-2.0
license-file: assets/app-icon/LICENSE.txt
@@ -1,6 +1,6 @@
asset: effectcraft-macos-512.png
title: EffectCraft runtime Dock icon (macOS)
author: EffectCraft contributors (drawing by the project owner in ArtCraft)
author: EffectCraft contributors (drawing by the project owner)
source: original work
license: MIT OR Apache-2.0
license-file: assets/app-icon/LICENSE.txt
@@ -1,6 +1,6 @@
asset: effectcraft-small.svg
title: EffectCraft app icon, light vector
author: EffectCraft contributors (drawing by the project owner in ArtCraft)
author: EffectCraft contributors (drawing by the project owner)
source: original work
license: MIT OR Apache-2.0
license-file: assets/app-icon/LICENSE.txt
+1 -1
View File
@@ -1,6 +1,6 @@
asset: effectcraft.icns
title: EffectCraft macOS app icon
author: EffectCraft contributors (drawing by the project owner in ArtCraft)
author: EffectCraft contributors (drawing by the project owner)
source: original work
license: MIT OR Apache-2.0
license-file: assets/app-icon/LICENSE.txt
+1 -1
View File
@@ -1,6 +1,6 @@
asset: effectcraft.ico
title: EffectCraft Windows app icon
author: EffectCraft contributors (drawing by the project owner in ArtCraft)
author: EffectCraft contributors (drawing by the project owner)
source: original work
license: MIT OR Apache-2.0
license-file: assets/app-icon/LICENSE.txt
+2 -2
View File
@@ -1,8 +1,8 @@
asset: effectcraft.svg
title: EffectCraft app icon, master vector
author: EffectCraft contributors (drawing by the project owner in ArtCraft)
author: EffectCraft contributors (drawing by the project owner)
source: original work
license: MIT OR Apache-2.0
license-file: assets/app-icon/LICENSE.txt
added: 2026-10-02 by EffectCraft contributors (Claude agent, app icon)
notes: The owner's ArtCraft drawing of an engraved unicorn bust, vectorised at 2048 px with craftrules assets/logo-options/_tools/vectorize_tile.py (potrace; three colours). Canonical artwork; the other icon files derive from it.
notes: The owner's drawing of an engraved unicorn bust, vectorised at 2048 px with craftrules assets/logo-options/_tools/vectorize_tile.py (potrace; three colours). Canonical artwork; the other icon files derive from it.
@@ -1,6 +1,6 @@
asset: ai.storyteller.effectcraft.png
title: EffectCraft app icon, Linux hicolor 128x128
author: EffectCraft contributors (drawing by the project owner in ArtCraft)
author: EffectCraft contributors (drawing by the project owner)
source: original work
license: MIT OR Apache-2.0
license-file: assets/app-icon/LICENSE.txt
@@ -1,6 +1,6 @@
asset: ai.storyteller.effectcraft.png
title: EffectCraft app icon, Linux hicolor 16x16
author: EffectCraft contributors (drawing by the project owner in ArtCraft)
author: EffectCraft contributors (drawing by the project owner)
source: original work
license: MIT OR Apache-2.0
license-file: assets/app-icon/LICENSE.txt
@@ -1,6 +1,6 @@
asset: ai.storyteller.effectcraft.png
title: EffectCraft app icon, Linux hicolor 24x24
author: EffectCraft contributors (drawing by the project owner in ArtCraft)
author: EffectCraft contributors (drawing by the project owner)
source: original work
license: MIT OR Apache-2.0
license-file: assets/app-icon/LICENSE.txt
@@ -1,6 +1,6 @@
asset: ai.storyteller.effectcraft.png
title: EffectCraft app icon, Linux hicolor 256x256
author: EffectCraft contributors (drawing by the project owner in ArtCraft)
author: EffectCraft contributors (drawing by the project owner)
source: original work
license: MIT OR Apache-2.0
license-file: assets/app-icon/LICENSE.txt
@@ -1,6 +1,6 @@
asset: ai.storyteller.effectcraft.png
title: EffectCraft app icon, Linux hicolor 32x32
author: EffectCraft contributors (drawing by the project owner in ArtCraft)
author: EffectCraft contributors (drawing by the project owner)
source: original work
license: MIT OR Apache-2.0
license-file: assets/app-icon/LICENSE.txt
@@ -1,6 +1,6 @@
asset: ai.storyteller.effectcraft.png
title: EffectCraft app icon, Linux hicolor 48x48
author: EffectCraft contributors (drawing by the project owner in ArtCraft)
author: EffectCraft contributors (drawing by the project owner)
source: original work
license: MIT OR Apache-2.0
license-file: assets/app-icon/LICENSE.txt
@@ -1,6 +1,6 @@
asset: ai.storyteller.effectcraft.png
title: EffectCraft app icon, Linux hicolor 512x512
author: EffectCraft contributors (drawing by the project owner in ArtCraft)
author: EffectCraft contributors (drawing by the project owner)
source: original work
license: MIT OR Apache-2.0
license-file: assets/app-icon/LICENSE.txt
@@ -1,6 +1,6 @@
asset: ai.storyteller.effectcraft.png
title: EffectCraft app icon, Linux hicolor 64x64
author: EffectCraft contributors (drawing by the project owner in ArtCraft)
author: EffectCraft contributors (drawing by the project owner)
source: original work
license: MIT OR Apache-2.0
license-file: assets/app-icon/LICENSE.txt
@@ -1,6 +1,6 @@
asset: ai.storyteller.effectcraft.svg
title: EffectCraft app icon, Linux hicolor scalable
author: EffectCraft contributors (drawing by the project owner in ArtCraft)
author: EffectCraft contributors (drawing by the project owner)
source: original work
license: MIT OR Apache-2.0
license-file: assets/app-icon/LICENSE.txt
-10
View File
@@ -54,13 +54,3 @@ sync_github_name = false
real_name = "Vikram Bhaskaran"
display_name = "Vikram Bhaskaran"
sync_github_name = false
[people.vangel76]
real_name = "Valentin Klein"
display_name = "Valentin Klein"
[people.Losminek]
sync_github_name = true
[people.webdevtodayjason]
display_name = "Jason Brashear"
-6
View File
@@ -681,12 +681,6 @@ fn list_fonts_lists_families_with_styles() {
assert!(matched.len() < fams.len(), "the query filters: {one}");
let mono = matched.iter().find(|f| f["family"] == "JetBrains Mono").unwrap_or_else(|| panic!("{one}"));
assert!(mono["styles"].as_array().unwrap().contains(&json!("Regular")), "{one}");
assert_eq!(mono["variable"], false, "{one}");
// `variable` keeps only the variable fonts (the font menu's filter); the static bundled
// families drop out.
let var = call_json(&mut s, "list_fonts", json!({"variable": true}));
let listed = var["families"].as_array().unwrap();
assert!(listed.iter().all(|f| f["variable"] == true && f["family"] != "Inter"), "{var}");
}
/// Trained models over MCP (Roto Brush and face tracking): listed with their authors and
+2 -3
View File
@@ -210,7 +210,7 @@ fn list_effects(b: &mut Backend, a: &Value) -> Result<Reply> {
}
fn list_fonts(b: &mut Backend, a: &Value) -> Result<Reply> {
json_reply(b.exec("text.fonts", obj(&[("query", get(a, "query")), ("variable", get(a, "variable")), ("rescan", get(a, "rescan"))]))?)
json_reply(b.exec("text.fonts", obj(&[("query", get(a, "query")), ("rescan", get(a, "rescan"))]))?)
}
/// `effect.apply` on one layer, then set parameters on the new instance by param id: one batch,
@@ -703,13 +703,12 @@ static TOOLS: &[ToolDef] = &[
},
ToolDef {
name: "list_fonts",
description: "The font families text layers can use, bundled and installed on this machine: family, styles, origin (bundled / system / user), whether it is a variable font and the name in the font's own language. Narrow with `query` and `variable`; `rescan` picks up fonts installed since launch. Set one with execute_command `layer.setText {\"layer\", \"font\", \"style\"}`.",
description: "The font families text layers can use, bundled and installed on this machine: family, styles, origin (bundled / system / user) and the name in the font's own language. Narrow with `query`; `rescan` picks up fonts installed since launch. Set one with execute_command `layer.setText {\"layer\", \"font\", \"style\"}`.",
bridge_only: false,
schema: || {
schema(
json!({
"query": {"type": "string", "description": "Only families whose name (English or own-language) contains this text."},
"variable": {"type": "boolean", "description": "Only variable fonts (families with variation axes)."},
"rescan": {"type": "boolean", "description": "Look for fonts installed since launch first."}
}),
&[],
+3 -51
View File
@@ -323,40 +323,11 @@ fn wild(pat: &str, name: &str) -> bool {
}
}
/// Split a selection at its commas. A name in double or single quotes stays whole, commas
/// included (Blender allows `Green, Glossy`), and a backslash takes the next character
/// literally (`Green\, Glossy`, `\"`). Unquoted text is trimmed; the quotes are dropped.
fn selection_names(sel: &str) -> Vec<String> {
let mut out = vec![];
let mut cur = String::new();
let mut quote: Option<char> = None;
let mut chars = sel.chars();
while let Some(c) = chars.next() {
match (c, quote) {
('\\', _) => {
if let Some(next) = chars.next() {
cur.push(next);
}
}
(c, Some(q)) if c == q => quote = None,
('"' | '\'', None) if cur.trim().is_empty() => {
cur.clear();
quote = Some(c);
}
(',', None) => out.push(std::mem::take(&mut cur).trim().to_string()),
(c, _) => cur.push(c),
}
}
out.push(cur.trim().to_string());
out
}
/// Parse a selection: comma-separated names (optionally quoted, see [`selection_names`]) or
/// `<hex>` hashes / wildcards.
/// Parse a selection: comma-separated names (optionally quoted) or `<hex>` hashes / wildcards.
pub fn selection_hashes(sel: &str, manifest: Option<&[(String, u32)]>) -> Vec<u32> {
let mut out = vec![];
for t in selection_names(sel) {
let t = t.as_str();
for tok in sel.split(',') {
let t = tok.trim().trim_matches('"').trim_matches('\'');
if t.is_empty() {
continue;
}
@@ -706,25 +677,6 @@ mod tests {
assert_eq!(selection_hashes(&hex, None), vec![crypto_hash("left")]);
}
/// #473: a name with a comma in it (Blender allows `Green, Glossy`) is selected when it is
/// quoted or its comma escaped, instead of being cut into two names that match nothing.
#[test]
fn cryptomatte_selection_keeps_quoted_names_with_commas_whole() {
let manifest = vec![("Green, Glossy".to_string(), 7), ("Green".to_string(), 1), ("Glossy".to_string(), 2), ("ring".to_string(), 3)];
let m = Some(manifest.as_slice());
assert_eq!(selection_hashes(r#""Green, Glossy""#, m), vec![7]);
assert_eq!(selection_hashes("'Green, Glossy', ring", m), vec![7, 3]);
assert_eq!(selection_hashes(r"Green\, Glossy", m), vec![7]);
assert_eq!(selection_hashes(r#"ring , "Green, Glossy""#, m), vec![3, 7]);
// Without a manifest the whole name is hashed.
assert_eq!(selection_hashes(r#""Green, Glossy""#, None), vec![crypto_hash("Green, Glossy")]);
// Plain lists, apostrophes inside names and an unclosed quote still parse.
assert_eq!(selection_hashes("Green, Glossy", m), vec![1, 2]);
assert_eq!(selection_names("Bob's mat, ring"), vec!["Bob's mat", "ring"]);
assert_eq!(selection_names(r#""Green, Glossy"#), vec!["Green, Glossy"]);
assert_eq!(selection_names(r#"say \"hi\""#), vec![r#"say "hi""#]);
}
#[test]
fn fog_and_depth_of_field_follow_depth() {
let h = scene();
+6 -45
View File
@@ -43,27 +43,16 @@ fn tritone(ctx: &EffectCtx, mut b: Buf) -> Buf {
b
}
/// Brightness & Contrast without Use Legacy: pin black and white, then a mild S-curve.
///
/// Fitted to After Effects non-legacy grey ramps (#681, kasper9n oracles). Brightness is a
/// power toward white (`1-(1-v)^e`) or black (`v^e`); contrast uses `g = 1 + 0.53 c` instead of
/// the old `2^(2c)` (that crushed 0.25 to ~8/255 at Contrast 100; AE is ~40/255). Values above 1
/// pass through. The GPU kernel mirrors it.
/// Brightness & Contrast without Use Legacy: tone curves that keep black and white fixed (no
/// clipping). Brightness bends the curve with a power (`b` in -1.5..1.5), contrast is an S
/// curve around mid grey (`c` in -1..1). Values above 1 pass through. The GPU kernel mirrors it.
pub fn bc_modern(v: f32, b: f32, c: f32) -> f32 {
if v >= 1.0 {
return v;
}
let mut v = v.max(0.0);
if b > 0.0 {
v = 1.0 - (1.0 - v).powf(2f32.powf(1.24 * b));
} else if b < 0.0 {
v = v.powf(2f32.powf(-1.32 * b));
}
if c != 0.0 {
let g = (1.0 + 0.53 * c).max(0.05);
v = if v < 0.5 { 0.5 * (2.0 * v).powf(g) } else { 1.0 - 0.5 * (2.0 - 2.0 * v).max(0.0).powf(g) };
}
v
let v = v.max(0.0).powf(2f32.powf(-b));
let g = 2f32.powf(2.0 * c);
if v < 0.5 { 0.5 * (2.0 * v).powf(g) } else { 1.0 - 0.5 * (2.0 - 2.0 * v).max(0.0).powf(g) }
}
fn brightness_contrast(ctx: &EffectCtx, mut b: Buf) -> Buf {
@@ -1192,34 +1181,6 @@ mod tests {
assert!((bc(&legacy, 0.0) - 0.5).abs() < 1e-5);
}
/// After Effects non-legacy ramps (kasper9n, #681). B0 C-100 matched B0 C-50 and is omitted.
#[test]
fn brightness_contrast_modern_matches_ae_ramp_samples() {
let u8 = |v: f32| (v * 255.0).round() as i32;
let go = |b: f64, c: f64, x: u8| {
u8(px("ec.color.brightnesscontrast", &[("brightness", num(b)), ("contrast", num(c))], [x as f32 / 255.0, x as f32 / 255.0, x as f32 / 255.0, 1.0])
[0])
};
// (brightness, contrast, input, AE 8-bit)
let samples: &[(f64, f64, u8, u8)] = &[
(0.0, 100.0, 64, 40),
(0.0, 100.0, 128, 129),
(0.0, 100.0, 192, 217),
(0.0, 50.0, 64, 53),
(0.0, 50.0, 192, 205),
(0.0, -50.0, 64, 77),
(100.0, 0.0, 128, 209),
(50.0, 0.0, 128, 171),
(106.0, -18.0, 128, 214),
];
for &(b, c, x, ae) in samples {
let got = go(b, c, x);
assert!((got - ae as i32).abs() <= 10, "B{b} C{c} in {x}: got {got} AE {ae}");
}
// Old g=4 S-curve put Contrast 100 at ~8/255 here.
assert!(go(0.0, 100.0, 64) >= 30);
}
#[test]
fn levels_clips_black_and_white_independently() {
let lv = |vals: &[(&str, Value)], v: f32| px("ec.color.levels", vals, [v, v, v, 1.0])[0];
+1 -37
View File
@@ -105,7 +105,7 @@ pub use noise::remove_grain_level;
pub use noise3::{FractalGpu, fractal_gpu};
pub use stylize3::{BumpLight, bump_layer_id};
pub use textfx::{TextLayer, text_layer};
pub use time_fx::{PixelMotion, TimewarpPlan, TwStep, pmb_plan, posterized_time, time_frames, timewarp_crop, timewarp_plan};
pub use time_fx::{PixelMotion, TimewarpPlan, TwStep, posterized_time, time_frames, timewarp_crop, timewarp_plan};
// effectcraft-gpu fx_tone.
pub use ocio::color_stabilizer_maps;
// GPU effects, part B (3D Channel, Immersive Video, colour management, simulation render passes).
@@ -264,21 +264,6 @@ impl Buf {
pub fn to_px(&self, p: [f64; 2]) -> (f64, f64) {
(p[0] * self.scale + self.offset[0], p[1] * self.scale + self.offset[1])
}
/// Grow the buffer so it covers a `size`-layer-pixel region starting at its offset (see
/// [`cover_plan`]): the whole layer rectangle a sized source's buffer already is. Content
/// keeps its place; the new area is transparent. No-op when the buffer already covers it.
pub fn cover(&mut self, size: [f64; 2]) {
let Some((dx, dy, w, h)) = cover_plan(self.offset, self.scale, size, self.img.width, self.img.height) else { return };
let src = std::mem::replace(&mut self.img, Image::new(w, h));
let (sw, sh) = (src.width as usize, src.height as usize);
for y in 0..sh {
let s = y * sw;
let d = (y + dy as usize) * w as usize + dx as usize;
self.img.data[d..d + sw].copy_from_slice(&src.data[s..s + sw]);
}
self.offset[0] += dx as f64;
self.offset[1] += dy as f64;
}
/// Measure from `origin` (in the current coordinates): a point `p` becomes `p − origin`.
/// Moves between layer space and effect space (the renderer's `effect_bounds`).
pub fn rebase(&mut self, origin: [f64; 2]) {
@@ -296,27 +281,6 @@ impl Buf {
}
}
/// Where [`Buf::cover`] would take a buffer: shift its pixels by `(dx, dy)` and resize it to
/// `width` × `height` so it spans the `size`-layer-pixel region that starts at its offset — the
/// whole layer rectangle, which a sized source's buffer already is. `None` when the buffer
/// already covers that region unchanged (footage, solids, the comp below an adjustment).
pub fn cover_plan(offset: [f64; 2], scale: f64, size: [f64; 2], width: u32, height: u32) -> Option<(u32, u32, u32, u32)> {
if scale <= 0.0 || !scale.is_finite() || !offset[0].is_finite() || !offset[1].is_finite() || !size[0].is_finite() || !size[1].is_finite() {
return None;
}
// Region and content, both as pixel ranges in the buffer's own coordinates.
let (rw, rh) = (offset[0] + (size[0] * scale).ceil(), offset[1] + (size[1] * scale).ceil());
let (lo_x, lo_y) = (offset[0].min(0.0).floor(), offset[1].min(0.0).floor());
let (hi_x, hi_y) = (rw.max(width as f64).ceil(), rh.max(height as f64).ceil());
let (nw_f, nh_f) = (hi_x - lo_x, hi_y - lo_y);
if !(nw_f <= 16384.0 && nh_f <= 16384.0) {
return None;
}
let (dx, dy) = ((-lo_x) as u32, (-lo_y) as u32);
let (nw, nh) = (nw_f.round().max(1.0) as u32, nh_f.round().max(1.0) as u32);
(dx != 0 || dy != 0 || nw != width || nh != height).then_some((dx, dy, nw, nh))
}
/// A layer mask at the current time, flattened to a polyline in layer coordinates.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct MaskShape {
+2 -25
View File
@@ -429,8 +429,7 @@ pub fn overflow(v: f32, mode: u32) -> f32 {
}
/// Composite grey noise `val` over the original pixel by Blending Mode, Opacity and (ours) Blend
/// With Original. The noise stays inside the layer's alpha, so a masked solid shows it only within
/// its masks (#939).
/// With Original.
pub(crate) fn composite(orig: [f32; 4], val: f32, opacity: f32, mode: u32, blend_orig: f32) -> [f32; 4] {
let src = [val * opacity, val * opacity, val * opacity, opacity];
let out = match mode {
@@ -441,7 +440,7 @@ pub(crate) fn composite(orig: [f32; 4], val: f32, opacity: f32, mode: u32, blend
}
};
let k = 1.0 - blend_orig;
[0, 1, 2, 3].map(|c| orig[c] * blend_orig + out[c] * orig[3] * k)
[0, 1, 2, 3].map(|c| orig[c] * blend_orig + out[c] * k)
}
fn render(ctx: &EffectCtx, mut b: Buf, turbulent: bool) -> Buf {
@@ -575,28 +574,6 @@ mod tests {
assert!(mul.data.iter().any(|p| p[2] > p[0] + 0.01));
}
#[test]
fn noise_stays_inside_the_layer_alpha() {
// #939: a masked solid (transparent outside the mask, a soft edge) filled the whole layer.
let mut img = Image::filled(40, 24, [0.2, 0.4, 0.6, 1.0]);
for (i, p) in img.data.iter_mut().enumerate() {
let a = match i % 40 {
0..10 => 0.0,
10..20 => 0.5,
_ => 1.0,
};
*p = p.map(|c| c * a);
}
for mode in [BLEND_NONE, BLEND_NORMAL, 3] {
let out = run_fx("ec.noise.fractal", &[("blendingMode", Value::Enum(mode))], img.clone(), 0.0, EffectEnv::default()).img;
for (o, p) in img.data.iter().zip(&out.data) {
assert!((o[3] - p[3]).abs() < 1e-6, "mode {mode}: alpha {} became {}", o[3], p[3]);
assert!(p[..3].iter().all(|&c| c <= p[3] + 1e-6), "mode {mode}: premultiplied colour above alpha");
}
assert!(out.data.iter().any(|p| p[3] == 1.0 && (p[0] - 0.2).abs() > 0.01), "mode {mode}: noise drawn inside the mask");
}
}
#[test]
fn sub_settings_and_fractal_types_change_the_result() {
let base = run("ec.noise.fractal", &[]);
+12 -75
View File
@@ -219,63 +219,28 @@ fn cylinder(ctx: &EffectCtx, mut b: Buf) -> Buf {
let (_, _, w, h) = rect;
let r = (ctx.params.f("radius") / 100.0 * w / std::f64::consts::TAU).max(0.5);
let c = b.to_px(ctx.params.v2("position"));
// Keep the pre-existing Rotation control as an additional texture spin.
let spin = ctx.params.f("rotation").to_radians();
let (rx, ry, rz) = (ctx.params.f("rotationX"), ctx.params.f("rotationY"), ctx.params.f("rotationZ"));
let tilted = rx != 0.0 || ry != 0.0 || rz != 0.0;
let m = rot_xyz(rx, ry, rz);
// The cylinder's local Y axis after rotation, in viewer coordinates.
let axis = [m[0][1], m[1][1], m[2][1]];
let a = 1.0 - axis[2] * axis[2];
let mode = ctx.params.e("render");
let sh = Shading::from(ctx);
let src = b.img.clone();
b.img.rows_mut().for_each(|(y, row)| {
let dy = y as f64 + 0.5 - c.1;
let v = (y as f64 + 0.5 - c.1) / h + 0.5;
for (x, px) in row.iter_mut().enumerate() {
let dx = x as f64 + 0.5 - c.0;
if !tilted {
// Preserve exactly the mapping of projects saved before axis rotations.
let v = dy / h + 0.5;
let s = dx / r;
if s.abs() >= 1.0 {
*px = [0.0; 4];
continue;
}
let th = s.asin();
let cz = th.cos();
let front = || sh.shade(texel(&src, rect, (th + spin) / std::f64::consts::TAU + 0.5, v), [s, 0.0, cz]);
let back = || sh.shade(texel(&src, rect, (std::f64::consts::PI - th + spin) / std::f64::consts::TAU + 0.5, v), [-s, 0.0, cz]);
*px = match mode {
1 => front(),
2 => back(),
_ => over(front(), back()),
};
continue;
}
// Intersect the viewing ray (dx,dy,z) with the rotated cylindrical
// side wall: |q - dot(q,axis)*axis|^2 = radius^2.
let da = dx * axis[0] + dy * axis[1];
let bq = -da * axis[2];
let discriminant = bq * bq - a * (dx * dx + dy * dy - da * da - r * r);
if a <= 1e-10 || !discriminant.is_finite() || discriminant <= 0.0 {
let s = (x as f64 + 0.5 - c.0) / r;
if s.abs() >= 1.0 {
*px = [0.0; 4];
continue;
}
let root = discriminant.sqrt();
let face = |z: f64, back: bool| {
// Rᵀ turns the intersection into cylinder-local coordinates.
let local = [m[0][0] * dx + m[1][0] * dy + m[2][0] * z, m[0][1] * dx + m[1][1] * dy + m[2][1] * z, m[0][2] * dx + m[1][2] * dy + m[2][2] * z];
let u = (local[0].atan2(local[2]) + spin) / std::f64::consts::TAU + 0.5;
let v = local[1] / h + 0.5;
let sign = if back { -1.0 } else { 1.0 };
let nx = local[0] / r * sign;
let nz = local[2] / r * sign;
let normal = [m[0][0] * nx + m[0][2] * nz, m[1][0] * nx + m[1][2] * nz, m[2][0] * nx + m[2][2] * nz];
sh.shade(texel(&src, rect, u, v), normal)
let th = s.asin();
let cz = th.cos();
let front = || {
let u = (th + spin) / std::f64::consts::TAU + 0.5;
sh.shade(texel(&src, rect, u, v), [s, 0.0, cz])
};
let back = || {
let u = (std::f64::consts::PI - th + spin) / std::f64::consts::TAU + 0.5;
sh.shade(texel(&src, rect, u, v), [-s, 0.0, cz])
};
let front = || face((-bq + root) / a, false);
let back = || face((-bq - root) / a, true);
*px = match mode {
1 => front(),
2 => back(),
@@ -397,9 +362,6 @@ pub fn specs() -> Vec<EffectSpec> {
p("radius", "Radius", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)),
p("position", "Position", pt(0.5, 0.5), ParamUi::Point),
p("rotation", "Rotation", num(0.0), ParamUi::Angle),
p("rotationX", "Rotation X", num(0.0), ParamUi::Angle),
p("rotationY", "Rotation Y", num(0.0), ParamUi::Angle),
p("rotationZ", "Rotation Z", num(0.0), ParamUi::Angle),
p("render", "Render", Value::Enum(0), render()),
];
cyl.extend(shading());
@@ -565,31 +527,6 @@ mod tests {
assert_eq!(out.get(50 + 18, 10)[3], 0.0);
}
#[test]
fn cylinder_axis_rotations_affect_geometry_and_texture() {
let img = Image::filled(100, 100, [0.5, 0.5, 0.5, 1.0]);
let default = run("ec.perspective.cccylinder", &img, &[]).img;
let x_tilt = run("ec.perspective.cccylinder", &img, &[("rotationX", num(70.0))]).img;
// Tilting the finite cylinder shortens its vertical projection.
assert!(default.get(50, 10)[3] > 0.99);
assert_eq!(x_tilt.get(50, 10)[3], 0.0);
assert!(x_tilt.get(50, 50)[3] > 0.99);
let z_roll = run("ec.perspective.cccylinder", &img, &[("rotationZ", num(90.0))]).img;
// A quarter turn moves the side wall from vertical to horizontal.
assert_eq!(default.get(5, 50)[3], 0.0);
assert!(z_roll.get(5, 50)[3] > 0.99);
let mut striped = Image::filled(100, 100, [0.0, 0.0, 0.0, 1.0]);
for y in 0..100 {
for x in 0..100 {
striped.set(x, y, [x as f32 / 100.0, 0.2, 0.3, 1.0]);
}
}
let baseline = run("ec.perspective.cccylinder", &striped, &[]).img;
let spun = run("ec.perspective.cccylinder", &striped, &[("rotationY", num(90.0))]).img;
assert!((baseline.get(50, 50)[0] - spun.get(50, 50)[0]).abs() > 0.05);
}
#[test]
fn spotlight_darkens_outside_cone() {
let img = Image::filled(80, 80, [1.0, 1.0, 1.0, 1.0]);
+4 -70
View File
@@ -583,13 +583,6 @@ fn smooth_flow(flow: &mut Flow, pm: &PixelMotion) {
/// The motion field from frame `a` to frame `b` (or from the warp layer's frames).
pub fn timewarp_flow(a: &Image, b: &Image, pm: &PixelMotion) -> Flow {
timewarp_flow_search(a, b, pm, 4)
}
/// [`timewarp_flow`] with an explicit coarse search radius: Timewarp's own resampling needs
/// little (motion between adjacent source frames at near-100% speed), but Pixel Motion Blur
/// must find fast full-frame displacements (#535).
fn timewarp_flow_search(a: &Image, b: &Image, pm: &PixelMotion, radius: i32) -> Flow {
let ga = motion_grey(&crop_edges(a, pm.crops), pm.weights, 1.0);
let gain = if pm.correct_luminance {
let (ma, mb) = (mean_grey(&ga), mean_grey(&motion_grey(b, pm.weights, 1.0)));
@@ -598,7 +591,7 @@ fn timewarp_flow_search(a: &Image, b: &Image, pm: &PixelMotion, radius: i32) ->
1.0
};
let gb = motion_grey(&crop_edges(b, pm.crops), pm.weights, gain);
let mut flow = effectcraft_raster::flow::block_flow_window(&ga, &gb, pm.tile, pm.window, radius);
let mut flow = effectcraft_raster::flow::block_flow_window(&ga, &gb, pm.tile, pm.window, 4);
smooth_flow(&mut flow, pm);
flow
}
@@ -930,71 +923,12 @@ fn wide_time(ctx: &EffectCtx, b: Buf) -> Buf {
mean_of(ctx, b, &times)
}
fn shutter_times(ctx: &EffectCtx) -> Vec<f64> {
fn pixel_motion_blur(ctx: &EffectCtx, b: Buf) -> Buf {
let angle = if ctx.params.e("shutterControl") == 1 { ctx.params.f("shutterAngle") } else { 180.0 };
let n = ctx.params.f("shutterSamples").round().clamp(1.0, 64.0) as usize;
let span = angle.clamp(0.0, 720.0) / 360.0 / ctx.fps();
(0..n).map(|i| ctx.time - span * 0.5 + if n > 1 { span * i as f64 / (n - 1) as f64 } else { span * 0.5 }).collect()
}
fn pixel_motion_blur(ctx: &EffectCtx, b: Buf) -> Buf {
match pmb_plan(ctx).as_ref().map(TimewarpPlan::render_cpu) {
Some(blur) => blur,
None => mean_of(ctx, b, &shutter_times(ctx)),
}
}
/// Pixel Motion Blur as a [`TimewarpPlan`]: each shutter instant is decoded between the two
/// source frames it falls between along a measured motion field (the same synthesis Timewarp
/// uses), and the instants average into the blur. Vector Detail sets the spacing of the motion
/// vectors, so a coarse grid follows one big motion and a fine grid separates neighbouring
/// ones (#535). `None` without a host — the caller then falls back. Shared by the CPU effect
/// and the GPU compositor (`fx_time`), which renders the plan like Timewarp's.
pub fn pmb_plan(ctx: &EffectCtx) -> Option<TimewarpPlan> {
ctx.env.host?;
let fps = ctx.fps();
let samples = shutter_times(ctx);
let n = samples.len();
let mut times = Vec::new();
for &s in &samples {
let (f0, _) = tw_split(s, fps);
times.push(f0 / fps);
times.push((f0 + 1.0) / fps);
}
let fr = fetch(ctx, &times)?;
let idx = |t: f64| times.iter().position(|q| (q - t).abs() < 1e-9).unwrap_or(0);
let mut pm = PixelMotion::from(ctx, fr.grid.scale);
// PMB's own Vector Detail: 0 → one vector per 32 px, 100 → one per 2 px.
let detail = (ctx.params.f("vectorDetail") / 100.0).clamp(0.0, 1.0);
pm.tile = (32.0 * (1.0f64 / 16.0).powf(detail) * fr.grid.scale).round().max(2.0) as u32;
let mut flows: Vec<FlowEntry> = vec![];
let mut steps = vec![];
let w = 1.0 / n as f32;
for &s in &samples {
let (f0, frac) = tw_split(s, fps);
let (i0, i1) = (idx(f0 / fps), idx((f0 + 1.0) / fps));
let step = if frac < 1e-4 {
TwStep::Whole(i0)
} else {
let flow = match flows.iter().position(|fl| fl.key == f0 as i64) {
Some(p) => p,
None => {
let reach = (fr.grid.img.width.max(fr.grid.img.height) / 4).clamp(8, 64);
flows.push(FlowEntry { key: f0 as i64, flow: timewarp_flow_search(&fr.imgs[i0], &fr.imgs[i1], &pm, reach as i32) });
flows.len() - 1
}
};
TwStep::Motion { a: i0, b: i1, w: frac, flow }
};
steps.push((step, w));
}
Some(TimewarpPlan { grid: fr.grid, frames: fr.imgs, steps, flows: flows.into_iter().map(|f| f.flow).collect(), pm })
}
/// A measured flow with the source frame pair it came from (plans cache one per pair).
struct FlowEntry {
key: i64,
flow: Flow,
let times: Vec<f64> = (0..n).map(|i| ctx.time - span * 0.5 + if n > 1 { span * i as f64 / (n - 1) as f64 } else { span * 0.5 }).collect();
mean_of(ctx, b, &times)
}
#[cfg(test)]
-255
View File
@@ -1,255 +0,0 @@
{
"format": "effectcraft.animationPresets/1",
"notes": "Original EffectCraft transition presets (MIT OR Apache-2.0), written from scratch on EffectCraft's own Transition effects. Key times are seconds from the current time (layer time); effect paths are the effect's parameter ids; layer paths are property paths such as transform/opacity.",
"presets": [
{
"id": "fadeIn",
"name": "Fade In",
"category": "Transitions",
"direction": "in",
"description": "The layer fades up from transparent over half a second.",
"layer": { "keys": { "transform/opacity": [[0, 0], [0.5, 100]] }, "ease": true }
},
{
"id": "fadeOut",
"name": "Fade Out",
"category": "Transitions",
"direction": "out",
"description": "The layer fades down to transparent over half a second.",
"layer": { "keys": { "transform/opacity": [[0, 100], [0.5, 0]] }, "ease": true }
},
{
"id": "wipeInFromLeft",
"name": "Wipe In from Left",
"category": "Transitions",
"direction": "in",
"description": "A soft edge sweeps across from the left and reveals the layer.",
"effects": [
{ "effect": "ec.transition.linearwipe", "set": { "angle": 270, "feather": 40 }, "keys": { "completion": [[0, 100], [1, 0]] }, "ease": true }
]
},
{
"id": "wipeInFromRight",
"name": "Wipe In from Right",
"category": "Transitions",
"direction": "in",
"description": "A soft edge sweeps across from the right and reveals the layer.",
"effects": [
{ "effect": "ec.transition.linearwipe", "set": { "angle": 90, "feather": 40 }, "keys": { "completion": [[0, 100], [1, 0]] }, "ease": true }
]
},
{
"id": "wipeInFromTop",
"name": "Wipe In from Top",
"category": "Transitions",
"direction": "in",
"description": "The layer is revealed from the top edge downwards.",
"effects": [
{ "effect": "ec.transition.linearwipe", "set": { "angle": 0, "feather": 30 }, "keys": { "completion": [[0, 100], [0.9, 0]] }, "ease": true }
]
},
{
"id": "wipeInFromBottom",
"name": "Wipe In from Bottom",
"category": "Transitions",
"direction": "in",
"description": "The layer is revealed from the bottom edge upwards.",
"effects": [
{ "effect": "ec.transition.linearwipe", "set": { "angle": 180, "feather": 30 }, "keys": { "completion": [[0, 100], [0.9, 0]] }, "ease": true }
]
},
{
"id": "diagonalSweepIn",
"name": "Diagonal Sweep In",
"category": "Transitions",
"direction": "in",
"description": "A wide, very soft edge sweeps in from the bottom-left corner.",
"effects": [
{ "effect": "ec.transition.linearwipe", "set": { "angle": 45, "feather": 160 }, "keys": { "completion": [[0, 100], [1.2, 0]] }, "ease": true }
]
},
{
"id": "wipeOutToRight",
"name": "Wipe Out to Right",
"category": "Transitions",
"direction": "out",
"description": "A soft edge travels left to right and takes the layer away.",
"effects": [
{ "effect": "ec.transition.linearwipe", "set": { "angle": 90, "feather": 40 }, "keys": { "completion": [[0, 0], [1, 100]] }, "ease": true }
]
},
{
"id": "clockReveal",
"name": "Clock Reveal",
"category": "Transitions",
"direction": "in",
"description": "A clock hand sweeps once around the centre and reveals the layer.",
"effects": [
{ "effect": "ec.transition.radialwipe", "set": { "wipe": 1, "feather": 6 }, "keys": { "completion": [[0, 100], [1.2, 0]] }, "ease": true }
]
},
{
"id": "fanOpen",
"name": "Fan Open",
"category": "Transitions",
"direction": "in",
"description": "The layer opens like a fan, from the top down both sides.",
"effects": [
{ "effect": "ec.transition.radialwipe", "set": { "wipe": 2, "startAngle": 180, "feather": 10 }, "keys": { "completion": [[0, 100], [1, 0]] }, "ease": true }
]
},
{
"id": "clockWipeOut",
"name": "Clock Wipe Out",
"category": "Transitions",
"direction": "out",
"description": "A clock hand sweeps once around the centre and removes the layer.",
"effects": [
{ "effect": "ec.transition.radialwipe", "set": { "feather": 6 }, "keys": { "completion": [[0, 0], [1.2, 100]] }, "ease": true }
]
},
{
"id": "blindsOpen",
"name": "Blinds Open",
"category": "Transitions",
"direction": "in",
"description": "Vertical slats widen until the layer is whole.",
"effects": [
{ "effect": "ec.transition.venetian", "set": { "direction": 0, "width": 60, "feather": 4 }, "keys": { "completion": [[0, 100], [0.9, 0]] }, "ease": true }
]
},
{
"id": "slatsDown",
"name": "Slats Down",
"category": "Transitions",
"direction": "in",
"description": "Horizontal slats thicken until the layer is whole.",
"effects": [
{ "effect": "ec.transition.venetian", "set": { "direction": 90, "width": 36, "feather": 3 }, "keys": { "completion": [[0, 100], [0.8, 0]] } }
]
},
{
"id": "blindsClose",
"name": "Blinds Close",
"category": "Transitions",
"direction": "out",
"description": "Vertical slats narrow until the layer is gone.",
"effects": [
{ "effect": "ec.transition.venetian", "set": { "direction": 0, "width": 60, "feather": 4 }, "keys": { "completion": [[0, 0], [0.9, 100]] }, "ease": true }
]
},
{
"id": "pixelDissolveIn",
"name": "Pixel Dissolve In",
"category": "Transitions",
"direction": "in",
"description": "Small hard-edged blocks pop in at random until the layer is whole.",
"effects": [
{
"effect": "ec.transition.blockdissolve",
"set": { "blockWidth": 6, "blockHeight": 6, "softEdges": false },
"keys": { "completion": [[0, 100], [1.2, 0]] }
}
]
},
{
"id": "mosaicFadeIn",
"name": "Mosaic Fade In",
"category": "Transitions",
"direction": "in",
"description": "Large soft tiles fade in at random.",
"effects": [
{
"effect": "ec.transition.blockdissolve",
"set": { "blockWidth": 48, "blockHeight": 48, "feather": 16 },
"keys": { "completion": [[0, 100], [1.2, 0]] },
"ease": true
}
]
},
{
"id": "pixelDissolveOut",
"name": "Pixel Dissolve Out",
"category": "Transitions",
"direction": "out",
"description": "Small hard-edged blocks drop out at random until the layer is gone.",
"effects": [
{
"effect": "ec.transition.blockdissolve",
"set": { "blockWidth": 6, "blockHeight": 6, "softEdges": false },
"keys": { "completion": [[0, 0], [1.2, 100]] }
}
]
},
{
"id": "cardFlipIn",
"name": "Card Flip In",
"category": "Transitions",
"direction": "in",
"description": "A grid of cards turns over, left to right, to show the layer.",
"effects": [
{
"effect": "ec.transition.cardwipe",
"set": { "rows": 4, "columns": 8, "flipAxis": 1, "flipOrder": 1, "transitionWidth": 70 },
"keys": { "completion": [[0, 100], [1.4, 0]] },
"ease": true
}
]
},
{
"id": "cardFlipOut",
"name": "Card Flip Out",
"category": "Transitions",
"direction": "out",
"description": "A grid of cards turns over, left to right, and the layer is gone.",
"effects": [
{
"effect": "ec.transition.cardwipe",
"set": { "rows": 4, "columns": 8, "flipAxis": 1, "transitionWidth": 70 },
"keys": { "completion": [[0, 0], [1.4, 100]] },
"ease": true
}
]
},
{
"id": "gridDoorsIn",
"name": "Grid Doors In",
"category": "Transitions",
"direction": "in",
"description": "Tiles open like small doors from the centre outwards.",
"effects": [
{ "effect": "ec.transition.ccgridwipe", "set": { "shape": 0, "tiles": 12, "reverse": true }, "keys": { "completion": [[0, 100], [1, 0]] }, "ease": true }
]
},
{
"id": "lensPopIn",
"name": "Lens Pop In",
"category": "Transitions",
"direction": "in",
"description": "The layer grows out of a circle that stretches the picture outwards.",
"effects": [
{ "effect": "ec.transition.ccradialscalewipe", "set": { "reverse": true }, "keys": { "completion": [[0, 100], [1, 0]] }, "ease": true }
]
},
{
"id": "lineSweepIn",
"name": "Line Sweep In",
"category": "Transitions",
"direction": "in",
"description": "A stepped, slanted edge sweeps in from the lower left.",
"effects": [
{ "effect": "ec.transition.cclinesweep", "set": { "direction": 90, "thickness": 30, "slant": 30 }, "keys": { "completion": [[0, 100], [1, 0]] } }
]
},
{
"id": "jawsCloseIn",
"name": "Jaws Close In",
"category": "Transitions",
"direction": "in",
"description": "Toothed edges close in from the top and bottom until the layer is whole.",
"effects": [
{ "effect": "ec.transition.ccjaws", "set": { "shape": 2, "height": 30, "width": 40 }, "keys": { "completion": [[0, 100], [1, 0]] }, "ease": true }
]
}
]
}
@@ -1,7 +0,0 @@
asset: transitions.json
title: EffectCraft Transitions animation presets (Fade In/Out, Wipe In from Left/Right/Top/Bottom, Diagonal Sweep In, Wipe Out to Right, Clock Reveal, Fan Open, Clock Wipe Out, Blinds Open/Close, Slats Down, Pixel Dissolve In/Out, Mosaic Fade In, Card Flip In/Out, Grid Doors In, Lens Pop In, Line Sweep In, Jaws Close In)
author: EffectCraft contributors
source: original work
license: MIT OR Apache-2.0
added: 2026-10-11 by vangel76 (Claude Code agent, #757)
notes: Effect choices, values and key timings written from scratch for EffectCraft on its own Transition effects; not derived from any Adobe preset (.ffx) or its values.
-177
View File
@@ -1,177 +0,0 @@
//! EffectCraft's own animation preset library (Effects & Presets ▸ * Animation Presets): the
//! Transitions category, original presets built on our Transition effects and layer opacity.
//!
//! Each preset adds effects (with values and keyframes) and/or keys layer properties, starting
//! at the current time, on every selected layer. The library is data
//! (`presets/transitions.json`), written from scratch; nothing comes from After Effects' `.ffx`
//! presets.
use effectcraft_keyframe::Keyframe;
use effectcraft_project::build::Ids;
use effectcraft_project::{Node, Property};
use effectcraft_time::Tick;
use serde_json::{Map, Value, json};
use super::text_anim::json_value;
use super::{CommandSpec, bad, has_layers, layer_mut, layers_p, str_p};
use crate::{EngineError, Result, Session, cmd, query};
/// The original transition presets (`presets/transitions.json`).
pub const TRANSITIONS_JSON: &str = include_str!("../../presets/transitions.json");
/// Keys per animated property, so a hostile inline preset can't allocate without bound.
const MAX_KEYS: usize = 256;
fn presets() -> Vec<Value> {
serde_json::from_str::<Value>(TRANSITIONS_JSON).ok().and_then(|v| v.get("presets").and_then(Value::as_array).cloned()).unwrap_or_default()
}
fn text<'a>(v: &'a Value, key: &str) -> Option<&'a str> {
v.get(key).and_then(Value::as_str)
}
/// (id, name, category) of every built-in animation preset, in library order.
pub fn preset_names() -> Vec<(String, String, String)> {
presets()
.iter()
.filter_map(|p| Some((text(p, "id")?.to_string(), text(p, "name")?.to_string(), text(p, "category").unwrap_or("Transitions").to_string())))
.collect()
}
fn find_preset(want: &str) -> Option<Value> {
presets().into_iter().find(|x| text(x, "id") == Some(want) || text(x, "name").is_some_and(|n| n.eq_ignore_ascii_case(want)))
}
/// Set `pr` to `v` (when given) and replace its keys with `keys` (`[[seconds, value], …]`,
/// seconds from `at`). Returns false when nothing in the request fits the property.
fn set_prop(pr: &mut Property, v: Option<&Value>, keys: Option<&Value>, at: Tick, ease: bool) -> bool {
let mut ok = true;
if let Some(v) = v {
match json_value(v, &pr.value) {
Some(val) => pr.value = val,
None => ok = false,
}
}
let Some(list) = keys else { return ok };
let Some(list) = list.as_array() else { return false };
let mut ks: Vec<Keyframe> = Vec::new();
for k in list.iter().take(MAX_KEYS) {
let t = k.get(0).and_then(Value::as_f64).filter(|t| t.is_finite() && t.abs() <= 86_400.0);
let (Some(t), Some(val)) = (t, k.get(1).and_then(|x| json_value(x, &pr.value))) else { return false };
let mut key = Keyframe::new(at + Tick::from_seconds_f64(t), val);
if pr.hold_only {
key = key.hold();
} else if ease {
key = key.eased();
}
ks.push(key);
}
ks.sort_by_key(|k| k.time);
ks.dedup_by_key(|k| k.time);
let Some(first) = ks.first() else { return false };
pr.value = first.value.clone();
pr.keys = ks;
ok
}
/// Apply `set` / `keys` maps to the properties under `root`; returns the paths that don't exist
/// or don't take the given values.
fn apply_maps(root: &mut effectcraft_project::PropGroup, spec: &Value, at: Tick) -> Vec<String> {
let ease = spec.get("ease").and_then(Value::as_bool).unwrap_or(false);
let empty = Map::new();
let set = spec.get("set").and_then(Value::as_object).unwrap_or(&empty);
let keys = spec.get("keys").and_then(Value::as_object).unwrap_or(&empty);
let mut skipped = Vec::new();
for (path, v) in set {
if !root.prop_mut(path).is_some_and(|pr| set_prop(pr, Some(v), None, at, false)) {
skipped.push(path.clone());
}
}
for (path, list) in keys {
if !root.prop_mut(path).is_some_and(|pr| !pr.static_only && set_prop(pr, None, Some(list), at, ease)) {
skipped.push(path.clone());
}
}
skipped
}
/// `anim.applyBuiltinPreset`: add a built-in preset's effects and keyframes to the layers.
fn apply(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "anim.applyBuiltinPreset";
let want = str_p(p, "preset").ok_or_else(|| bad(C, "`preset` id or name required"))?;
let preset = find_preset(want).ok_or_else(|| bad(C, format!("no animation preset `{want}`")))?;
let name = text(&preset, "name").unwrap_or("Preset").to_string();
let effects: Vec<(&'static effectcraft_effects::EffectSpec, Value)> = preset
.get("effects")
.and_then(Value::as_array)
.into_iter()
.flatten()
.map(|e| {
let id = text(e, "effect").unwrap_or_default();
effectcraft_effects::lookup(id).map(|spec| (spec, e.clone())).ok_or_else(|| bad(C, format!("preset `{name}` uses unknown effect `{id}`")))
})
.collect::<Result<_>>()?;
let layer_spec = preset.get("layer").cloned();
let (cid, ids) = layers_p(s, p)?;
if ids.is_empty() {
return Err(bad(C, "select a layer"));
}
let sizes: Vec<_> = {
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
ids.iter().filter_map(|id| comp.layer(*id)).map(|l| (l.id, effectcraft_render::effect_bounds(&s.project, comp, l).0)).collect()
};
let now = s.time();
let (added, skipped) = s.edit(&format!("Apply Animation Preset {name}"), None, |proj, _| {
let mut added: Vec<u64> = Vec::new();
let mut skipped: Vec<String> = Vec::new();
for (lid, size) in &sizes {
let mut next = proj.next_id;
let l = layer_mut(proj, cid, *lid)?;
let at = l.layer_time(now);
if !effects.is_empty() {
let fx = l.props.sub_mut("effects").ok_or_else(|| bad(C, "this layer type has no effects"))?;
for (spec, ej) in &effects {
let same = fx.groups().filter(|g| g.match_id == spec.id).count();
let inst = if same == 0 { spec.name.to_string() } else { format!("{} {}", spec.name, same.saturating_add(1)) };
let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), &inst, *size);
skipped.extend(apply_maps(&mut g, ej, at).into_iter().map(|path| format!("{}/{path}", spec.id)));
added.push(g.uid);
fx.children.push(Node::Group(g));
}
}
if let Some(ls) = &layer_spec {
skipped.extend(apply_maps(&mut l.props, ls, at));
}
proj.next_id = next;
}
Ok((added, skipped))
})?;
Ok(json!({"preset": text(&preset, "id"), "effects": added, "skipped": skipped}))
}
/// `anim.builtinPresets`: the library, optionally one category.
fn list(_: &mut Session, p: &Value) -> Result<Value> {
let cat = str_p(p, "category");
Ok(Value::Array(
presets()
.iter()
.filter(|x| cat.is_none_or(|c| text(x, "category").is_some_and(|k| k.eq_ignore_ascii_case(c))))
.map(|x| {
json!({
"id": x.get("id"),
"name": x.get("name"),
"category": x.get("category"),
"direction": x.get("direction"),
"description": x.get("description"),
})
})
.collect(),
))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("anim.applyBuiltinPreset", "Apply Built-in Animation Preset", [], None, "{preset: id|name (see anim.builtinPresets), layers?}", has_layers, apply),
query!("anim.builtinPresets", "Built-in Animation Presets", "{category?}", list),
]
}
+1 -83
View File
@@ -5,8 +5,7 @@
use std::collections::BTreeMap;
use effectcraft_keyframe::{Interp, Keyframe, Value as KV};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{GroupKind, Layer, LayerId, LayerSource, Node, Project, PropGroup, Property, Uid};
use effectcraft_project::{GroupKind, LayerId, Node, PropGroup, Property, Uid};
use effectcraft_time::Tick;
use serde_json::{Value, json};
@@ -294,9 +293,6 @@ fn modified(pr: &Property, group: &str, default_anchor: [f64; 2], comp_center: [
match (group, pr.match_id.as_str()) {
("transform", "anchor") => !near(&v.as_vec2(), &default_anchor),
("transform", "position") => !near(&v.as_vec2(), &comp_center) || v.as_vec3()[2].abs() > 1e-6,
// Separate Dimensions: X and Y Position default to the comp centre.
("transform", "positionX") => (v.as_f64() - comp_center[0]).abs() > 1e-6,
("transform", "positionY") => (v.as_f64() - comp_center[1]).abs() > 1e-6,
("transform", "scale") => !near(&v.as_vec3(), &[100.0; 3]),
("transform", "opacity") => (v.as_f64() - 100.0).abs() > 1e-6,
("transform", "orientation") => !near(&v.as_vec3(), &[0.0; 3]),
@@ -305,50 +301,6 @@ fn modified(pr: &Property, group: &str, default_anchor: [f64; 2], comp_center: [
}
}
/// The group a fresh layer of `l`'s kind (`fresh`) has in place of `top`, for the groups whose
/// defaults are fixed: Audio, Material / Geometry / Camera / Light Options, a text layer's Path
/// and More Options, and a camera's or light's Transform (whose defaults aren't a 2D layer's).
fn default_group(top: &PropGroup, l: &Layer, fresh: &Layer) -> Option<PropGroup> {
let mut next = 0;
let mut ids = Ids(&mut next);
let camera_or_light = matches!(l.source, LayerSource::Camera | LayerSource::Light { .. });
let mut g = match top.match_id.as_str() {
"transform" if !camera_or_light => return None,
// Added to a layer after it is made (a source with audio, an Advanced 3D comp).
"audio" => build::audio(&mut ids),
"geometryOptions" if matches!(l.source, LayerSource::Text | LayerSource::Shape) => build::extrusion_geometry_options(&mut ids),
"transform" | "cameraOptions" | "lightOptions" | "materialOptions" | "geometryOptions" | "text" => fresh.props.sub(&top.match_id)?.clone(),
_ => return None,
};
match top.match_id.as_str() {
// Source Text and the animators are the layer's content, not options with a default.
"text" => g.children.retain(|c| matches!(c.match_id(), "pathOptions" | "moreOptions")),
// A model's scale and clip come from its file.
"geometryOptions" if matches!(l.source, LayerSource::Model { .. }) => g.children.retain(|c| !matches!(c.match_id(), "unitScale" | "animation")),
_ => {}
}
Some(g)
}
/// Static properties of `g` whose value differs from the same property of `default` (matched by
/// match id; properties `default` lacks have no known default and are left out).
fn changed_from(g: &PropGroup, default: &PropGroup, out: &mut Vec<Uid>) {
for c in &g.children {
match c {
Node::Prop(pr) => {
if default.get(&pr.match_id).is_some_and(|d| d.value != pr.value) && !out.contains(&pr.uid) {
out.push(pr.uid);
}
}
Node::Group(sg) => {
if let Some(d) = default.sub(&sg.match_id) {
changed_from(sg, d, out);
}
}
}
}
}
fn reveal(s: &mut Session, p: &Value) -> Result<Value> {
let kind = str_p(p, "kind").unwrap_or("keyframes").to_string();
let cid = super::comp_id(s, p)?;
@@ -360,27 +312,10 @@ fn reveal(s: &mut Session, p: &Value) -> Result<Value> {
let (w, h) = effectcraft_render::source_size(&s.project, l);
let anchor = [w as f64 / 2.0, h as f64 / 2.0];
let center = [comp.width as f64 / 2.0, comp.height as f64 / 2.0];
// Properties at the top of the layer's tree (Time Remap): enabled time remapping is a
// modification in itself.
for pr in l.props.props() {
let hit = match kind.as_str() {
"keyframes" => pr.is_animated(),
"animation" => pr.is_animated() || pr.has_expression(),
_ => pr.match_id == "timeRemap" || modified(pr, "", anchor, center),
};
if hit {
out.push(json!({"layer": lid.0, "prop": pr.uid}));
}
}
// Modified: a fresh layer of the same kind gives the defaults to compare against.
let fresh = (kind == "modified").then(|| build::layer(&mut Project::default(), comp, "", l.source.clone(), (w, h), None));
let camera_or_light = matches!(l.source, LayerSource::Camera | LayerSource::Light { .. });
for top in l.props.groups() {
let hit = |pr: &Property| match kind.as_str() {
"keyframes" => pr.is_animated(),
"animation" => pr.is_animated() || pr.has_expression(),
// A camera's or light's Transform is compared with a fresh one's below.
_ if camera_or_light && top.match_id == "transform" => pr.is_animated() || pr.expr.is_some(),
_ => modified(pr, &top.match_id, anchor, center),
};
let mut found = vec![];
@@ -400,23 +335,6 @@ fn reveal(s: &mut Session, p: &Value) -> Result<Value> {
found.push(g.uid);
}
}
if let Some(fresh) = &fresh {
if let Some(default) = default_group(top, l, fresh) {
changed_from(top, &default, &mut found);
}
// Every layer style counts in full, like an effect; so does each overridden
// Essential Property.
if top.match_id == effectcraft_project::styles::GROUP {
found.extend(top.groups().filter(|g| g.match_id != effectcraft_project::styles::BLENDING).map(|g| g.uid));
}
if let GroupKind::Essential { overridden } = &top.kind {
for u in overridden {
if !found.contains(u) && (top.find(*u).is_some() || top.find_group(*u).is_some()) {
found.push(*u);
}
}
}
}
out.extend(found.into_iter().map(|u| json!({"layer": lid.0, "prop": u})));
}
}
+3 -32
View File
@@ -128,7 +128,7 @@ fn clear_recent_presets(s: &mut Session, _: &Value) -> Result<Value> {
fn save_copy_xml(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_p(p, "path").ok_or_else(|| bad("file.saveCopyAsXml", "missing `path` (.ecprojx)"))?;
let xml = crate::xml_project::to_xml(&s.project.to_file_json_with_view(&s.saved_view())?).map_err(EngineError::Other)?;
let xml = crate::xml_project::to_xml(&s.project.to_file_json()?).map_err(EngineError::Other)?;
s.services.write_file(path, xml.as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
s.toast(format!("Saved an XML copy to {path}"));
Ok(json!({"path": path, "bytes": xml.len()}))
@@ -194,7 +194,7 @@ fn execute_file(s: &mut Session, p: &Value) -> Result<Value> {
s.events.push(Event::Frontend { command: "app.confirmExecute".into(), params: json!({"path": path}) });
return Ok(json!({"needsConfirmation": path}));
}
let url = super::layer_menu::file_url(path);
let url = format!("file://{}", path.replace(' ', "%20"));
s.events.push(Event::OpenUrl(url));
Ok(json!({"opened": path}))
}
@@ -288,12 +288,7 @@ fn enable_logging(s: &mut Session, p: &Value) -> Result<Value> {
}
fn reveal_log(s: &mut Session, _: &Value) -> Result<Value> {
// Enable Logging's file while it is on, else this run's session log.
let file = crate::logging::current_file()
.filter(|_| crate::logging::is_enabled())
.or_else(crate::logging::session_file)
.or_else(crate::logging::current_file)
.unwrap_or_else(|| log_file(s));
let file = crate::logging::current_file().unwrap_or_else(|| log_file(s));
let path = file.to_string_lossy().to_string();
let url = super::layer_menu::folder_url(&path);
s.events.push(Event::OpenUrl(url.clone()));
@@ -321,8 +316,6 @@ pub const IMPORT_FORMATS: &[&str] = &[
"Opus",
"glTF/GLB",
"OBJ",
"STL",
"FBX",
"Lottie JSON",
];
@@ -621,25 +614,3 @@ pub fn specs() -> Vec<CommandSpec> {
query!("help.systemInfo", "System Information", "{}", |s, _| Ok(system_report(s))),
]
}
#[cfg(test)]
mod tests {
use serde_json::json;
/// `File.execute()` opens the file as a well-formed `file://` URL (spaces, `#`, `%`).
#[test]
fn execute_file_opens_an_encoded_file_url() {
let dir = std::env::temp_dir().join(format!("ec-exec-url-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let f = dir.join("run #1 100%.txt");
std::fs::write(&f, "x").unwrap();
let mut s = crate::Session::default();
s.execute("prefs.set", json!({"key": "scripting.allowScriptsWriteFiles", "value": true})).unwrap();
s.drain_events();
s.execute("file.executeFile", json!({"path": f.to_string_lossy(), "confirmed": true})).unwrap();
let expected = super::super::layer_menu::file_url(&f.to_string_lossy());
assert!(expected.ends_with("/run%20%231%20100%25.txt"), "{expected}");
assert!(s.drain_events().iter().any(|e| matches!(e, crate::Event::OpenUrl(u) if *u == expected)));
let _ = std::fs::remove_dir_all(&dir);
}
}
-1
View File
@@ -109,7 +109,6 @@ pub fn specs() -> Vec<CommandSpec> {
label: "Run Commands (Batch)",
menu: &[],
shortcut: None,
alt_shortcuts: &[],
params: "{steps: [{command, params?}], label? (undo step name, default Batch), atomic?: bool (default true: a failing step rolls the whole batch back)} → {steps, results: [each step's result]}; a string param \"$N\" or \"$N.key.0\" is step N's result (1-based), e.g. {\"layer\": \"$1.layer\"}",
enabled: always,
run: batch,
+7 -7
View File
@@ -1,7 +1,7 @@
//! Composition menu.
use effectcraft_color::Label;
use effectcraft_project::{Comp, ItemId, ItemKind, Marker, limits};
use effectcraft_project::{Comp, ItemId, ItemKind, Marker};
use effectcraft_time::{FrameRate, Tick};
use serde_json::{Value, json};
@@ -15,7 +15,7 @@ pub(crate) fn rate_p(p: &Value) -> Option<FrameRate> {
/// A zero, negative or tiny rate would make a zero-length frame (and divide by zero later).
fn check_rate(p: &Value, cmd: &str) -> Result<()> {
match f_p(p, "frameRate").or(f_p(p, "fps")) {
Some(f) if !(limits::MIN_FPS..=limits::MAX_FPS).contains(&f) => Err(bad(cmd, "frameRate: fps from 0.001 to 1000")),
Some(f) if !(0.001..=1000.0).contains(&f) => Err(bad(cmd, "frameRate: fps from 0.001 to 1000")),
_ => Ok(()),
}
}
@@ -23,10 +23,10 @@ fn check_rate(p: &Value, cmd: &str) -> Result<()> {
fn apply_settings(c: &mut Comp, p: &Value) {
let (ow, oh) = (c.width, c.height);
if let Some(w) = p.get("width").and_then(Value::as_u64) {
c.width = limits::size(w).max(4);
c.width = (w as u32).clamp(4, 30000);
}
if let Some(h) = p.get("height").and_then(Value::as_u64) {
c.height = limits::size(h).max(4);
c.height = (h as u32).clamp(4, 30000);
}
// Advanced › Anchor: where the old frame sits in the resized one (0 = top left … 4 = center …
// 8 = bottom right). Unparented layers (and all their position keys) move by that offset.
@@ -69,7 +69,7 @@ fn apply_settings(c: &mut Comp, p: &Value) {
keep_work_area_ordered(c);
}
if let Some(pa) = f_p(p, "pixelAspect") {
c.pixel_aspect = limits::pixel_aspect(pa.max(limits::MIN_PIXEL_ASPECT));
c.pixel_aspect = pa.max(0.1);
}
if let Some(Value::Array(bg)) = p.get("background") {
let g = |i: usize| bg.get(i).and_then(Value::as_f64).unwrap_or(0.0) as f32;
@@ -84,10 +84,10 @@ fn apply_settings(c: &mut Comp, p: &Value) {
c.shutter_phase = a.clamp(-360.0, 360.0);
}
if let Some(n) = p.get("motionBlurSamples").and_then(Value::as_u64) {
c.motion_blur_samples = n.clamp(limits::MOTION_BLUR_SAMPLES.0.into(), limits::MOTION_BLUR_SAMPLES.1.into()) as u32;
c.motion_blur_samples = (n as u32).clamp(2, 64);
}
if let Some(n) = p.get("adaptiveSampleLimit").and_then(Value::as_u64) {
c.motion_blur_adaptive_limit = n.clamp(limits::ADAPTIVE_SAMPLE_LIMIT.0.into(), limits::ADAPTIVE_SAMPLE_LIMIT.1.into()) as u32;
c.motion_blur_adaptive_limit = (n as u32).clamp(16, 256);
}
if let Some(b) = p.get("preserveFrameRate").and_then(Value::as_bool) {
c.preserve_frame_rate = b;
+4 -97
View File
@@ -1,13 +1,12 @@
//! Composition menu, part two: crop to region of interest / layer bounds, Save Frame As ▸ File,
//! Copy Frame to Clipboard, Responsive Design — Time.
//! Responsive Design — Time.
use effectcraft_project::Marker;
use effectcraft_time::Tick;
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, comp_id, f_p, has_comp, has_layers, layers_p, str_p};
use crate::prefs::CompPreset;
use crate::{EngineError, Result, Session, cmd, query};
use super::{CommandSpec, bad, comp_id, f_p, has_comp, has_layers, layers_p, str_p};
use crate::{EngineError, Result, Session, cmd};
fn has_roi(s: &Session) -> std::result::Result<(), String> {
has_comp(s)?;
@@ -18,8 +17,7 @@ fn has_roi(s: &Session) -> std::result::Result<(), String> {
/// (all position keys too) so nothing moves on screen.
fn crop(s: &mut Session, cid: effectcraft_project::ItemId, r: [f64; 4], label: &str) -> Result<Value> {
let (x, y) = (r[0].round(), r[1].round());
// At most 30,000 px a side, like any composition (#788).
let (w, h) = (effectcraft_project::limits::size_f64(r[2]), effectcraft_project::limits::size_f64(r[3]));
let (w, h) = (r[2].round().max(1.0) as u32, r[3].round().max(1.0) as u32);
s.edit(label, None, |proj, st| {
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
c.width = w;
@@ -122,26 +120,6 @@ fn save_frame(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"path": path, "width": img.width, "height": img.height, "time": t.seconds()}))
}
/// Composition ▸ Copy Frame to Clipboard (After Effects 26.3): render the frame as Save Frame As
/// ▸ File does and hand it to the frontend, which puts it on the system clipboard.
fn copy_frame(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let t = f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or(s.time());
let scale = f_p(p, "scale").unwrap_or(1.0).clamp(0.01, 4.0);
let bg = comp.background;
let img = s.render(cid, t, effectcraft_render::RenderOpts { scale, backend: effectcraft_render::Backend::Auto, ..Default::default() });
let (width, height) = (img.width, img.height);
let rgba = img.to_rgba8_over(bg);
let expected = (width as usize).checked_mul(height as usize).and_then(|n| n.checked_mul(4));
if width == 0 || height == 0 || expected != Some(rgba.len()) {
return Err(EngineError::Other(format!("cannot copy an empty or malformed {width}×{height} frame")));
}
s.events.push(crate::Event::CopyImage { width, height, rgba });
s.toast("Copied frame to the clipboard");
Ok(json!({"width": width, "height": height, "time": t.seconds()}))
}
/// Responsive Design — Time: protected comp markers for an intro, an outro or the work area.
fn responsive_time(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
@@ -165,65 +143,6 @@ fn responsive_time(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"start": start.seconds(), "end": end.seconds()}))
}
// ---------------------------------------------------------------- composition presets
/// `comp.savePreset`: remember a frame size, pixel aspect and frame rate under a name in the
/// settings (shown in Composition Settings ▸ Preset). A preset with the same name is replaced.
fn save_preset(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "comp.savePreset";
let name = str_p(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(C, "missing `name`"))?;
let num = |k: &str| f_p(p, k).ok_or_else(|| bad(C, format!("missing `{k}`")));
let dim = |k: &str| {
num(k).and_then(|v| {
if v.fract() == 0.0 && (0.0..=f64::from(u32::MAX)).contains(&v) { Ok(v as u32) } else { Err(bad(C, format!("`{k}` must be a whole number"))) }
})
};
let preset =
CompPreset { name: name.to_string(), width: dim("width")?, height: dim("height")?, pixel_aspect: num("pixelAspect")?, frame_rate: num("frameRate")? };
if !preset.is_valid() {
return Err(bad(
C,
format!(
"not a valid preset: width and height 4–30000 px, pixel aspect 0.01–100, frame rate 1–999, a name of at most {} characters",
CompPreset::MAX_NAME
),
));
}
let presets = &mut s.prefs.comp_presets;
let replaced = match presets.iter_mut().find(|q| q.name == preset.name) {
Some(q) => {
*q = preset;
true
}
None => {
presets.push(preset);
false
}
};
s.save_prefs();
s.prefs_revision += 1;
Ok(json!({"name": name, "replaced": replaced}))
}
/// `comp.deletePreset`: forget a saved composition preset.
fn delete_preset(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "comp.deletePreset";
let name = str_p(p, "name").ok_or_else(|| bad(C, "missing `name`"))?;
let before = s.prefs.comp_presets.len();
s.prefs.comp_presets.retain(|q| q.name != name);
if s.prefs.comp_presets.len() == before {
return Err(bad(C, format!("no saved preset named `{name}`")));
}
s.save_prefs();
s.prefs_revision += 1;
Ok(Value::Null)
}
/// `comp.presets`: the saved composition presets.
fn list_presets(s: &mut Session, _: &Value) -> Result<Value> {
serde_json::to_value(&s.prefs.comp_presets).map_err(|e| bad("comp.presets", e.to_string()))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("comp.cropToRegionOfInterest", "Crop Comp to Region of Interest", ["Composition"], None, "{comp?}", has_roi, crop_to_roi),
@@ -237,18 +156,6 @@ pub fn specs() -> Vec<CommandSpec> {
has_comp,
save_frame
),
cmd!("comp.copyFrame", "Copy Frame to Clipboard", ["Composition"], None, "{comp?, time?, scale?}", has_comp, copy_frame),
cmd!(
"comp.savePreset",
"Save Composition Preset",
[],
None,
"{name, width, height, pixelAspect, frameRate} (replaces a saved preset with the same name)",
always,
save_preset
),
cmd!("comp.deletePreset", "Delete Composition Preset", [], None, "{name}", always, delete_preset),
query!("comp.presets", "Composition Presets", "{}", list_presets),
cmd!("comp.responsiveTime", "Responsive Design — Time", [], None, "{op: intro|outro|workArea}", has_comp, responsive_time),
]
}
@@ -405,7 +405,6 @@ pub fn specs() -> Vec<CommandSpec> {
label: "Generate Fill Layer",
menu: &[],
shortcut: None,
alt_shortcuts: &[],
params: P,
enabled: has_layers,
run: generate,
@@ -416,7 +415,6 @@ pub fn specs() -> Vec<CommandSpec> {
label: "Content-Aware Fill Layer...",
menu: &["Layer", "New"],
shortcut: None,
alt_shortcuts: &[],
params: P,
enabled: always,
run: open_panel,
+1 -68
View File
@@ -531,42 +531,6 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!(new.iter().map(|l| l.0).collect::<Vec<_>>()))
}
/// Largest SVG text `edit.pasteSvg` reads (a clipboard can hold anything).
const MAX_SVG_PASTE: usize = 64 << 20;
/// `edit.pasteSvg`: SVG markup (the system clipboard's, copied from a browser, Illustrator or a
/// design tool) as a new shape layer above the selected layer, its canvas centred in the comp:
/// After Effects 26.3 pastes copied vector artwork as shape layers. Edit ▸ Paste runs it when
/// the system clipboard holds SVG copied after anything copied in the app.
fn paste_svg(s: &mut Session, p: &Value) -> Result<Value> {
let c = "edit.pasteSvg";
let text = str_p(p, "svg").ok_or_else(|| super::bad(c, "missing `svg` (SVG markup)"))?;
if text.len() > MAX_SVG_PASTE {
return Err(super::bad(c, format!("the SVG is larger than {} MB", MAX_SVG_PASTE >> 20)));
}
if !crate::vector::is_svg_text(text) {
return Err(super::bad(c, "the text is not SVG markup"));
}
let doc = effectcraft_svg::parse(text.as_bytes()).map_err(|e| super::bad(c, format!("cannot read the SVG: {e}")))?;
if doc.flatten().is_empty() {
return Err(super::bad(c, "the SVG has no shapes"));
}
let cid = super::comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let name = match str_p(p, "name") {
Some(n) => n.to_string(),
None => {
let count = comp.layers.iter().filter(|l| matches!(l.source, LayerSource::Shape)).count();
comp.unique_layer_name(&format!("Shape Layer {}", count.saturating_add(1)))
}
};
let id = s.edit("Paste", None, |proj, st| {
let l = crate::vector::shapes_from_svg(proj, &comp, &doc, &name);
super::layer::insert_layer(proj, st, cid, l)
})?;
Ok(json!({"layer": id.0, "skipped": doc.skipped}))
}
fn split(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
let t = s.time();
@@ -809,7 +773,7 @@ fn original_path(s: &Session) -> Option<String> {
fn edit_original(s: &mut Session, _: &Value) -> Result<Value> {
let path = original_path(s).ok_or_else(|| super::bad("edit.editOriginal", "select a footage item or layer"))?;
let url = super::layer_menu::file_url(&path);
let url = format!("file://{path}");
s.events.push(crate::Event::OpenUrl(url.clone()));
Ok(json!({"url": url}))
}
@@ -854,15 +818,6 @@ pub fn specs() -> Vec<CommandSpec> {
has_clip,
paste
),
cmd!(
"edit.pasteSvg",
"Paste SVG as Shapes",
[],
None,
"{svg: SVG markup, name?, comp?} → {layer, skipped} (Edit ▸ Paste runs it for SVG on the system clipboard)",
has_comp,
paste_svg
),
cmd!("edit.pasteReversedKeyframes", "Paste Reversed Keyframes", ["Edit"], None, "{layers?, prop?|path?, time?}", has_key_clip, paste_reversed),
cmd!("edit.clear", "Clear", ["Edit"], Some("Delete"), "{layers?}", layers_or_keys, delete),
cmd!("edit.duplicate", "Duplicate", ["Edit"], Some("Cmd+D"), "{layers?} (effects or shape items when they are selected)", has_layers, duplicate),
@@ -938,25 +893,3 @@ fn layers_or_keys(s: &Session) -> std::result::Result<(), String> {
fn always_ok(_: &Session) -> std::result::Result<(), String> {
Ok(())
}
#[cfg(test)]
mod tests {
use serde_json::json;
/// Edit Original opens the footage as a well-formed `file://` URL, whatever its name contains.
#[test]
fn edit_original_opens_an_encoded_file_url() {
let mut s = crate::Session::default();
let id = s
.edit("add", None, |p, _| {
let f = effectcraft_project::Footage { path: "/clips/take #1.mov".into(), ..Default::default() };
Ok(p.add_item("take", effectcraft_color::Label::Lavender, None, effectcraft_project::ItemKind::Footage(f)))
})
.unwrap();
s.state.project_selection = vec![id];
s.drain_events();
let r = s.execute("edit.editOriginal", json!({})).unwrap();
assert_eq!(r["url"], "file:///clips/take%20%231.mov");
assert!(s.drain_events().iter().any(|e| matches!(e, crate::Event::OpenUrl(u) if u == "file:///clips/take%20%231.mov")));
}
}
+5 -18
View File
@@ -43,18 +43,8 @@ impl ExprError {
pub fn errors(s: &Session, cid: ItemId, t: Tick) -> Vec<ExprError> {
let Some(comp) = s.project.comp(cid) else { return vec![] };
let ctx = EvalCtx { footage: Some(s.footage.as_ref()), expr: s.expr.as_deref(), ..EvalCtx::new(&s.project, cid, comp, t) };
errors_in(&ctx, s.expr_check, || true).unwrap_or_default()
}
/// Evaluate diagnostics against an immutable snapshot. Background callers can cancel between
/// properties; cancelled scans return no partial result. Includes hidden/non-solo layers and
/// syntax errors on disabled expressions, just like the explicit `expr.errors` query.
pub fn errors_in(ctx: &EvalCtx, check: Option<fn(&str) -> std::result::Result<(), String>>, mut keep_going: impl FnMut() -> bool) -> Option<Vec<ExprError>> {
let mut out = vec![];
for (i, l) in ctx.comp.layers.iter().enumerate() {
if !keep_going() {
return None;
}
for (i, l) in comp.layers.iter().enumerate() {
let mut props: Vec<(&Property, &Expression)> = vec![];
l.props.walk("", &mut |_, p| {
if let Some(e) = p.expr.as_ref().filter(|e| !e.text.trim().is_empty()) {
@@ -62,23 +52,20 @@ pub fn errors_in(ctx: &EvalCtx, check: Option<fn(&str) -> std::result::Result<()
}
});
for (p, e) in props {
if !keep_going() {
return None;
}
let msg = if e.enabled {
match &ctx.expr {
Some(h) => h.eval(ctx, l, p, &p.value_at(l.layer_time(ctx.time))).err(),
Some(h) => h.eval(&ctx, l, p, &p.value_at(l.layer_time(t))).err(),
None => None,
}
} else {
check.and_then(|check| check(&e.text).err())
s.expr_check.and_then(|check| check(&e.text).err())
};
if let Some(message) = msg {
out.push(error_of(ctx.comp_id, i, l, p, message, !e.enabled));
out.push(error_of(cid, i, l, p, message, !e.enabled));
}
}
}
keep_going().then_some(out)
out
}
fn error_of(cid: ItemId, i: usize, l: &Layer, p: &Property, message: String, disabled: bool) -> ExprError {
+3 -12
View File
@@ -43,19 +43,10 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result<Value> {
let text = String::from_utf8(bytes).map_err(|_| EngineError::Other("not a text project file".into()))?;
// XML copies (File ▸ Save a Copy As XML…) open like the JSON project.
let text = if crate::xml_project::is_xml(&text) { crate::xml_project::from_xml(&text).map_err(EngineError::Other)? } else { text };
let (mut proj, repaired) =
Project::from_json_repaired(&text).map_err(|e| EngineError::Other(format!("{path} is not a project EffectCraft can open: {e}")))?;
let mut proj = Project::from_json(&text).map_err(|e| EngineError::Other(format!("{path} is not a project EffectCraft can open: {e}")))?;
resolve_relative_footage(&mut proj, path, s.services.as_ref());
s.replace_project(proj, Some(path.to_string()));
s.restore_view(&effectcraft_project::saved_view(&text));
s.note_project_path(path);
// Values past the limits (a corrupt or hostile file) were brought within them (#788).
if !repaired.is_empty() {
let more = repaired.len().saturating_sub(3);
let shown = repaired.iter().take(3).cloned().collect::<Vec<_>>().join("; ");
let more = if more > 0 { format!(" and {more} more") } else { String::new() };
s.toast(format!("This project had values outside EffectCraft's limits, corrected when it opened: {shown}{more}."));
}
// Written by a newer EffectCraft: what this version doesn't know would be lost on saving.
let saved_by = effectcraft_project::saved_by(&text);
if let Some(v) = saved_by.as_deref().filter(|v| effectcraft_project::is_newer_version(v, effectcraft_project::APP_VERSION)) {
@@ -69,7 +60,7 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result<Value> {
if s.check_footage_on_open {
crate::footage_check::start(s, None, false)?;
}
Ok(json!({"path": path, "savedBy": saved_by, "repaired": repaired}))
Ok(json!({"path": path, "savedBy": saved_by}))
}
/// Footage saved with a relative path (imported by the CLI or an agent as `clip.mp4`, or written
@@ -98,7 +89,7 @@ fn resolve_relative_footage(p: &mut Project, project_path: &str, services: &dyn
/// project opened from an XML copy saves as XML again). Fails, writing nothing, when the project
/// wouldn't read back.
pub(crate) fn file_text(s: &Session, path: &str) -> Result<String> {
let json = s.project.to_file_json_with_view(&s.saved_view())?;
let json = s.project.to_file_json()?;
if crate::xml_project::is_xml_path(path) { crate::xml_project::to_xml(&json).map_err(EngineError::Other) } else { Ok(json) }
}
+7 -8
View File
@@ -5,7 +5,7 @@
use std::collections::{BTreeMap, BTreeSet};
use effectcraft_color::Label;
use effectcraft_project::{AlphaMode, Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid, limits};
use effectcraft_project::{AlphaMode, Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid};
use effectcraft_time::{FrameRate, Tick};
use serde_json::{Value, json};
@@ -84,12 +84,12 @@ fn save_copy(s: &mut Session, p: &Value) -> Result<Value> {
}
fn rate_of(p: &Value) -> FrameRate {
f_p(p, "frameRate").map(|f| limits::frame_rate(FrameRate::from_f64(f), FrameRate::FPS_29_97)).unwrap_or(FrameRate::FPS_29_97)
f_p(p, "frameRate").map(FrameRate::from_f64).unwrap_or(FrameRate::FPS_29_97)
}
fn placeholder_footage(p: &Value) -> Footage {
let w = p.get("width").and_then(Value::as_u64).map_or(1920, limits::size);
let h = p.get("height").and_then(Value::as_u64).map_or(1080, limits::size);
let w = p.get("width").and_then(Value::as_u64).unwrap_or(1920) as u32;
let h = p.get("height").and_then(Value::as_u64).unwrap_or(1080) as u32;
Footage {
path: String::new(),
kind: FootageKind::Video,
@@ -133,8 +133,8 @@ fn solid_from(p: &Value, s: &Session) -> Solid {
.unwrap_or([0.5, 0.5, 0.5]);
Solid {
color,
width: p.get("width").and_then(Value::as_u64).map(limits::size).unwrap_or(cw).max(1),
height: p.get("height").and_then(Value::as_u64).map(limits::size).unwrap_or(ch).max(1),
width: p.get("width").and_then(Value::as_u64).map(|v| v as u32).unwrap_or(cw).max(1),
height: p.get("height").and_then(Value::as_u64).map(|v| v as u32).unwrap_or(ch).max(1),
pixel_aspect: 1.0,
}
}
@@ -620,8 +620,7 @@ impl Interpretation {
),
};
it.invert_alpha = super::b_p(p, "invertAlpha");
// The Interpret Footage form sends numbers as floats (3.0), which `as_u64` rejects.
it.loops = f_p(p, "loop").filter(|x| x.is_finite()).map(|v| v.round().clamp(1.0, 9999.0) as u32);
it.loops = p.get("loop").and_then(Value::as_u64).map(|v| v.clamp(1, 9999) as u32);
it.par = f_p(p, "pixelAspect").filter(|x| *x > 0.0);
it.fields = match str_p(p, "fields") {
Some(f) => Some(effectcraft_project::FieldOrder::parse(f).ok_or_else(|| bad(cmd, "fields: off|upper|lower"))?),
+2 -7
View File
@@ -179,8 +179,7 @@ fn edit_into_comp(s: &mut Session, p: &Value, ripple: bool) -> Result<Value> {
let mut l = build::layer(proj, &comp, &it.name, src, size, Some(len));
l.start_time = at - src_in;
l.in_point = at;
// Keep the full marked length past the comp's end (After Effects); the comp just doesn't show it.
l.out_point = at + len;
l.out_point = (at + len).min(comp.duration);
let lid = l.id;
let cm = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
cm.layers.insert(0, l);
@@ -207,12 +206,8 @@ fn info(s: &mut Session, _: &Value) -> Result<Value> {
let item = s.project.item(v.item);
let name = item.map(|i| i.name.clone()).unwrap_or_default();
let frame = fr.frame_at(v.time);
let problem = match item.map(|i| &i.kind) {
Some(ItemKind::Footage(f)) => s.footage.problem(f),
_ => None,
};
Ok(
json!({"item": v.item.0, "name": name, "time": v.time.seconds(), "frame": frame, "timecode": timecode(item, fr, frame), "in": v.in_point.map(|t| t.seconds()), "out": v.out_point.map(|t| t.seconds()), "duration": dur.map(|d| d.seconds()), "frameRate": fr.as_f64(), "problem": problem}),
json!({"item": v.item.0, "name": name, "time": v.time.seconds(), "frame": frame, "timecode": timecode(item, fr, frame), "in": v.in_point.map(|t| t.seconds()), "out": v.out_point.map(|t| t.seconds()), "duration": dur.map(|d| d.seconds()), "frameRate": fr.as_f64()}),
)
}
+19 -31
View File
@@ -1,12 +1,10 @@
//! Frontend-only commands: viewer zoom/overlays, panels, workspaces, dialogs and app
//! Frontend-only commands: viewer zoom/resolution/overlays, panels, workspaces, dialogs and app
//! chrome. They live in the registry so every menu entry is a command (menus, shortcuts, the
//! control channel and MCP all see them); running one emits [`crate::Event::Frontend`] and the UI
//! performs it. Headless sessions accept and ignore them.
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, frontend, has_comp, has_layers};
use crate::{Result, Session, cmd};
use super::{CommandSpec, always, frontend, has_comp, has_layers};
use crate::cmd;
macro_rules! fe {
($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr) => {
@@ -37,6 +35,16 @@ pub fn specs() -> Vec<CommandSpec> {
fe!("app.keyboardShortcuts", "Keyboard Shortcuts", ["Edit"], Some("Cmd+Alt+'"), "{}", always),
fe!("app.templates", "Templates", [], None, "{kind: renderSettings|outputModule}", always),
fe!("app.find", "Find", ["File"], Some("Cmd+F"), "{query?}", always),
// Themes (View ▸ Preferences ▸ Theme): `view.theme.toggle` cycles the appearance mode, the
// others show that theme and fix the mode to its family (see `appearance.theme`).
fe!("view.theme.toggle", "Next Appearance Mode", [], None, "{}", always),
fe!("view.theme.dark", "Theme: Dark", [], None, "{}", always),
fe!("view.theme.darker", "Theme: Darker", [], None, "{}", always),
fe!("view.theme.light", "Theme: Light", [], None, "{}", always),
fe!("view.theme.catppuccinLatte", "Theme: Catppuccin Latte", [], None, "{}", always),
fe!("view.theme.catppuccinFrappe", "Theme: Catppuccin Frappé", [], None, "{}", always),
fe!("view.theme.catppuccinMacchiato", "Theme: Catppuccin Macchiato", [], None, "{}", always),
fe!("view.theme.catppuccinMocha", "Theme: Catppuccin Mocha", [], None, "{}", always),
// Preview.
fe!(
"playback.toggle",
@@ -59,6 +67,11 @@ pub fn specs() -> Vec<CommandSpec> {
// Viewer.
fe!("view.zoomIn", "Zoom In", ["View"], Some("."), "{}", has_comp),
fe!("view.zoomOut", "Zoom Out", ["View"], Some(","), "{}", has_comp),
fe!("view.res.full", "Full", ["View", "Resolution"], Some("Cmd+J"), "{}", has_comp),
fe!("view.res.half", "Half", ["View", "Resolution"], Some("Cmd+Shift+J"), "{}", has_comp),
fe!("view.res.third", "Third", ["View", "Resolution"], None, "{}", has_comp),
fe!("view.res.quarter", "Quarter", ["View", "Resolution"], Some("Cmd+Alt+Shift+J"), "{}", has_comp),
fe!("view.res.custom", "Custom...", ["View", "Resolution"], None, "{factor?: 1..40 (render every n-th pixel)}", has_comp),
fe!("view.rulers", "Show Rulers", ["View"], Some("Cmd+R"), "{value?}", always),
fe!("view.panelBackground", "Panel Background Color", [], None, "{color?: black|darkGray|mediumGray|lightGray|white|custom|#hex, pick?: true}", always),
fe!("view.guides", "Show Guides", ["View"], Some("Cmd+;"), "{value?}", always),
@@ -68,9 +81,7 @@ pub fn specs() -> Vec<CommandSpec> {
fe!("view.snapToGrid", "Snap to Grid", ["View"], Some("Cmd+Shift+'"), "{value?}", always),
fe!("view.options", "View Options...", ["View"], Some("Cmd+Alt+U"), "{}", has_comp),
fe!("view.layerControls", "Show Layer Controls", ["View"], Some("Cmd+Shift+H"), "{value?}", always),
// macOS uses Control+Command+F for native full screen. On Windows and Linux,
// Cmd maps to Ctrl; the After Effects-style Ctrl+\ is window.maximizeApp.
fe!("view.fullScreen", "Enter Full Screen", ["View"], cfg!(target_os = "macos").then_some("Ctrl+Cmd+F"), "{}", always),
fe!("view.fullScreen", "Enter Full Screen", ["View"], Some("Ctrl+Cmd+F"), "{}", always),
fe!(
"view.newViewer",
"New Viewer",
@@ -115,28 +126,5 @@ pub fn specs() -> Vec<CommandSpec> {
"{} — the Project panel's selection, with the layers that use it; one undo step",
always
),
// A gradient property's "Edit Gradient…" (the Timeline's Colors value): opens the
// Gradient Editor on it at the current time. OK sets it with prop.set (one undo step).
cmd!(
"prop.editGradient",
"Edit Gradient...",
[],
None,
"{layer?, path|prop, comp?} — a gradient property; opens the Gradient Editor at the current time",
has_layers,
edit_gradient
),
]
}
/// `prop.editGradient`: check the target is a gradient property before the UI opens the editor,
/// so agents get an error (not success) for a wrong one, and hand the UI the resolved ids.
fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
let c = "prop.editGradient";
let (cid, lid, uid) = super::prop::prop_ref(s, p, c)?;
let pr = s.project.comp(cid).and_then(|comp| comp.layer(lid)).and_then(|l| l.props.find(uid)).ok_or_else(|| bad(c, format!("no property @{uid}")))?;
if !matches!(pr.value, effectcraft_keyframe::Value::Gradient(_)) {
return Err(bad(c, format!("{} is not a gradient property", pr.name)));
}
frontend(s, c, &json!({"comp": cid.0, "layer": lid.0, "prop": uid}))
}
+2 -19
View File
@@ -1,4 +1,4 @@
//! Help menu: community and project links (the frontend opens `Event::OpenUrl`).
//! Help menu: project links (the frontend opens `Event::OpenUrl`).
use serde_json::{Value, json};
@@ -11,11 +11,6 @@ fn open(s: &mut Session, url: String) -> Result<Value> {
Ok(json!({"url": url}))
}
fn sibling(s: &mut Session, p: &Value) -> Result<Value> {
let slug = str_p(p, "app").unwrap_or("photocraft").to_ascii_lowercase();
if str_p(p, "kind") == Some("github") { open(s, links::github_of(&slug)) } else { open(s, links::page_of(&slug)) }
}
/// Help ▸ EffectCraft Help / Scripting Help / Expression Reference / Effect Reference.
fn docs(s: &mut Session, p: &Value) -> Result<Value> {
let page = match str_p(p, "page").unwrap_or("help") {
@@ -31,25 +26,13 @@ fn docs(s: &mut Session, p: &Value) -> Result<Value> {
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("help.docs", "EffectCraft Help...", [], Some("F1"), "{page?: help|scripting|expressions|effects|agents}", always, docs),
cmd!("help.discord", "Join the ArtCraft Discord...", ["Help"], None, "{}", always, |s, _| open(s, links::DISCORD.into())),
cmd!("help.website", "ArtCraft Website", ["Help"], None, "{}", always, |s, _| open(s, links::WEBSITE.into())),
cmd!("help.appPage", "EffectCraft Home Page", ["Help"], None, "{}", always, |s, _| open(s, links::APP_PAGE.into())),
cmd!("help.github", "EffectCraft on GitHub", ["Help"], None, "{}", always, |s, _| open(s, links::GITHUB.into())),
cmd!("help.onlineTutorials", "Online Tutorials...", ["Help"], None, "{}", always, |s, _| open(s, links::APP_PAGE.into())),
cmd!("help.onlineTutorials", "Online Tutorials...", ["Help"], None, "{}", always, |s, _| open(s, links::GITHUB.into())),
cmd!("help.inAppTutorials", "In-App Tutorials...", ["Help"], None, "{} → the Home screen's Learn tab", always, |s, p| super::frontend(
s,
"help.inAppTutorials",
p
)),
cmd!("help.reportIssue", "Provide Feedback...", ["Help"], None, "{}", always, |s, _| open(s, links::ISSUES.into())),
cmd!(
"help.sibling",
"Other ArtCraft Apps",
[],
None,
"{app: photocraft|vectorcraft|filmcraft|lightcraft|pdfcraft|designcraft, kind?: page|github}",
always,
sibling
),
]
}
+20 -97
View File
@@ -4,8 +4,7 @@ use effectcraft_color::{BlendMode, Label};
use effectcraft_keyframe::{Justify, TextDoc, Value as KV};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{
Comp, FrameBlend, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, MaskMode, MatteKind, Node, Project, PropGroup, Quality, Solid, TrackMatte,
limits,
Comp, FrameBlend, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, MaskMode, MatteKind, Project, PropGroup, Quality, Solid, TrackMatte,
};
use effectcraft_time::Tick;
use serde_json::{Value, json};
@@ -93,9 +92,8 @@ fn new_solid_like(s: &mut Session, p: &Value, adjustment: bool) -> Result<Value>
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let color = color_p(p, "color").unwrap_or(if adjustment { [1.0, 1.0, 1.0] } else { [0.85, 0.2, 0.2] });
// 1 to 30,000 px a side (#788): the layer is sized from the same numbers as its solid.
let w = p.get("width").and_then(Value::as_u64).map(limits::size).unwrap_or(comp.width);
let h = p.get("height").and_then(Value::as_u64).map(limits::size).unwrap_or(comp.height);
let w = p.get("width").and_then(Value::as_u64).map(|v| v as u32).unwrap_or(comp.width);
let h = p.get("height").and_then(Value::as_u64).map(|v| v as u32).unwrap_or(comp.height);
let pixel_aspect = pixel_aspect_p(p)?.unwrap_or(comp.pixel_aspect);
let default_name = if adjustment { "Adjustment Layer 1".to_string() } else { "Solid 1".to_string() };
let name = str_p(p, "name").map(str::to_string).unwrap_or(default_name);
@@ -116,7 +114,7 @@ fn new_solid_like(s: &mut Session, p: &Value, adjustment: bool) -> Result<Value>
fn pixel_aspect_p(p: &Value) -> Result<Option<f64>> {
match p.get("pixelAspect").and_then(Value::as_f64) {
Some(v) if !(v.is_finite() && v > 0.0) => Err(bad("layer.settings", "`pixelAspect` must be a positive number")),
v => Ok(v.map(limits::pixel_aspect)),
v => Ok(v),
}
}
@@ -297,10 +295,7 @@ fn add_item(s: &mut Session, p: &Value) -> Result<Value> {
let mut l = build::layer(proj, &comp, &it.name, src, size, dur);
l.start_time = start;
l.in_point = start;
// Footage and precomps keep their full length past the comp's end (After Effects); items
// without a duration (solids, comp-length stills) end with the comp.
let end = dur.map_or((start + comp.duration).min(comp.duration), |d| start + d);
l.out_point = fr.snap_nearest(end).max(start + fr.frame_duration());
l.out_point = fr.snap_nearest((start + dur.unwrap_or(comp.duration)).min(comp.duration)).max(start + fr.frame_duration());
place(&mut l, position);
insert_layer_at(proj, st, cid, l, index)
})?;
@@ -382,9 +377,6 @@ fn set_switch(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_p(p, "switch").ok_or_else(|| bad("layer.setSwitch", "missing `switch`"))?.to_string();
let v = b_p(p, "value");
let label = format!("Layer Switch ({name})");
if b_p(p, "others") == Some(true) {
return set_switch_others(s, cid, &ids, &name, v, &label);
}
// `merge`: a switch dragged over several layers in the Timeline is one undo step.
let r = s.edit(&label, merge_p(p), |proj, _| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
@@ -402,38 +394,6 @@ fn set_switch(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!(r))
}
/// `layer.setSwitch {others: true}` (Alt-click on an A/V feature in the Timeline): the named
/// layers stand alone, shown / heard / soloed / unlocked while every other layer the switch
/// applies to is the opposite; when they already do, the other layers come back. `value` sets
/// the other layers directly.
fn set_switch_others(s: &mut Session, cid: ItemId, ids: &[LayerId], name: &str, value: Option<bool>, label: &str) -> Result<Value> {
let r = s.edit(label, None, |proj, _| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
// The state that sets a layer apart: unlocked for Lock, on for the rest.
let active = !matches!(name, "lock" | "locked");
// Track mattes keep their video off by design.
let mattes: Vec<LayerId> =
if matches!(name, "video" | "eye") { comp.layers.iter().filter_map(|l| l.track_matte.map(|m| m.layer)).collect() } else { Vec::new() };
let applies = |l: &Layer| match name {
"audio" => l.props.sub("audio").is_some(),
"lock" | "locked" => true,
_ => l.source.is_av() && !mattes.contains(&l.id),
};
let mut alone = true;
for l in comp.layers.iter().filter(|l| applies(l)) {
let Some(on) = switch_value(l, name) else { return Err(bad("layer.setSwitch", format!("unknown switch `{name}`"))) };
alone &= on == (ids.contains(&l.id) == active);
}
let others = value.unwrap_or(alone == active);
for l in comp.layers.iter_mut().filter(|l| applies(l)) {
let v = if ids.contains(&l.id) { active } else { others };
set_switch_value(l, name, v);
}
Ok(others)
})?;
Ok(json!(r))
}
fn switch_value(l: &Layer, name: &str) -> Option<bool> {
let sw = &l.switches;
Some(match name {
@@ -897,13 +857,9 @@ fn timing(s: &mut Session, p: &Value) -> Result<Value> {
let fr = s.project.comp(cid).ok_or(EngineError::NoComp)?.frame_rate;
let snap = |k: &str| f_p(p, k).map(|v| fr.snap_nearest(Tick::from_seconds_f64(v)));
let (delta, start, inp, outp) = (snap("delta"), snap("start"), snap("in"), snap("out"));
// Relative trims: each layer moves its own in/out point by the same amount, so dragging an
// edge of several selected layers trims them all together (as in AE).
let (in_delta, out_delta) = (snap("inDelta"), snap("outDelta"));
s.edit("Layer Timing", merge_p(p), |proj, _| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let fd = comp.frame_duration();
let comp_dur = comp.duration;
for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
let shift = |l: &mut Layer, d: Tick| {
l.start_time += d;
@@ -935,14 +891,6 @@ fn timing(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(o) = outp {
l.out_point = o.max(l.in_point + fd);
}
// Stops at the comp start/end (a point already past it doesn't jump onto
// it), keeping at least one frame of layer.
if let Some(d) = in_delta {
l.in_point = (l.in_point + d).max(Tick::ZERO.min(l.in_point)).min(l.out_point - fd);
}
if let Some(d) = out_delta {
l.out_point = (l.out_point + d).min(comp_dur.max(l.out_point)).max(l.in_point + fd);
}
}
}
}
@@ -1238,26 +1186,6 @@ fn add_shape_item(s: &mut Session, p: &Value) -> Result<Value> {
let uid = s.edit("Add Shape Item", None, |proj, _| {
let mut next = proj.next_id;
let mut ids = Ids(&mut next);
// Front / Bevel / Side / Back ▸ property: into the group's surface item (one per group).
if let Some((surface, prop)) = build::shape_surface_kind(&kind) {
let target = shape_item_target(proj, cid, lid, group_uid)?;
let item = match target.children.iter().position(|c| matches!(c, Node::Group(g) if g.match_id == surface)) {
Some(i) => i,
None => {
let g = build::shape_surface(&mut ids, surface).ok_or_else(|| bad("layer.addShapeItem", format!("unknown kind `{kind}`")))?;
target.children.push(g.into());
target.children.len() - 1
}
};
let Some(Node::Group(g)) = target.children.get_mut(item) else { return Err(bad("layer.addShapeItem", "no such group")) };
if g.get(prop).is_none() {
let p = build::shape_surface_prop(&mut ids, prop).ok_or_else(|| bad("layer.addShapeItem", format!("unknown kind `{kind}`")))?;
g.children.push(p.into());
}
let uid = g.uid;
proj.next_id = next;
return Ok(uid);
}
let g = match kind.as_str() {
"group" => build::shape_group(&mut ids, "Group 1", vec![]),
"rect" | "rectangle" => build::shape_rect(&mut ids, [200.0, 200.0], [0.0, 0.0], 0.0),
@@ -1275,7 +1203,16 @@ fn add_shape_item(s: &mut Session, p: &Value) -> Result<Value> {
};
let uid = g.uid;
proj.next_id = next;
shape_item_target(proj, cid, lid, group_uid)?.children.push(g.into());
let l = layer_mut(proj, cid, lid)?;
let contents = l.props.sub_mut("contents").ok_or_else(|| bad("layer.addShapeItem", "not a shape layer"))?;
let target = match group_uid {
Some(u) => contents
.find_group_mut(u)
.and_then(|g| if g.match_id == "contents" { Some(g) } else { g.sub_mut("contents") })
.ok_or_else(|| bad("layer.addShapeItem", "no such group"))?,
None => contents,
};
target.children.push(g.into());
Ok(uid)
})?;
// The new item's property path, ready for prop.set / add_keyframe (`contents/trim#2/end`).
@@ -1283,20 +1220,6 @@ fn add_shape_item(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"uid": uid, "path": path}))
}
/// The contents a new shape item goes into: the shape group `group_uid` (its Contents), else the
/// layer's Contents.
fn shape_item_target(proj: &mut Project, cid: ItemId, lid: LayerId, group_uid: Option<u64>) -> Result<&mut PropGroup> {
let l = layer_mut(proj, cid, lid)?;
let contents = l.props.sub_mut("contents").ok_or_else(|| bad("layer.addShapeItem", "not a shape layer"))?;
Ok(match group_uid {
Some(u) => contents
.find_group_mut(u)
.and_then(|g| if g.match_id == "contents" { Some(g) } else { g.sub_mut("contents") })
.ok_or_else(|| bad("layer.addShapeItem", "no such group"))?,
None => contents,
})
}
fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid) = layer_p(s, p, "layer.setText")?;
// The Character panel's recent fonts (Settings ▸ Type ▸ Number of Recent Fonts to Display).
@@ -1481,8 +1404,8 @@ fn layer_settings(s: &mut Session, p: &Value) -> Result<Value> {
_ => {}
}
let color = color_p(p, "color");
let w = p.get("width").and_then(Value::as_u64).map(limits::size);
let h = p.get("height").and_then(Value::as_u64).map(limits::size);
let w = p.get("width").and_then(Value::as_u64).map(|v| v.max(1) as u32);
let h = p.get("height").and_then(Value::as_u64).map(|v| v.max(1) as u32);
let pixel_aspect = pixel_aspect_p(p)?;
let name = str_p(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
let affect_all = p.get("affectAll").and_then(Value::as_bool).unwrap_or(true);
@@ -1585,7 +1508,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Layer Switch",
[],
None,
"{layers?, switch: video|audio|solo|lock|shy|collapse|quality|fx|frameBlend|motionBlur|adjustment|threeD|guide|preserveTransparency, value?, merge?, others? (the named layers alone: the other layers get the opposite state, or come back)}",
"{layers?, switch: video|audio|solo|lock|shy|collapse|quality|fx|frameBlend|motionBlur|adjustment|threeD|guide|preserveTransparency, value?, merge?}",
has_layers,
set_switch
),
@@ -1607,7 +1530,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Layer Timing",
[],
None,
"{layers?, op?: moveInToTime|moveOutToTime|trimInToTime|trimOutToTime, delta?, start?, in?, out?, inDelta?, outDelta? (seconds, per layer), merge?}",
"{layers?, op?: moveInToTime|moveOutToTime|trimInToTime|trimOutToTime, delta?, start?, in?, out?, merge?}",
has_layers,
timing
),
@@ -1647,7 +1570,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add (Shape)",
[],
None,
"{layer?, kind: group|rect|ellipse|star|polygon|path|fill|stroke|gfill|gstroke|trim|repeater|round|offset|pucker|twist|zigzag|wiggle|merge|frontColor|bevelColor|sideColor|backColor, group?: uid|path} → {uid, path} (frontColor…backColor: the group's Front / Bevel / Side / Back item, which colours that surface of every extruded paint in the group in Advanced 3D)",
"{layer?, kind: group|rect|ellipse|star|polygon|path|fill|stroke|gfill|gstroke|trim|repeater|round|offset|pucker|twist|zigzag|wiggle|merge, group?: uid|path} → {uid, path}",
has_layers,
add_shape_item
),
+1 -36
View File
@@ -661,24 +661,7 @@ fn open_source(s: &mut Session, p: &Value) -> Result<Value> {
/// Folder containing a file as a `file://` URL.
pub(crate) fn folder_url(path: &str) -> String {
let dir = std::path::Path::new(path).parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_default();
file_url(&dir)
}
/// `path` as a `file://` URL: backslashes become `/`, a Windows drive path gets the third slash
/// (`file:///C:/…`), and every byte that isn't a plain path character is percent-encoded (spaces,
/// `#`, `%`, `?`, non-ASCII as UTF-8), so the URL names exactly this file.
pub(crate) fn file_url(path: &str) -> String {
use std::fmt::Write as _;
let path = path.replace('\\', "/");
let mut url = String::from(if path.starts_with('/') { "file://" } else { "file:///" });
for b in path.bytes() {
if b.is_ascii_alphanumeric() || b"/-._~:@!$&'()*+,;=".contains(&b) {
url.push(char::from(b));
} else {
let _ = write!(url, "%{b:02X}");
}
}
url
format!("file://{dir}")
}
fn reveal_in_finder(s: &mut Session, p: &Value) -> Result<Value> {
@@ -862,21 +845,3 @@ pub fn specs() -> Vec<CommandSpec> {
),
]
}
#[cfg(test)]
mod tests {
use super::{file_url, folder_url};
#[test]
fn file_urls_are_well_formed() {
assert_eq!(file_url("C:\\Users\\Ann\\Footage\\clip 1.mov"), "file:///C:/Users/Ann/Footage/clip%201.mov");
assert_eq!(file_url("/home/ann/a#b%c?d.mov"), "file:///home/ann/a%23b%25c%3Fd.mov");
assert_eq!(file_url("/Users/ann/Café/x.mov"), "file:///Users/ann/Caf%C3%A9/x.mov");
assert_eq!(file_url("/plain/path-1_2.~(x).mov"), "file:///plain/path-1_2.~(x).mov", "plain characters stay");
}
#[test]
fn folder_url_names_the_containing_folder() {
assert_eq!(folder_url("/media/My Clips/a.mov"), "file:///media/My%20Clips");
}
}
+6 -49
View File
@@ -4,7 +4,6 @@
mod align;
mod align_data;
mod anim;
mod anim_presets;
pub mod anim_tools;
mod animation;
pub(crate) mod app_more;
@@ -44,7 +43,7 @@ pub mod panels_cmds;
pub(crate) mod path_nulls;
mod paths;
mod project_items;
pub(crate) mod prop;
mod prop;
mod prop_groups;
mod proxy;
pub mod puppet;
@@ -76,8 +75,6 @@ pub(crate) mod watch_folder;
pub(crate) use app_more::report_text as report_text_for_tests;
pub(crate) use app_more::view_command;
#[cfg(test)]
pub(crate) use file::open as open_for_tests;
#[cfg(test)]
pub(crate) use keys_more::amplitudes as amplitudes_for_tests;
#[cfg(test)]
pub(crate) use mask::split_segment as split_segment_for_tests;
@@ -101,8 +98,6 @@ pub struct CommandSpec {
pub menu: &'static [&'static str],
/// Default shortcut (egui-style names, `Cmd` = ⌘ on macOS / Ctrl elsewhere).
pub shortcut: Option<&'static str>,
/// Further default shortcuts (the menus and tooltips show `shortcut`).
pub alt_shortcuts: &'static [&'static str],
/// Parameter description for agents and the MCP schema.
pub params: &'static str,
pub enabled: Enabled,
@@ -113,22 +108,8 @@ pub struct CommandSpec {
#[macro_export]
macro_rules! cmd {
// Several default shortcuts: `["PageDown", "Cmd+ArrowRight"]`.
($id:literal, $label:literal, [$($m:literal),*], [$sc:literal, $($alt:literal),+], $params:literal, $en:expr, $run:expr) => {
$crate::commands::CommandSpec { alt_shortcuts: &[$($alt),+], ..$crate::cmd!($id, $label, [$($m),*], Some($sc), $params, $en, $run) }
};
($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
$crate::commands::CommandSpec {
id: $id,
label: $label,
menu: &[$($m),*],
shortcut: $sc,
alt_shortcuts: &[],
params: $params,
enabled: $en,
run: $run,
journal: true,
}
$crate::commands::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true }
};
}
#[macro_export]
@@ -139,7 +120,6 @@ macro_rules! query {
label: $label,
menu: &[],
shortcut: None,
alt_shortcuts: &[],
params: $params,
enabled: $crate::commands::always,
run: $run,
@@ -182,7 +162,6 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(layer_menu::specs());
v.extend(create::specs());
v.extend(animation::specs());
v.extend(anim_presets::specs());
v.extend(view::specs());
v.extend(viewer_cmds::specs());
v.extend(shape_stroke::specs());
@@ -242,11 +221,6 @@ pub fn text_preset_list() -> Vec<(String, String)> {
text_anim::preset_names()
}
/// (id, name, category) of the built-in animation presets (Transitions…).
pub fn builtin_preset_list() -> Vec<(String, String, String)> {
anim_presets::preset_names()
}
pub fn find(id: &str) -> Option<&'static CommandSpec> {
// Every command execution looks its spec up: index the ~2,000 ids once.
static INDEX: OnceLock<std::collections::HashMap<&'static str, usize>> = OnceLock::new();
@@ -468,34 +442,17 @@ pub(crate) fn merge_p(p: &Value) -> Option<&str> {
str_p(p, "merge")
}
/// `comp`: item id or name (an all-digit string that names no comp is taken as an id, as MCP
/// clients often send numbers as strings); default the active comp.
/// `comp`: item id or name; default the active comp.
pub(crate) fn comp_id(s: &Session, p: &Value) -> Result<ItemId> {
let missing = |v: &Value| EngineError::NoSuchComp(v.to_string());
match p.get("comp") {
Some(v @ Value::Number(n)) => n.as_u64().map(ItemId).filter(|id| s.project.comp(*id).is_some()).ok_or_else(|| missing(v)),
Some(v @ Value::String(name)) => s
.project
.items
.values()
.find(|i| &i.name == name && i.as_comp().is_some())
.map(|i| i.id)
.or_else(|| digits_id(name).map(ItemId).filter(|id| s.project.comp(*id).is_some()))
.ok_or_else(|| missing(v)),
Some(v @ Value::String(name)) => s.project.items.values().find(|i| &i.name == name && i.as_comp().is_some()).map(|i| i.id).ok_or_else(|| missing(v)),
_ => s.active_comp_id().ok_or(EngineError::NoComp),
}
}
/// An all-ASCII-digit string as a number (`"98"` -> 98); anything else (names, `#n`, signs) is `None`.
fn digits_id(s: &str) -> Option<u64> {
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
s.parse().ok()
}
/// Resolve a layer reference: id (number), `#n` / index (1-based), or name. An all-digit string
/// that names no layer is resolved as the number it spells.
/// Resolve a layer reference: id (number), `#n` / index (1-based), or name.
pub(crate) fn resolve_layer(comp: &Comp, v: &Value) -> Option<LayerId> {
match v {
Value::Number(n) => {
@@ -506,7 +463,7 @@ pub(crate) fn resolve_layer(comp: &Comp, v: &Value) -> Option<LayerId> {
if let Some(i) = s.strip_prefix('#').and_then(|i| i.parse::<usize>().ok()) {
return comp.layers.get(i.checked_sub(1)?).map(|l| l.id);
}
comp.layers.iter().find(|l| &l.name == s).map(|l| l.id).or_else(|| digits_id(s).and_then(|id| resolve_layer(comp, &Value::from(id))))
comp.layers.iter().find(|l| &l.name == s).map(|l| l.id)
}
_ => None,
}
+2 -2
View File
@@ -1,4 +1,4 @@
//! Advanced 3D: model layers (File ▸ Import of glTF/OBJ/STL/FBX, Layer ▸ New ▸ 3D primitives), the
//! Advanced 3D: model layers (File ▸ Import of glTF/OBJ, Layer ▸ New ▸ 3D primitives), the
//! comp renderer switch, Layer ▸ Environment Layer, Geometry Options for extruded text and
//! shapes, and Layer ▸ Material commands.
@@ -376,7 +376,7 @@ pub fn specs() -> Vec<CommandSpec> {
"3D Model Layer",
[],
None,
"{item?: id or name of a 3D model footage item, path?: a .gltf, .glb, .obj, .stl or .fbx file to import, name?, time? (s), index? (1-based stack position), position? ([x, y] comp px)}; switches a Classic 3D comp to Advanced 3D",
"{item?: id or name of a 3D model footage item, path?: a .gltf, .glb or .obj file to import, name?, time? (s), index? (1-based stack position), position? ([x, y] comp px)}; switches a Classic 3D comp to Advanced 3D",
has_comp,
new_model
),
+1 -1
View File
@@ -284,7 +284,7 @@ fn free_transform(s: &mut Session, p: &Value) -> Result<Value> {
const SHAPE_ITEMS: &[&str] = &[
"group", "rect", "ellipse", "star", "path", "fill", "stroke", "gfill", "gstroke", "trim", "repeater", "round", "offset", "pucker", "twist", "zigzag",
"wiggle", "merge", "front", "bevel", "side", "back",
"wiggle", "merge",
];
/// Selected shape items (contents entries) of shape layers: (layer, uid).
+9 -46
View File
@@ -230,20 +230,8 @@ fn json_numbers(v: &Value) -> Option<Vec<f64>> {
}
}
/// The share of a Proportional Scrubbing change (After Effects 26.2, Ctrl+Alt / Cmd+Opt scrub)
/// that the layer at `index` of `count` selected layers (in selection order) takes: the first
/// none, the last all of it, the ones between even steps.
pub(crate) fn proportional_share(index: usize, count: usize) -> f64 {
match count.checked_sub(1) {
Some(last) if last > 0 => index.min(last) as f64 / last as f64,
_ => 1.0,
}
}
/// `prop.set`; with `selected`, also on the other selected layers: the same value, or with
/// `offset` (a scrub) the same change from each layer's own value. With `offset` and
/// `proportional` (Proportional Scrubbing) each layer takes [`proportional_share`] of the change
/// by its place in the selection order, the scrubbed layer too. One undo step (with `merge`,
/// `offset` (a scrub) the same change from each layer's own value. One undo step (with `merge`,
/// one that a continuing scrub keeps folding into).
fn set_selected(s: &mut Session, p: &Value) -> Result<Value> {
let twins = selected_twins(s, p, "prop.set")?;
@@ -256,41 +244,16 @@ fn set_selected(s: &mut Session, p: &Value) -> Result<Value> {
(Some(true), Some(n), Some(c)) => Some(n.iter().zip(c.iter().chain(std::iter::repeat(&0.0))).map(|(a, b)| a - b).collect::<Vec<f64>>()),
_ => None,
};
// Proportional Scrubbing: the edited layer and its twins in selection order.
let order: Option<Vec<LayerId>> = (delta.is_some() && b_p(p, "proportional") == Some(true))
.then(|| s.state.selected_layers.iter().copied().filter(|o| *o == lid || twins.iter().any(|(t, _)| t == o)).collect());
let share = |l: LayerId| order.as_ref().map_or(1.0, |o| o.iter().position(|x| *x == l).map_or(1.0, |i| proportional_share(i, o.len())));
let scalar = p.get("value").is_some_and(Value::is_number);
// `p` aimed at (layer, prop) with its value moved by `k` × the change.
let shifted = |s: &Session, q: &mut Value, olid: LayerId, ouid: Uid, k: f64| {
if let Some(d) = &delta
&& let Some(c) = current_components(s, cid, olid, ouid)
{
let v: Vec<f64> = c.iter().enumerate().map(|(i, x)| x + k * d.get(i).copied().unwrap_or(0.0)).collect();
q["value"] = if scalar { json!(v.first().copied().unwrap_or(0.0)) } else { json!(v) };
}
};
let run = |s: &mut Session| -> Result<Value> {
let out = if order.is_some() {
let k = share(lid);
if k <= 0.0 {
// (the first selected layer keeps its value: no key is added to it)
current_components(s, cid, lid, uid).map_or(Value::Null, |c| if scalar { json!(c.first().copied().unwrap_or(0.0)) } else { json!(c) })
} else {
let mut q = p.clone();
shifted(s, &mut q, lid, uid, k);
set(s, &q)?
}
} else {
set(s, p)?
};
let out = set(s, p)?;
for (olid, ouid) in twins {
let k = share(olid);
if k <= 0.0 {
continue;
}
let mut q = twin_params(p, olid, ouid);
shifted(s, &mut q, olid, ouid, k);
if let Some(d) = &delta
&& let Some(c) = current_components(s, cid, olid, ouid)
{
let v: Vec<f64> = c.iter().enumerate().map(|(i, x)| x + d.get(i).copied().unwrap_or(0.0)).collect();
q["value"] = if p.get("value").is_some_and(Value::is_number) { json!(v.first().copied().unwrap_or(0.0)) } else { json!(v) };
}
// (a layer that can't take it, e.g. a clamped or static property, keeps its value)
let _ = set(s, &q);
}
@@ -1177,7 +1140,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Set Property Value",
[],
None,
"{layer?, path|prop, value, time? (layer s by default), timeBase?: layer|comp, merge?, selected? (also the other selected layers), offset? (them by the same change), proportional? (with offset: Proportional Scrubbing, each layer by its share in selection order, the first none, the last all)}",
"{layer?, path|prop, value, time? (layer s by default), timeBase?: layer|comp, merge?, selected? (also the other selected layers), offset? (them by the same change)}",
has_layers,
set_selected
),
-8
View File
@@ -46,13 +46,6 @@ fn summary(s: &mut Session, _: &Value) -> Result<Value> {
o["layers"] = json!(c.layers.len());
o["frameRate"] = json!(c.frame_rate.as_f64());
}
// Footage that shows no picture although its file is there (a codec or profile the
// decoders lack): why, so an agent can tell the user.
if let ItemKind::Footage(f) = &i.kind
&& let Some(why) = s.footage.problem(f)
{
o["problem"] = json!(why);
}
o
})
.collect();
@@ -87,7 +80,6 @@ fn comp_info(s: &mut Session, p: &Value) -> Result<Value> {
"id": cid.0, "name": name, "width": c.width, "height": c.height, "frameRate": c.frame_rate.as_f64(), "duration": c.duration.seconds(),
"workArea": [c.work_area.0.seconds(), c.work_area.1.seconds()], "background": c.background,
"preserveFrameRate": c.preserve_frame_rate, "preserveResolution": c.preserve_resolution,
"resolution": c.resolution.label(), "resolutionFactor": c.resolution.factor(),
"layers": c.layers.iter().enumerate().map(|(i, l)| layer_json(l, i + 1)).collect::<Vec<_>>(),
}))
}
+3 -3
View File
@@ -203,7 +203,7 @@ fn apply_om_template(om: &mut OutputModule, t: &OutputModule) {
}
/// The `format` parameter's values.
const FORMATS: &str = "format: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|gif|wav|aiff";
const FORMATS: &str = "format: h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff";
/// Apply Output Module parameters (`template` first). `roi`: the viewer's region of interest
/// (captured by `crop.useRoi`).
@@ -999,7 +999,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add to Render Queue",
["Composition"],
Some("Cmd+M"),
"{comp?: id|name, template?: Render Settings template, format?: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|gif|wav|aiff, output?: path|template, log?: errorsOnly|plusSettings|plusPerFrameInfo, quality?: best|draft|1-100 (jpeg, webm), resolution?: full|half|third|quarter|scale, proxyUse?, effects?, solo?, guideLayers?, colorDepth?, frameBlending?, fieldRender?, pulldown?, motionBlur?, timeSpan?: workArea|comp|custom, start?: s, end?: s, duration?: s, frameRate?: fps|null, skipExisting?: bool, storageOverflow?: bool, channels?: rgb|rgba|alpha, color?, bitrate?: kbps, webmBitrate?, keyframeInterval?, proresProfile?, profile?, level?, rateControl?, webmCodec?, audioBitrate?, opusApplication?, crop?, resize?, includeProjectLink?, audio?: auto|on|off, sampleRate?, audioChannels?, audioFormat?, loop?: bool (values as in setRenderSettings / setOutputModule)}",
"{comp?: id|name, template?: Render Settings template, format?: h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff, output?: path|template, log?: errorsOnly|plusSettings|plusPerFrameInfo, quality?: best|draft|1-100 (jpeg, webm), resolution?: full|half|third|quarter|scale, proxyUse?, effects?, solo?, guideLayers?, colorDepth?, frameBlending?, fieldRender?, pulldown?, motionBlur?, timeSpan?: workArea|comp|custom, start?: s, end?: s, duration?: s, frameRate?: fps|null, skipExisting?: bool, storageOverflow?: bool, channels?: rgb|rgba|alpha, color?, bitrate?: kbps, webmBitrate?, keyframeInterval?, proresProfile?, profile?, level?, rateControl?, webmCodec?, audioBitrate?, opusApplication?, crop?, resize?, includeProjectLink?, audio?: auto|on|off, sampleRate?, audioChannels?, audioFormat?, loop?: bool (values as in setRenderSettings / setOutputModule)}",
can_add,
add
),
@@ -1019,7 +1019,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Output Module Settings...",
[],
None,
"{item?|index?, module?: n (1 = first), template?: name, format?: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|gif|wav|aiff, channels?: rgb|rgba|alpha, color?: straight|premultiplied, quality?: 1-100, bitrate?: kbps, webmBitrate?: bool, keyframeInterval?: frames (0 = auto), proresProfile?: proxy|lt|standard|hq|4444|4444xq, profile?: main|main10 (HEVC/AV1), level?: auto|4.1, rateControl?: bitrate|quality, webmCodec?: vp9|av1, audioBitrate?: Opus kbps, opusApplication?: audio|voice, crop?: bool|{useRoi?, roi?, top?, left?, bottom?, right?}, cropTop?, cropLeft?, cropBottom?, cropRight?, resize?: bool|{preset?, width?, height?, lockAspect?, quality?: low|high}, resizeWidth?, resizeHeight?, postRenderAction?: none|import|importAndReplace|setProxy, includeProjectLink?: bool, audio?: auto|on|off, sampleRate?, audioChannels?: mono|stereo, audioFormat?: 16|24|32, loop?: bool, output?}",
"{item?|index?, module?: n (1 = first), template?: name, format?: h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff, channels?: rgb|rgba|alpha, color?: straight|premultiplied, quality?: 1-100, bitrate?: kbps, webmBitrate?: bool, keyframeInterval?: frames (0 = auto), proresProfile?: proxy|lt|standard|hq|4444|4444xq, profile?: main|main10 (HEVC/AV1), level?: auto|4.1, rateControl?: bitrate|quality, webmCodec?: vp9|av1, audioBitrate?: Opus kbps, opusApplication?: audio|voice, crop?: bool|{useRoi?, roi?, top?, left?, bottom?, right?}, cropTop?, cropLeft?, cropBottom?, cropRight?, resize?: bool|{preset?, width?, height?, lockAspect?, quality?: low|high}, resizeWidth?, resizeHeight?, postRenderAction?: none|import|importAndReplace|setProxy, includeProjectLink?: bool, audio?: auto|on|off, sampleRate?, audioChannels?: mono|stereo, audioFormat?: 16|24|32, loop?: bool, output?}",
has_items,
set_output_module
),
-18
View File
@@ -384,15 +384,6 @@ pub fn specs() -> Vec<CommandSpec> {
always,
settings_save
),
cmd!(
"script.runTasks",
"Run Scheduled Script Tasks",
[],
None,
"{} → runs the app.scheduleTask tasks that are due (frontends do it every frame) → {ran, output, errors, next (ms until the next task, or null)}",
always,
scriptui::run_tasks
),
crate::query!("scriptui.list", "List Script Windows", "{} → [{window, title, kind: dialog|palette|window|panel, script, modal, size}]", scriptui::list),
crate::query!(
"scriptui.get",
@@ -418,15 +409,6 @@ pub fn specs() -> Vec<CommandSpec> {
always,
scriptui::set
),
cmd!(
"scriptui.mouse",
"Mouse Event on Script Window Control",
[],
None,
"{window?, widget, type: mousedown | mouseup | mousemove | mouseover | mouseout | click (default), x?, y? (relative to the control; default its centre), button? (0 left, 1 middle, 2 right), detail? (click count), screenX?, screenY?, altKey?, ctrlKey?, metaKey?, shiftKey?} → the script's addEventListener listeners on the control and its containers receive a MouseEvent",
always,
scriptui::mouse
),
cmd!(
"scriptui.close",
"Close Script Window",
+17 -174
View File
@@ -3,14 +3,13 @@
//! commands here set several of them in one undo step and add the groups to strokes saved before
//! they existed. Also the toolbar's Fill / Stroke options on selected shape layers.
use effectcraft_keyframe::{Gradient, Value as KV};
use effectcraft_keyframe::Value as KV;
use effectcraft_project::build::{self, EXTRA_DASHES, Ids};
use effectcraft_project::{ItemId, Layer, LayerId, LayerSource, Node, PropGroup, Uid};
use effectcraft_time::Tick;
use serde_json::{Value, json};
use super::layer::color_p;
use super::shape_tool::{self, PaintKind};
use super::{CommandSpec, bad, f_p, has_layers, layer_mut, layer_p, layers_p, merge_p};
use crate::{EngineError, Result, Session, cmd};
@@ -225,157 +224,15 @@ pub fn first_paint(layer: &Layer, lt: Tick) -> (Option<[f64; 4]>, Option<[f64; 4
(fill, stroke, width)
}
/// What the toolbar's Fill and Stroke set on the selected shape layers.
#[derive(Clone, Copy, Debug, Default)]
struct PaintEdit {
fill: Option<[f64; 4]>,
stroke: Option<[f64; 4]>,
width: Option<f64>,
/// A group with a path and no stroke gets one.
add_stroke: bool,
fill_kind: Option<PaintKind>,
stroke_kind: Option<PaintKind>,
}
/// The kind of the first Fill and the first Stroke in `layer`'s contents at layer time `lt`: a
/// switched-off or missing one is None (what the toolbar shows for a selected shape layer).
pub fn first_paint_kinds(layer: &Layer, lt: Tick) -> (PaintKind, PaintKind) {
let (mut fill, mut stroke) = (None, None);
let kind = |g: &PropGroup| match g.match_id.as_str() {
_ if !g.enabled => PaintKind::None,
"fill" | "stroke" => PaintKind::Solid,
_ if g.get("type").is_some_and(|p| p.value_at(lt).as_enum() == 1) => PaintKind::Radial,
_ => PaintKind::Linear,
};
if let Some(c) = layer.props.sub("contents") {
walk_groups(c, &mut |g| match g.match_id.as_str() {
"fill" | "gfill" if fill.is_none() => fill = Some(kind(g)),
"stroke" | "gstroke" if stroke.is_none() => stroke = Some(kind(g)),
_ => {}
});
}
(fill.unwrap_or(PaintKind::None), stroke.unwrap_or(PaintKind::None))
}
/// The bounds (`[x, y, w, h]`) of the path items of one contents level at layer time `lt`, where
/// a new gradient's Start and End Points go; a 100 px square at the origin without paths.
fn path_bounds(c: &PropGroup, lt: Tick) -> [f64; 4] {
let mut acc: Option<[f64; 4]> = None;
for g in c.groups() {
let at = |id: &str| g.get(id).map(|p| p.value_at(lt));
let b = match g.match_id.as_str() {
"rect" | "ellipse" => {
let [w, h] = at("size").map(|v| v.as_vec2()).unwrap_or([100.0; 2]);
let [x, y] = at("position").map(|v| v.as_vec2()).unwrap_or([0.0; 2]);
Some([x - w.abs() / 2.0, y - h.abs() / 2.0, x + w.abs() / 2.0, y + h.abs() / 2.0])
}
"star" => {
let r = at("outerRadius").map(|v| v.as_f64().abs()).unwrap_or(100.0);
let [x, y] = at("position").map(|v| v.as_vec2()).unwrap_or([0.0; 2]);
Some([x - r, y - r, x + r, y + r])
}
"path" => {
let v = at("path");
let bez = v.as_ref().and_then(|v| v.as_path()).map(effectcraft_path::to_kurbo);
bez.and_then(|b| effectcraft_path::bounds(&[b])).map(|r| [r.x0, r.y0, r.x1, r.y1])
}
_ => None,
};
let Some(b) = b.filter(|b| b.iter().all(|v| v.is_finite())) else { continue };
acc = Some(match acc {
None => b,
Some(a) => [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])],
});
}
acc.map(|[x0, y0, x1, y1]| [x0, y0, x1 - x0, y1 - y0]).unwrap_or([-50.0, -50.0, 100.0, 100.0])
}
/// Fill Options / Stroke Options on one contents level (as in After Effects, with shape layers
/// selected they change the layers' paint): None switches its Fills and Gradient Fills (or
/// Strokes and Gradient Strokes) off; another kind switches them on, turns them into that kind
/// (keeping the properties both kinds have: blend mode, opacity, width, dashes…) and gives a
/// level with paths and no such item one.
fn set_kind(c: &mut PropGroup, ids: &mut Ids, lt: Tick, stroke: bool, kind: PaintKind, e: &PaintEdit) {
let (solid, grad) = if stroke { ("stroke", "gstroke") } else { ("fill", "gfill") };
let is_item = |g: &PropGroup| g.match_id == solid || g.match_id == grad;
if kind == PaintKind::None {
for n in &mut c.children {
if let Node::Group(g) = n
&& is_item(g)
{
g.enabled = false;
}
}
return;
}
let radial = kind == PaintKind::Radial;
let color = (if stroke { e.stroke } else { e.fill }).unwrap_or([1.0; 4]);
let width = e.width.filter(|w| *w > 0.0).unwrap_or(2.0);
let (start, end) = shape_tool::gradient_ends(radial, path_bounds(c, lt));
let fresh = |ids: &mut Ids| match (kind, stroke) {
(PaintKind::Solid, false) => build::shape_fill(ids, color),
(PaintKind::Solid, true) => build::shape_stroke(ids, color, width),
(_, false) => build::shape_gradient_fill(ids, radial, start, end, Gradient::default()),
(_, true) => build::shape_gradient_stroke(ids, radial, start, end, Gradient::default(), width),
};
let want = if kind == PaintKind::Solid { solid } else { grad };
let mut found = false;
for i in 0..c.children.len() {
let Some(Node::Group(g)) = c.children.get(i) else { continue };
if !is_item(g) {
continue;
}
found = true;
let replacement = (g.match_id != want).then(|| {
let mut new = fresh(ids);
new.uid = g.uid;
new.name = super::effect::unique_name(c, &new.name);
for n in &mut new.children {
if let Some(old) = g.children.iter().find(|o| o.match_id() == n.match_id()) {
*n = old.clone();
}
}
new
});
let Some(Node::Group(g)) = c.children.get_mut(i) else { continue };
match replacement {
Some(new) => *g = new,
None => {
g.enabled = true;
if let Some(t) = g.get_mut("type").filter(|_| want == grad) {
t.set_value_at(lt, KV::Enum(radial as u32));
}
}
}
}
if !found && c.groups().any(|g| PATHS.contains(&g.match_id.as_str())) {
let mut new = fresh(ids);
new.name = super::effect::unique_name(c, &new.name);
// Paths, strokes, then fills, as new shapes have them.
let at = if stroke {
c.children.iter().position(|n| matches!(n.match_id(), "fill" | "gfill")).unwrap_or(c.children.len())
} else {
c.children.iter().rposition(|n| PATHS.contains(&n.match_id()) || matches!(n.match_id(), "stroke" | "gstroke")).map_or(c.children.len(), |i| i + 1)
};
c.children.insert(at, new.into());
}
}
/// Paint one contents group and its sub-groups: the kinds first, then Fills take `fill`, Strokes
/// `stroke`, Strokes and Gradient Strokes `width`. With `add_stroke`, a group with a path and no
/// stroke gets one (before its fills, as new shapes have it).
fn paint(c: &mut PropGroup, ids: &mut Ids, lt: Tick, e: &PaintEdit, depth: usize) {
/// Paint one contents group and its sub-groups: Fills take `fill`, Strokes `stroke`, Strokes and
/// Gradient Strokes `width`. With `add_stroke`, a group with a path and no stroke gets one (before
/// its fills, as new shapes have it).
#[allow(clippy::too_many_arguments)]
fn paint(c: &mut PropGroup, ids: &mut Ids, lt: Tick, fill: Option<[f64; 4]>, stroke: Option<[f64; 4]>, width: Option<f64>, add_stroke: bool, depth: usize) {
if depth > MAX_DEPTH {
return;
}
if let Some(k) = e.stroke_kind {
set_kind(c, ids, lt, true, k, e);
}
if let Some(k) = e.fill_kind {
set_kind(c, ids, lt, false, k, e);
}
let (fill, stroke, width) = (e.fill, e.stroke, e.width);
if e.add_stroke && c.groups().any(|g| PATHS.contains(&g.match_id.as_str())) && !c.groups().any(is_stroke) {
if add_stroke && c.groups().any(|g| PATHS.contains(&g.match_id.as_str())) && !c.groups().any(is_stroke) {
let at = c.children.iter().position(|n| matches!(n, Node::Group(g) if matches!(g.match_id.as_str(), "fill" | "gfill"))).unwrap_or(c.children.len());
c.children.insert(at, build::shape_stroke(ids, stroke.unwrap_or([1.0; 4]), width.unwrap_or(2.0)).into());
}
@@ -395,7 +252,7 @@ fn paint(c: &mut PropGroup, ids: &mut Ids, lt: Tick, e: &PaintEdit, depth: usize
"gstroke" => set(g, "width", width.map(KV::Scalar)),
"group" => {
if let Some(sub) = g.sub_mut("contents") {
paint(sub, ids, lt, e, depth + 1);
paint(sub, ids, lt, fill, stroke, width, add_stroke, depth + 1);
}
}
_ => {}
@@ -403,20 +260,10 @@ fn paint(c: &mut PropGroup, ids: &mut Ids, lt: Tick, e: &PaintEdit, depth: usize
}
}
/// `p`'s `fillType` / `strokeType` (none|solid|linear|radial).
fn kind_p(p: &Value, key: &str, c: &str) -> Result<Option<PaintKind>> {
match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(v) => v.as_str().and_then(PaintKind::from_name).map(Some).ok_or_else(|| bad(c, format!("`{key}`: none|solid|linear|radial"))),
}
}
/// The toolbar's Fill and Stroke options with shape layers selected (as in After Effects, they
/// change the layers' fills and strokes): `fillType` / `strokeType` switch every Fill / Stroke
/// off (none) or turn them into Solid Color, Linear or Radial Gradient ones (adding one where a
/// path has none), `fill` sets every Fill's colour, `stroke` every Stroke's, `strokeWidth` every
/// stroke's width. A stroke colour or a width above 0 gives the paths that have no stroke one.
/// Without values, reports the first layer's paint.
/// change the layers' fills and strokes): `fill` sets every Fill's colour, `stroke` every Stroke's,
/// `strokeWidth` every stroke's width. A stroke colour or a width above 0 gives the paths that
/// have no stroke one. Without values, reports the first layer's paint.
fn fill_stroke(s: &mut Session, p: &Value) -> Result<Value> {
let c = "shape.fillStroke";
let (cid, lids) = layers_p(s, p)?;
@@ -428,10 +275,8 @@ fn fill_stroke(s: &mut Session, p: &Value) -> Result<Value> {
let rgba = |k: &str| color_p(p, k).map(|c| [c[0] as f64, c[1] as f64, c[2] as f64, 1.0]);
let (fill, stroke) = (rgba("fill"), rgba("stroke"));
let width = f_p(p, "strokeWidth").filter(|w| w.is_finite()).map(|w| w.max(0.0));
let (fill_kind, stroke_kind) = (kind_p(p, "fillType", c)?, kind_p(p, "strokeType", c)?);
let add_stroke = stroke_kind != Some(PaintKind::None) && width.map_or(stroke.is_some(), |w| w > 0.0);
let e = PaintEdit { fill, stroke, width, add_stroke, fill_kind, stroke_kind };
if fill.is_some() || stroke.is_some() || width.is_some() || fill_kind.is_some() || stroke_kind.is_some() {
if fill.is_some() || stroke.is_some() || width.is_some() {
let add_stroke = width.map_or(stroke.is_some(), |w| w > 0.0);
let t = s.time();
s.edit("Fill and Stroke", merge_p(p), |proj, _| {
let mut next = proj.next_id;
@@ -439,7 +284,7 @@ fn fill_stroke(s: &mut Session, p: &Value) -> Result<Value> {
let l = layer_mut(proj, cid, *lid)?;
let lt = l.layer_time(t);
if let Some(contents) = l.props.sub_mut("contents") {
paint(contents, &mut Ids(&mut next), lt, &e, 0);
paint(contents, &mut Ids(&mut next), lt, fill, stroke, width, add_stroke, 0);
}
}
proj.next_id = next;
@@ -447,10 +292,8 @@ fn fill_stroke(s: &mut Session, p: &Value) -> Result<Value> {
})?;
}
let l = shapes.first().and_then(|id| s.project.comp(cid)?.layer(*id)).ok_or(EngineError::NoComp)?;
let lt = l.layer_time(s.time());
let (fill, stroke, width) = first_paint(l, lt);
let (fill_kind, stroke_kind) = first_paint_kinds(l, lt);
Ok(json!({"fill": fill, "stroke": stroke, "strokeWidth": width, "fillType": fill_kind.id(), "strokeType": stroke_kind.id()}))
let (fill, stroke, width) = first_paint(l, l.layer_time(s.time()));
Ok(json!({"fill": fill, "stroke": stroke, "strokeWidth": width}))
}
pub fn specs() -> Vec<CommandSpec> {
@@ -460,7 +303,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Fill and Stroke",
[],
None,
"{layers?, fillType?: none|solid|linear|radial, strokeType?, fill?: color, stroke?: color, strokeWidth?, merge?} → {fill, stroke, strokeWidth, fillType, strokeType} (first layer)",
"{layers?, fill?: color, stroke?: color, strokeWidth?, merge?} → {fill, stroke, strokeWidth} (first layer)",
has_layers,
fill_stroke
),
+3 -9
View File
@@ -139,18 +139,12 @@ fn blended(mut g: PropGroup, paint: &ToolPaint) -> PropGroup {
g
}
/// A gradient's Start and End Points across `bounds` (`[x, y, w, h]`): left to right, or out
/// from the centre.
pub(crate) fn gradient_ends(radial: bool, bounds: [f64; 4]) -> ([f64; 2], [f64; 2]) {
let [x, y, w, h] = bounds;
let mid = [x + w / 2.0, y + h / 2.0];
if radial { (mid, [mid[0] + w.max(h).max(2.0) / 2.0, mid[1]]) } else { ([x, mid[1]], [x + w.max(1.0), mid[1]]) }
}
/// The Stroke and Fill a new shape gets from `paint`; gradients run across `bounds` (`[x, y, w,
/// h]` in the group's space): left to right, or out from the centre.
pub(crate) fn paint_items(ids: &mut Ids, paint: &ShapeTool, bounds: [f64; 4]) -> Vec<PropGroup> {
let ends = |radial: bool| gradient_ends(radial, bounds);
let [x, y, w, h] = bounds;
let mid = [x + w / 2.0, y + h / 2.0];
let ends = |radial: bool| if radial { (mid, [mid[0] + w.max(h).max(2.0) / 2.0, mid[1]]) } else { ([x, mid[1]], [x + w.max(1.0), mid[1]]) };
let mut items = vec![];
let (s, width) = (&paint.stroke, paint.stroke_width);
let stroke = match s.kind {
+2 -2
View File
@@ -10,10 +10,10 @@ use super::{CommandSpec, not_yet, not_yet_run};
#[allow(unused_macros)]
macro_rules! stub {
($id:literal, $label:literal, [$($m:literal),*], $params:literal) => {
CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: None, alt_shortcuts: &[], params: $params, enabled: not_yet, run: not_yet_run, journal: true }
CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: None, params: $params, enabled: not_yet, run: not_yet_run, journal: true }
};
($id:literal, $label:literal, [$($m:literal),*], $sc:literal, $params:literal) => {
CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: Some($sc), alt_shortcuts: &[], params: $params, enabled: not_yet, run: not_yet_run, journal: true }
CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: Some($sc), params: $params, enabled: not_yet, run: not_yet_run, journal: true }
};
}
+2 -9
View File
@@ -115,13 +115,6 @@ fn add_animator(s: &mut Session, p: &Value) -> Result<Value> {
if added == 0 && !kinds.is_empty() {
return Err(bad(C, "unknown animator property"));
}
// Like After Effects, a scripted animator has no selectors until the script adds one
// (addProperty("ADBE Text Selector")); a default 0-100% one would select every character.
if kinds.is_empty()
&& let Some(sels) = g.sub_mut("selectors")
{
sels.children.clear();
}
let uid = g.uid;
anims.children.push(g.into());
proj.next_id = next;
@@ -253,7 +246,7 @@ fn enable_per_char(s: &mut Session, p: &Value) -> Result<Value> {
}
/// JSON → property value shaped like `like`.
pub(super) fn json_value(v: &Value, like: &KV) -> Option<KV> {
fn json_value(v: &Value, like: &KV) -> Option<KV> {
Some(match v {
Value::Bool(b) => match like {
KV::Bool(_) => KV::Bool(*b),
@@ -438,7 +431,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Animate Text",
[],
None,
"{layer?, properties: [anchor|position|scale|skew|rotation|opacity|transformAll|lineAnchor|lineSpacing|characterOffset|characterValue|blur|fillColor|fillHue|fillSaturation|fillBrightness|fillOpacity|strokeColor|strokeHue|strokeSaturation|strokeBrightness|strokeOpacity|strokeWidth|tracking|frontColor|bevelColor|sideColor|backColor|perChar3d], name?}",
"{layer?, properties: [anchor|position|scale|skew|rotation|opacity|transformAll|lineAnchor|lineSpacing|characterOffset|characterValue|blur|fillColor|fillHue|fillSaturation|fillBrightness|fillOpacity|strokeColor|strokeHue|strokeSaturation|strokeBrightness|strokeOpacity|strokeWidth|tracking|perChar3d], name?}",
has_text_layer,
add_animator
),
+2 -5
View File
@@ -506,9 +506,7 @@ pub fn fonts(_s: &mut Session, p: &Value) -> Result<Value> {
use effectcraft_text::fonts;
let added = if p.get("rescan").and_then(Value::as_bool).unwrap_or(false) { fonts::rescan_system() } else { 0 };
let query = p.get("query").and_then(Value::as_str).map(str::to_lowercase).unwrap_or_default();
let only_variable = p.get("variable").and_then(Value::as_bool).unwrap_or(false);
let native = fonts::native_families();
let variable = fonts::variable_families();
let mut origin: std::collections::BTreeMap<String, &'static str> = Default::default();
for f in fonts::all_faces() {
origin.entry(f.info.family.clone()).or_insert(f.info.origin);
@@ -516,9 +514,8 @@ pub fn fonts(_s: &mut Session, p: &Value) -> Result<Value> {
let list: Vec<Value> = effectcraft_text::families()
.into_iter()
.filter(|(f, _)| query.is_empty() || f.to_lowercase().contains(&query) || native.get(f).is_some_and(|n| n.to_lowercase().contains(&query)))
.filter(|(f, _)| !only_variable || variable.contains(f))
.map(|(f, styles)| {
let mut v = json!({"family": f, "styles": styles, "origin": origin.get(&f).copied().unwrap_or("system"), "variable": variable.contains(&f)});
let mut v = json!({"family": f, "styles": styles, "origin": origin.get(&f).copied().unwrap_or("system")});
if let Some(n) = native.get(&f) {
v["nativeName"] = json!(n);
}
@@ -562,7 +559,7 @@ pub fn specs() -> Vec<CommandSpec> {
"List Fonts",
[],
None,
"{query?, variable?, rescan?} → {count, families: [{family, styles, origin: bundled|system|user, variable, nativeName?}], added} (variable: only variable fonts; rescan picks up fonts installed since launch)",
"{query?, rescan?} → {count, families: [{family, styles, origin: bundled|system|user, nativeName?}], added} (rescan picks up fonts installed since launch)",
always_ok,
fonts
),
-24
View File
@@ -320,21 +320,6 @@ fn set_3d_view(s: &mut Session, p: &Value) -> Result<Value> {
Ok(switch_view(s, cid, v))
}
/// The view of one pane of the View Layout: pane 0 is the main view (the same as Switch 3D View),
/// panes 1 to 3 are the extra views of a 2- or 4-view layout. UI state, not part of the project.
fn set_pane_view(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
let v = view_p(p, "view.setPaneView")?;
let pane = p.get("pane").and_then(Value::as_u64).filter(|n| *n <= 3).ok_or_else(|| bad("view.setPaneView", "pane: 0 (main view) to 3"))? as usize;
if pane == 0 {
switch_view(s, cid, v);
} else {
views(s, cid).set_pane(pane - 1, v);
}
let vs = views(s, cid);
Ok(json!({"pane": pane, "view": v.id(), "views": [vs.current.id(), vs.pane(0).id(), vs.pane(1).id(), vs.pane(2).id()]}))
}
macro_rules! view_cmd {
($f:ident, $v:expr) => {
fn $f(s: &mut Session, p: &Value) -> Result<Value> {
@@ -721,15 +706,6 @@ pub fn specs() -> Vec<CommandSpec> {
has_comp,
set_3d_view
),
cmd!(
"view.setPaneView",
"Set View of Pane",
[],
None,
"{pane: 0 (main) | 1 | 2 | 3, view: activeCamera|default|front|left|top|back|right|bottom|custom1|custom2|custom3, comp?}",
has_comp,
set_pane_view
),
cmd!("view.3d.activeCamera", "Active Camera", ["View", "Switch 3D View"], Some("F12"), "{comp?}", has_comp, view_active),
cmd!("view.3d.default", "Default", ["View", "Switch 3D View"], None, "{comp?}", has_comp, view_default),
cmd!("view.3d.front", "Front", ["View", "Switch 3D View"], Some("F10"), "{comp?}", has_comp, view_front),
+7 -11
View File
@@ -39,10 +39,7 @@ pub fn parse_display_time(s: &Session, comp: &Comp, text: &str, current: i64) ->
effectcraft_time::parse_feet_frames(text, pf, current + start).map(|f| f - start).map_err(|e| e.0)
}
None => {
// The Timeline shows non-drop timecode (`:`), so typed `:`/`.` text is read as non-drop
// too; only explicit `;` fields mean drop-frame (#890).
let drop_frame = fr.supports_drop_frame() && text.contains(';');
let parsed = effectcraft_time::parse_timecode(text, fr, drop_frame, current).map_err(|e| e.0)?;
let parsed = effectcraft_time::parse_timecode(text, fr, fr.supports_drop_frame(), current).map_err(|e| e.0)?;
let relative = text.trim().starts_with('+') || text.trim().starts_with('-');
if relative {
Ok(parsed)
@@ -64,9 +61,8 @@ pub fn parse_display_time(s: &Session, comp: &Comp, text: &str, current: i64) ->
fn set(s: &mut Session, p: &Value) -> Result<Value> {
let comp = s.active_comp().ok_or(EngineError::NoComp)?;
// Dialog number fields send floats (`5.0`), so a frame is any JSON number, rounded.
let t = if let Some(f) = f_p(p, "frame") {
comp.frame_rate.tick_of(f.round() as i64)
let t = if let Some(f) = p.get("frame").and_then(Value::as_i64) {
comp.frame_rate.tick_of(f)
} else if let Some(tc) = str_p(p, "timecode") {
let cur = comp.frame_rate.frame_at(s.time());
let f = parse_display_time(s, comp, tc, cur).map_err(|e| super::bad("time.set", e))?;
@@ -173,10 +169,10 @@ fn with_to(p: &Value, to: &str) -> Value {
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("time.set", "Go to Time...", ["View"], Some("Alt+Shift+J"), "{time? (s) | frame? | timecode?}", has_comp, set),
cmd!("time.nextFrame", "Next Frame", [], ["PageDown", "Cmd+ArrowRight"], "{}", has_comp, |s, _| step(s, &json!({"frames": 1}))),
cmd!("time.previousFrame", "Previous Frame", [], ["PageUp", "Cmd+ArrowLeft"], "{}", has_comp, |s, _| step(s, &json!({"frames": -1}))),
cmd!("time.forward10", "Forward 10 Frames", [], ["Shift+PageDown", "Cmd+Shift+ArrowRight"], "{}", has_comp, |s, _| step(s, &json!({"frames": 10}))),
cmd!("time.back10", "Back 10 Frames", [], ["Shift+PageUp", "Cmd+Shift+ArrowLeft"], "{}", has_comp, |s, _| step(s, &json!({"frames": -10}))),
cmd!("time.nextFrame", "Next Frame", [], Some("PageDown"), "{}", has_comp, |s, _| step(s, &json!({"frames": 1}))),
cmd!("time.previousFrame", "Previous Frame", [], Some("PageUp"), "{}", has_comp, |s, _| step(s, &json!({"frames": -1}))),
cmd!("time.forward10", "Forward 10 Frames", [], Some("Shift+PageDown"), "{}", has_comp, |s, _| step(s, &json!({"frames": 10}))),
cmd!("time.back10", "Back 10 Frames", [], Some("Shift+PageUp"), "{}", has_comp, |s, _| step(s, &json!({"frames": -10}))),
cmd!("time.step", "Step Frames", [], None, "{frames}", has_comp, step),
cmd!("time.start", "Go to Start", [], Some("Home"), "{}", has_comp, |s, _| go(s, &json!({"to": "start"}))),
cmd!("time.end", "Go to End", [], Some("End"), "{}", has_comp, |s, _| go(s, &json!({"to": "end"}))),
+3 -35
View File
@@ -1,7 +1,7 @@
//! View menu commands that change the project (guides, each comp's Resolution) or
//! headless-relevant editor state (the region of interest).
//! View menu commands that change the project (guides) or headless-relevant editor state (the
//! region of interest).
use effectcraft_project::{Guide, Resolution};
use effectcraft_project::Guide;
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, comp_id, f_p, has_comp, str_p};
@@ -75,24 +75,6 @@ fn region_of_interest(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!(s.state.region_of_interest))
}
/// View ▸ Resolution (and the Composition panel's Resolution popup): the comp's Resolution, saved
/// with the project but no undo step ([`Session::set_resolution`]).
fn set_resolution(s: &mut Session, p: &Value, r: Resolution) -> Result<Value> {
let cid = comp_id(s, p)?;
s.set_resolution(cid, r)?;
Ok(json!({"comp": cid.0, "resolution": r.label(), "factor": r.factor()}))
}
/// View ▸ Resolution ▸ Custom: `factor` renders every n-th pixel; without one the frontend asks
/// for it (the Custom Resolution dialog).
fn custom_resolution(s: &mut Session, p: &Value) -> Result<Value> {
match f_p(p, "factor") {
Some(n) => set_resolution(s, p, Resolution::from_factor(n.round().clamp(1.0, 40.0) as u64)),
None if p.get("factor").is_some_and(|v| !v.is_null()) => Err(bad("view.res.custom", "factor: a number from 1 to 40")),
None => super::frontend(s, "view.res.custom", p),
}
}
fn view_layout(s: &mut Session, p: &Value) -> Result<Value> {
let n = p.get("views").and_then(Value::as_u64).ok_or_else(|| bad("view.layout", "views: 1|2|4"))?;
if !matches!(n, 1 | 2 | 4) {
@@ -119,26 +101,12 @@ fn extended_viewer(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!(v))
}
/// A `view.res.*` command setting one [`Resolution`].
macro_rules! res {
($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $r:ident) => {
cmd!($id, $label, [$($m),*], $sc, "{comp?}", has_comp, |s, p| set_resolution(s, p, Resolution::$r))
};
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("view.addGuide", "Add Guide...", ["View"], None, "{orientation?: vertical|horizontal, position? (comp px)}", has_comp, add_guide),
cmd!("view.clearGuides", "Clear Guides", ["View"], None, "{comp?}", has_guides, clear_guides),
cmd!("view.importGuides", "Import Guides...", ["View"], None, "{path}", has_comp, import_guides),
cmd!("view.exportGuides", "Export Guides...", ["View"], None, "{path}", has_guides, export_guides),
res!("view.res.full", "Full", ["View", "Resolution"], Some("Cmd+J"), Full),
res!("view.res.half", "Half", ["View", "Resolution"], Some("Cmd+Shift+J"), Half),
res!("view.res.third", "Third", ["View", "Resolution"], None, Third),
res!("view.res.quarter", "Quarter", ["View", "Resolution"], Some("Cmd+Alt+Shift+J"), Quarter),
cmd!("view.res.custom", "Custom...", ["View", "Resolution"], None, "{factor?: 1..40 (render every n-th pixel), comp?}", has_comp, custom_resolution),
// The Composition panel's Resolution popup (not in the View menu).
res!("view.res.auto", "Resolution: Auto", [], None, Auto),
cmd!("view.layout", "Switch View Layout", [], None, "{views: 1|2|4}", has_comp, view_layout),
cmd!("view.shareViewOptions", "Share View Options", ["View", "Switch View Layout"], None, "{value?}", has_comp, share_view_options),
cmd!("view.extendedViewer", "Extended Viewer", [], None, "{value?}", always, extended_viewer),
+1 -7
View File
@@ -210,13 +210,7 @@ mod tests {
let mut paths: Vec<String> =
o.project.items.values().filter_map(|i| if let ItemKind::Footage(f) = &i.kind { Some(f.path.clone()) } else { None }).collect();
paths.sort();
// Windows joins the stored "media/..." onto the project's folder and leaves a path with mixed
// separators; `Path` equality compares components, so both spellings are the same file.
let expected = ["/nonexistent/ec/abs.png", clip.to_str().unwrap(), "media/ec-relative-gone.png"];
assert_eq!(paths.len(), expected.len(), "{paths:?}");
for (got, want) in paths.iter().zip(expected) {
assert_eq!(std::path::Path::new(got), std::path::Path::new(want), "{paths:?}");
}
assert_eq!(paths, ["/nonexistent/ec/abs.png", clip.to_str().unwrap(), "media/ec-relative-gone.png"]);
assert!(!o.is_dirty(), "finding the files is no edit");
let r = o.execute("footage.check", json!({"wait": true})).unwrap();
assert_eq!(r["missing"], 2);
+1 -2
View File
@@ -341,8 +341,7 @@ impl Session {
let k = new_line.iter().position(|&i| i == target).unwrap_or(0);
let undo: Vec<(String, Arc<Project>)> = (0..k).map(|i| (a.label[new_line[i + 1]].clone(), a.state[new_line[i]].clone())).collect();
let redo: Vec<(String, Arc<Project>)> = new_line[k + 1..].iter().rev().map(|&i| (a.label[i].clone(), a.state[i].clone())).collect();
let cur = std::mem::replace(&mut self.project, a.state[target].clone());
self.keep_view_settings(&cur);
self.project = a.state[target].clone();
self.history.undo = undo;
self.history.redo = redo;
self.history.branches = branches;
+3 -83
View File
@@ -350,15 +350,6 @@ pub enum Event {
},
/// Drop cached frames / renders (Edit ▸ Purge).
PurgeCaches,
/// Put an image on the system clipboard (Composition ▸ Copy Frame to Clipboard): `rgba` is
/// `width` × `height` straight-alpha RGBA8 rows. Headless sessions ignore it; the pixels are
/// not serialized.
CopyImage {
width: u32,
height: u32,
#[serde(skip)]
rgba: Vec<u8>,
},
}
pub struct Session {
@@ -707,38 +698,9 @@ impl Session {
self.events.push(Event::ProjectChanged { revision: self.revision });
}
/// Set a comp's Resolution. As in After Effects it is saved with the project (which becomes
/// modified) but is no undo step, and it changes no pixels: the comp's cached frames stay
/// valid ([`effectcraft_project::Comp::same_pixels`]).
pub fn set_resolution(&mut self, comp: ItemId, r: effectcraft_project::Resolution) -> Result<()> {
if self.project.comp(comp).ok_or(EngineError::NoComp)?.resolution == r {
return Ok(());
}
Arc::make_mut(&mut self.project).comp_mut(comp).ok_or(EngineError::NoComp)?.resolution = r;
self.bump();
Ok(())
}
/// Keep what lives outside the undo history (each comp's Resolution) as it is in `from`, the
/// project before undo, redo or a history jump restored another state.
pub(crate) fn keep_view_settings(&mut self, from: &Project) {
let keep: Vec<(ItemId, effectcraft_project::Resolution)> =
self.project.comps().filter_map(|(id, c)| from.comp(*id).map(|f| (*id, f.resolution)).filter(|(_, r)| *r != c.resolution)).collect();
if keep.is_empty() {
return;
}
let p = Arc::make_mut(&mut self.project);
for (id, r) in keep {
if let Some(c) = p.comp_mut(id) {
c.resolution = r;
}
}
}
pub fn undo(&mut self) -> bool {
let Some((label, p)) = self.history.undo.pop() else { return false };
let cur = std::mem::replace(&mut self.project, p);
self.keep_view_settings(&cur);
self.history.redo.push((label, cur));
self.history.merge_key = None;
self.sanitize_state();
@@ -749,7 +711,6 @@ impl Session {
pub fn redo(&mut self) -> bool {
let Some((label, p)) = self.history.redo.pop() else { return false };
let cur = std::mem::replace(&mut self.project, p);
self.keep_view_settings(&cur);
self.history.undo.push((label, cur));
self.history.merge_key = None;
self.sanitize_state();
@@ -889,26 +850,6 @@ impl Session {
self.events.push(Event::OpenComp(id));
}
/// The open compositions, as a project file records them ([`effectcraft_project::SavedView`]).
pub fn saved_view(&self) -> effectcraft_project::SavedView {
effectcraft_project::SavedView { active_comp: self.state.active_comp, open_comps: self.state.open_comps.clone() }
}
/// Reopen the compositions a project file was saved with (#748). Ids that no longer name a
/// comp are skipped; with none left the project stays on the comp [`Session::replace_project`]
/// opened (its first), as files from before the view was saved do.
pub fn restore_view(&mut self, view: &effectcraft_project::SavedView) {
let mut open: Vec<ItemId> = Vec::new();
for id in view.open_comps.iter().copied().filter(|id| self.project.comp(*id).is_some()) {
if !open.contains(&id) {
open.push(id);
}
}
let Some(active) = view.active_comp.filter(|id| self.project.comp(*id).is_some()).or_else(|| open.first().copied()) else { return };
self.state.open_comps = open;
self.open_comp(active);
}
pub fn toast(&mut self, msg: impl Into<String>) {
self.events.push(Event::Toast { message: msg.into(), error: false });
}
@@ -1039,7 +980,6 @@ impl Session {
self.roto_job = None;
self.roto_pending.clear();
self.drop_tasks();
self.script_ui.project_epoch = self.script_ui.project_epoch.wrapping_add(1);
p.fix_next_id();
upgrade_effects(&mut p);
self.project = Arc::new(p);
@@ -1106,8 +1046,6 @@ mod tests;
#[cfg(test)]
mod tests_3d;
#[cfg(test)]
mod tests_anim_presets;
#[cfg(test)]
mod tests_anim_tools;
#[cfg(test)]
mod tests_camera_track;
@@ -1130,14 +1068,10 @@ mod tests_fidelity;
#[cfg(test)]
mod tests_frame_export;
#[cfg(test)]
mod tests_fuzz_open;
#[cfg(test)]
mod tests_key_metadata_order;
#[cfg(test)]
mod tests_keylight;
#[cfg(test)]
mod tests_limits;
#[cfg(test)]
mod tests_lottie;
#[cfg(test)]
mod tests_m137;
@@ -1190,11 +1124,6 @@ pub fn text_presets() -> Vec<(String, String)> {
commands::text_preset_list()
}
/// Built-in animation presets (Effects & Presets ▸ * Animation Presets): (id, name, category).
pub fn animation_presets() -> Vec<(String, String, String)> {
commands::builtin_preset_list()
}
pub fn text_families() -> Vec<String> {
effectcraft_text::families().into_iter().map(|(f, _)| f).collect()
}
@@ -1224,8 +1153,6 @@ pub struct FontRow {
/// Settings ▸ Type ▸ Show Font Names in English is off.
pub display: String,
pub recent: bool,
/// A variable font (the menu's Variable Fonts filter keeps only these).
pub variable: bool,
}
/// Outline polylines of "Sample" set in `family` at `size` px (baseline at y = 0, y down), for
@@ -1266,21 +1193,14 @@ pub fn font_preview(family: &str, size: f64) -> Vec<Vec<[f32; 2]>> {
}
/// The Character panel's font menu: the recent fonts (Settings ▸ Type ▸ Number of Recent Fonts
/// to Display), a separator, then every family. Each row says whether the family is variable,
/// for the menu's Variable Fonts filter.
/// to Display), a separator, then every family.
pub fn font_menu(prefs: &prefs::Prefs) -> Vec<FontRow> {
let all = text_families();
let native = if prefs.type_.font_names_in_english { Default::default() } else { effectcraft_text::fonts::native_families() };
let variable = effectcraft_text::fonts::variable_families();
let row = |f: &String, recent: bool| FontRow {
family: f.clone(),
display: native.get(f).cloned().unwrap_or_else(|| f.clone()),
recent,
variable: variable.contains(f),
};
let row = |f: &String, recent: bool| FontRow { family: f.clone(), display: native.get(f).cloned().unwrap_or_else(|| f.clone()), recent };
let mut out: Vec<FontRow> = prefs.recent_fonts_shown().iter().filter(|f| all.contains(f)).map(|f| row(f, true)).collect();
if !out.is_empty() {
out.push(FontRow { family: String::new(), display: "-".into(), recent: false, variable: false });
out.push(FontRow { family: String::new(), display: "-".into(), recent: false });
}
out.extend(all.iter().map(|f| row(f, false)));
out
+1 -22
View File
@@ -1,27 +1,6 @@
//! Community and project links (shown in Help ▸, the About dialog, the start screen and the
//! header's Discord button).
//! Project links (shown in Help ▸, the About dialog and the start screen).
pub const APP_NAME: &str = "EffectCraft";
pub const APP_SLUG: &str = "effectcraft";
pub const DISCORD: &str = "https://discord.gg/artcraft";
pub const WEBSITE: &str = "https://getartcraft.com";
pub const APP_PAGE: &str = "https://getartcraft.com/apps/effectcraft";
pub const GITHUB: &str = "https://github.com/storytold/effectcraft";
pub const ISSUES: &str = "https://github.com/storytold/effectcraft/issues";
/// Sibling apps of the ArtCraft family: (name, what it is, GitHub repo, app page).
pub const SIBLINGS: &[(&str, &str)] = &[
("PhotoCraft", "photocraft"),
("VectorCraft", "vectorcraft"),
("FilmCraft", "filmcraft"),
("LightCraft", "lightcraft"),
("PdfCraft", "pdfcraft"),
("DesignCraft", "designcraft"),
];
pub fn github_of(slug: &str) -> String {
format!("https://github.com/storytold/{slug}")
}
pub fn page_of(slug: &str) -> String {
format!("https://getartcraft.com/apps/{slug}")
}
+27 -301
View File
@@ -1,47 +1,25 @@
//! The `log` logger. Records go to standard error and, on the desktop, to a session log that
//! every launch starts afresh: `Logs/effectcraft.log` in the settings folder, with the two runs
//! before it kept as `effectcraft.1.log` and `effectcraft.2.log`, so the log of a run that
//! crashed survives the next start. A launch from the Dock, the Start menu or a launcher has no
//! terminal, so that file is what a bug report can attach. The last few hundred warnings and
//! errors are also kept in memory for the System Compatibility Report.
//! Help ▸ Enable Logging / Reveal Logging File: a `log` logger that, while enabled, appends
//! every record to `Logs/EffectCraft Log.txt` in the settings folder (or the temporary folder),
//! and always keeps the last few hundred warnings and errors in memory for the System
//! Compatibility Report.
//!
//! Levels: `info` for EffectCraft's own crates, `warn` for everything else (wgpu and naga are
//! chatty). `RUST_LOG` replaces that (a level such as `debug`, or per target:
//! `effectcraft=debug,wgpu=warn`).
//!
//! Help ▸ Enable Logging ([`set_enabled`], the `help.enableLogging` command) additionally appends
//! every EffectCraft record to `Logs/EffectCraft Log.txt`, across launches.
//!
//! Frontends call [`install`] once at startup and the desktop app [`attach_session_log`] once the
//! settings folder is known; records logged before that are kept (up to [`MAX_PENDING`]) and
//! written first. Writing never panics: a file that can't be created or written leaves standard
//! error as the only sink. Never log the control-channel token.
//! Frontends call [`install`] once at startup; [`set_enabled`] (the `help.enableLogging`
//! command) turns file logging on and off. `RUST_LOG` (a level such as `debug`, or per target:
//! `effectcraft=debug,wgpu=warn`) also prints what it lets through to stderr, so a start-up that
//! fails before the window opens leaves a trace.
use std::collections::VecDeque;
use std::fs::File;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Mutex, OnceLock, PoisonError};
use std::sync::{Mutex, OnceLock};
use log::LevelFilter;
/// The session log's name inside the `Logs` folder.
pub const SESSION_LOG: &str = "effectcraft.log";
/// How many previous session logs are kept (`effectcraft.1.log` … `effectcraft.<KEEP>.log`).
pub const KEEP: usize = 2;
/// The session log stops growing past this size (a runaway warning can't fill the disk).
pub const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024;
/// Records kept in memory until the session log is attached.
pub const MAX_PENDING: usize = 512;
/// The filter when `RUST_LOG` is unset or empty.
pub const DEFAULT_FILTER: &str = "warn,effectcraft=info";
static ENABLED: AtomicBool = AtomicBool::new(false);
/// The filter for standard error and the session log: (target prefix or every target, level).
static FILTER: OnceLock<Vec<(Option<String>, LevelFilter)>> = OnceLock::new();
/// The `RUST_LOG` filter for stderr: (target prefix or every target, level).
static STDERR: OnceLock<Vec<(Option<String>, LevelFilter)>> = OnceLock::new();
static STATE: Mutex<State> = Mutex::new(State { file: None, recent: VecDeque::new() });
static SESSION: Mutex<Sink> = Mutex::new(Sink::new(MAX_FILE_BYTES));
struct State {
file: Option<PathBuf>,
@@ -54,16 +32,15 @@ const RECENT: usize = 300;
impl log::Log for Logger {
fn enabled(&self, m: &log::Metadata) -> bool {
m.level() <= log::Level::Warn || (ENABLED.load(Ordering::Relaxed) && m.target().starts_with("effectcraft")) || shown(m)
m.level() <= log::Level::Warn || (ENABLED.load(Ordering::Relaxed) && m.target().starts_with("effectcraft")) || to_stderr(m)
}
fn log(&self, r: &log::Record) {
if !self.enabled(r.metadata()) {
return;
}
let line = format_line(&timestamp(unix_millis()), r.level(), &thread_name(), r.target(), &r.args().to_string());
if shown(r.metadata()) {
let _ = writeln!(std::io::stderr(), "{line}");
SESSION.lock().unwrap_or_else(PoisonError::into_inner).write(&format!("{line}\n"));
let line = format!("{} {:<5} {}: {}", unix_secs(), r.level(), r.target(), r.args());
if to_stderr(r.metadata()) {
eprintln!("{line}");
}
let Ok(mut st) = STATE.lock() else { return };
if r.level() <= log::Level::Warn {
@@ -82,62 +59,22 @@ impl log::Log for Logger {
fn flush(&self) {}
}
fn unix_millis() -> u64 {
web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX)).unwrap_or(0)
}
fn thread_name() -> String {
#[cfg(not(target_arch = "wasm32"))]
return std::thread::current().name().unwrap_or("?").to_string();
#[cfg(target_arch = "wasm32")]
return "main".to_string();
}
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds) for `ms` milliseconds since 1970.
fn timestamp(ms: u64) -> String {
let secs = ms / 1000;
let (days, rem) = (secs / 86_400, secs % 86_400);
let (y, m, d) = civil_from_days(days);
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, ms % 1000)
}
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
/// `civil_from_days`, unsigned since the clock is clamped at the epoch). `days` is at most
/// `u64::MAX / 86_400_000`, so none of the arithmetic below can overflow.
fn civil_from_days(days: u64) -> (u64, u64, u64) {
let z = days + 719_468;
let era = z / 146_097;
let doe = z % 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + u64::from(m <= 2);
(y, m, d)
}
/// One log line (without the newline): `<timestamp> <LEVEL> [<thread>] <target>: <message>`.
fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String {
let message = message.strip_suffix('\n').unwrap_or(message);
format!("{ts} {level:<5} [{thread}] {target}: {message}")
fn unix_secs() -> u64 {
web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}
/// Install the logger (once; later calls and other installed loggers are left alone).
pub fn install() {
static LOGGER: Logger = Logger;
if log::set_logger(&LOGGER).is_ok() {
let filter = FILTER.get_or_init(|| {
let spec = std::env::var("RUST_LOG").ok().filter(|s| !s.trim().is_empty());
parse_filter(spec.as_deref().unwrap_or(DEFAULT_FILTER))
});
log::set_max_level(filter.iter().map(|d| d.1).fold(LevelFilter::Info, Ord::max));
let stderr = STDERR.get_or_init(|| std::env::var("RUST_LOG").map(|s| parse_filter(&s)).unwrap_or_default());
log::set_max_level(stderr.iter().map(|d| d.1).fold(LevelFilter::Info, Ord::max));
}
}
/// Whether the filter lets a record through to standard error and the session log.
fn shown(m: &log::Metadata) -> bool {
FILTER.get().is_some_and(|f| m.level() <= level_for(f, m.target()))
/// Whether `RUST_LOG` lets a record through to stderr.
fn to_stderr(m: &log::Metadata) -> bool {
STDERR.get().is_some_and(|f| m.level() <= level_for(f, m.target()))
}
/// `RUST_LOG`'s directives, as env_logger reads them: `level`, `target=level` or a bare target
@@ -173,112 +110,9 @@ pub fn install_panic_hook() {
}));
}
/// The `Logs` folder inside `config_dir` (the settings folder), or in the temporary folder.
pub fn log_dir(config_dir: Option<&Path>) -> PathBuf {
config_dir.map(Path::to_path_buf).unwrap_or_else(std::env::temp_dir).join("Logs")
}
/// Help ▸ Enable Logging's file inside `config_dir` (the settings folder), or the temporary folder.
/// The log file inside `config_dir` (the settings folder), or the temporary folder.
pub fn log_path(config_dir: Option<&Path>) -> PathBuf {
log_dir(config_dir).join("EffectCraft Log.txt")
}
fn numbered(dir: &Path, n: usize) -> PathBuf {
if n == 0 { dir.join(SESSION_LOG) } else { dir.join(format!("effectcraft.{n}.log")) }
}
/// Shift the previous session logs up one (`.log` → `.1.log` → … → `.<KEEP>.log`, the oldest
/// dropped) and create a fresh, empty log file in `dir` (created if missing).
fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> {
std::fs::create_dir_all(dir)?;
for n in (1..=KEEP).rev() {
match std::fs::rename(numbered(dir, n - 1), numbered(dir, n)) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e),
_ => {}
}
}
let path = numbered(dir, 0);
let file = File::create(&path)?;
Ok((path, file))
}
/// Rotate the session logs in `dir` (the `Logs` folder, see [`log_dir`]) and write records to
/// a fresh `effectcraft.log` there, first the ones logged so far; returns its path.
pub fn attach_session_log(dir: &Path) -> Result<PathBuf, String> {
let (path, file) = rotate(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let mut sink = SESSION.lock().unwrap_or_else(PoisonError::into_inner);
sink.attach(file);
sink.path = Some(path.clone());
Ok(path)
}
/// This run's session log (once [`attach_session_log`] succeeded).
pub fn session_file() -> Option<PathBuf> {
SESSION.lock().unwrap_or_else(PoisonError::into_inner).path.clone()
}
/// Where lines go besides standard error: kept in memory until a file is attached, then
/// appended to it until it reaches its size cap.
struct Sink {
file: Option<File>,
path: Option<PathBuf>,
pending: Vec<String>,
dropped: usize,
written: u64,
max_bytes: u64,
capped: bool,
}
impl Sink {
const fn new(max_bytes: u64) -> Sink {
Sink { file: None, path: None, pending: Vec::new(), dropped: 0, written: 0, max_bytes, capped: false }
}
/// Append `line` to the file, or keep it until [`Sink::attach`].
fn write(&mut self, line: &str) {
if self.file.is_none() && !self.capped {
if self.pending.len() < MAX_PENDING {
self.pending.push(line.to_owned());
} else {
self.dropped = self.dropped.saturating_add(1);
}
return;
}
self.append(line);
}
/// Start writing to `file`, first the lines kept so far.
fn attach(&mut self, file: File) {
self.file = Some(file);
for line in std::mem::take(&mut self.pending) {
self.append(&line);
}
if self.dropped > 0 {
let note = format!("{} earlier log lines were dropped before the log file was opened\n", self.dropped);
self.dropped = 0;
self.append(&note);
}
}
fn append(&mut self, line: &str) {
if self.capped {
return;
}
let Some(file) = self.file.as_mut() else { return };
let len = u64::try_from(line.len()).unwrap_or(u64::MAX);
let text = if self.written.saturating_add(len) > self.max_bytes {
self.capped = true;
format!("log file reached {} bytes; later records go to standard error only\n", self.max_bytes)
} else {
self.written = self.written.saturating_add(len);
line.to_owned()
};
// A full disk or a vanished file must not take the app down: drop the file sink.
if file.write_all(text.as_bytes()).is_err() {
self.file = None;
self.capped = true;
}
}
config_dir.map(Path::to_path_buf).unwrap_or_else(std::env::temp_dir).join("Logs").join("EffectCraft Log.txt")
}
/// Turn file logging on (writing to `file`) or off.
@@ -289,7 +123,7 @@ pub fn set_enabled(on: bool, file: PathBuf) -> std::io::Result<()> {
std::fs::create_dir_all(d)?;
}
let mut f = std::fs::OpenOptions::new().create(true).append(true).open(&file)?;
writeln!(f, "{} INFO effectcraft: logging enabled ({} {})", timestamp(unix_millis()), std::env::consts::OS, env!("CARGO_PKG_VERSION"))?;
writeln!(f, "{} INFO effectcraft: logging enabled ({} {})", unix_secs(), std::env::consts::OS, env!("CARGO_PKG_VERSION"))?;
}
if let Ok(mut st) = STATE.lock() {
st.file = Some(file);
@@ -302,7 +136,7 @@ pub fn is_enabled() -> bool {
ENABLED.load(Ordering::Relaxed)
}
/// The current Enable Logging file (set once logging was enabled).
/// The current log file (set once logging was enabled).
pub fn current_file() -> Option<PathBuf> {
STATE.lock().ok().and_then(|s| s.file.clone())
}
@@ -314,8 +148,7 @@ pub fn recent() -> Vec<String> {
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::AtomicUsize;
use super::{LevelFilter, level_for, parse_filter};
/// `RUST_LOG` picks what start-up prints to stderr (#234).
#[test]
@@ -329,111 +162,4 @@ mod tests {
assert_eq!(level_for(&parse_filter("wgpu=debug"), "winit"), LevelFilter::Off);
assert!(parse_filter("").is_empty());
}
fn temp_dir(tag: &str) -> PathBuf {
static N: AtomicUsize = AtomicUsize::new(0);
let d = std::env::temp_dir().join(format!("effectcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
let _ = std::fs::remove_dir_all(&d);
d
}
fn read(path: &Path) -> String {
std::fs::read_to_string(path).unwrap()
}
/// Without `RUST_LOG`, EffectCraft's own info records and everyone's warnings are shown.
#[test]
fn the_default_filter_shows_effectcraft_info_and_other_crates_warnings() {
let f = parse_filter(DEFAULT_FILTER);
assert_eq!(level_for(&f, "effectcraft"), LevelFilter::Info);
assert_eq!(level_for(&f, "effectcraft_ui_egui::app"), LevelFilter::Info);
assert_eq!(level_for(&f, "wgpu_core::device"), LevelFilter::Warn);
assert_eq!(level_for(&f, "naga"), LevelFilter::Warn);
}
#[test]
fn timestamps_are_utc_with_milliseconds() {
assert_eq!(timestamp(0), "1970-01-01T00:00:00.000Z");
assert_eq!(timestamp(1_791_446_357_728), "2026-10-08T07:59:17.728Z");
assert_eq!(timestamp(1_709_164_800_000), "2024-02-29T00:00:00.000Z");
assert_eq!(timestamp(951_782_400_000), "2000-02-29T00:00:00.000Z");
assert_eq!(timestamp(1_767_225_599_000), "2025-12-31T23:59:59.000Z");
let _ = timestamp(u64::MAX);
}
/// Each launch keeps the previous runs' logs, so the log of a run that crashed survives.
#[test]
fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() {
let dir = temp_dir("rotate");
for run in 1..=4 {
let (path, _file) = rotate(&dir).unwrap();
assert_eq!(path, dir.join(SESSION_LOG));
assert_eq!(read(&path), "");
std::fs::write(&path, format!("run {run}")).unwrap();
}
assert_eq!(read(&dir.join(SESSION_LOG)), "run 4");
assert_eq!(read(&dir.join("effectcraft.1.log")), "run 3");
assert_eq!(read(&dir.join("effectcraft.2.log")), "run 2");
assert!(!dir.join("effectcraft.3.log").exists(), "only {KEEP} old logs are kept");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() {
let dir = temp_dir("blocked");
std::fs::write(&dir, "a file where the directory should be").unwrap();
assert!(rotate(&dir).is_err());
let _ = std::fs::remove_file(&dir);
}
#[test]
fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() {
let dir = temp_dir("pending");
let (path, file) = rotate(&dir).unwrap();
let mut sink = Sink::new(MAX_FILE_BYTES);
sink.write("early 1\n");
sink.write("early 2\n");
sink.attach(file);
sink.write("late\n");
assert_eq!(read(&path), "early 1\nearly 2\nlate\n");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() {
let dir = temp_dir("bounded");
let (path, file) = rotate(&dir).unwrap();
let mut sink = Sink::new(MAX_FILE_BYTES);
for i in 0..MAX_PENDING + 5 {
sink.write(&format!("line {i}\n"));
}
sink.attach(file);
let text = read(&path);
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING);
assert_eq!(text.matches("5 earlier log lines were dropped").count(), 1, "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_file_stops_at_its_size_cap_with_one_note() {
let dir = temp_dir("cap");
let (path, file) = rotate(&dir).unwrap();
let mut sink = Sink::new(100);
sink.attach(file);
for i in 0..50 {
sink.write(&format!("line {i:02} with some padding\n"));
}
let text = read(&path);
assert!(text.lines().filter(|l| l.starts_with("line ")).count() < 5, "{text}");
assert_eq!(text.matches("log file reached").count(), 1, "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_line_carries_time_level_thread_target_and_message() {
assert_eq!(
format_line("2026-10-08T07:59:17.728Z", log::Level::Warn, "main", "effectcraft_engine::autosave", "disk full\n"),
"2026-10-08T07:59:17.728Z WARN [main] effectcraft_engine::autosave: disk full"
);
}
}
+2 -2
View File
@@ -194,10 +194,10 @@ pub struct Entry {
}
const VIDEO: &[&str] = &["mp4", "m4v", "mov", "webm", "mkv", "avi", "mxf", "gif"];
const IMAGE: &[&str] = &["png", "jpg", "jpeg", "tif", "tiff", "exr", "bmp", "webp", "psd", "psb", "svg", "tga", "dpx", "cin", "hdr"];
const IMAGE: &[&str] = &["png", "jpg", "jpeg", "tif", "tiff", "exr", "bmp", "webp", "psd", "psb", "svg", "tga", "dpx", "hdr"];
const AUDIO: &[&str] = &["wav", "aif", "aiff", "mp3", "m4a", "aac", "flac", "ogg", "opus"];
const DATA: &[&str] = &["json", "csv", "tsv", "mgjson"];
const MODEL: &[&str] = &["gltf", "glb", "obj", "stl", "fbx"];
const MODEL: &[&str] = &["gltf", "glb", "obj"];
/// Kind of file from its extension.
pub fn kind_of(path: &str) -> Option<&'static str> {
+14 -15
View File
@@ -322,9 +322,6 @@ pub fn checked(s: &Session, command: &str, params: &Value) -> Option<bool> {
}
"layer.markersLock" => Some(layer?.markers_locked),
"layer.mask.hideLocked" => Some(s.state.hide_locked_masks),
"view.res.full" | "view.res.half" | "view.res.third" | "view.res.quarter" | "view.res.custom" => {
Some(comp.resolution.label().eq_ignore_ascii_case(command.trim_start_matches("view.res.")))
}
"view.layout" => Some(params.get("views").and_then(Value::as_u64) == Some(s.state.view_layout.max(1) as u64)),
"view.shareViewOptions" => Some(s.state.share_view_options),
"view.extendedViewer" => Some(s.prefs.three_d.extended_viewer),
@@ -696,7 +693,6 @@ Composition
File... | comp.saveFrameAs
Photoshop Layers... | comp.saveFrameAsPsd
ProEXR... | comp.saveFrameAsExr
Copy Frame to Clipboard | comp.copyFrame
Pre-render... | render.preRender
Save Current Preview... | render.saveCurrentPreview
---
@@ -1025,14 +1021,6 @@ Animation
Brightness | layer.addTextAnimator {"properties":["strokeBrightness"]}
Opacity | layer.addTextAnimator {"properties":["strokeOpacity"]}
Stroke Width | layer.addTextAnimator {"properties":["strokeWidth"]}
Front
Color | layer.addTextAnimator {"properties":["frontColor"]}
Bevel
Color | layer.addTextAnimator {"properties":["bevelColor"]}
Side
Color | layer.addTextAnimator {"properties":["sideColor"]}
Back
Color | layer.addTextAnimator {"properties":["backColor"]}
---
Tracking | layer.addTextAnimator {"properties":["tracking"]}
Line Anchor | layer.addTextAnimator {"properties":["lineAnchor"]}
@@ -1150,6 +1138,20 @@ View
---
Go to Time... | time.set
Enter Full Screen | view.fullScreen
---
Preferences
Theme
Dark | view.theme.dark
Darker | view.theme.darker
Light | view.theme.light
---
Catppuccin Latte | view.theme.catppuccinLatte
Catppuccin Frappé | view.theme.catppuccinFrappe
Catppuccin Macchiato | view.theme.catppuccinMacchiato
Catppuccin Mocha | view.theme.catppuccinMocha
---
Next Appearance Mode | view.theme.toggle
Appearance... | app.settings {"page":"appearance"}
Window
Workspace
Default | window.workspace {"name":"Default"} | Shift+F10
@@ -1230,11 +1232,8 @@ Help
Enable Logging | help.enableLogging
Reveal Logging File | help.revealLogFile
---
Join the ArtCraft Discord... | help.discord
Provide Feedback... | help.reportIssue
---
ArtCraft Website | help.website
EffectCraft Home Page | help.appPage
EffectCraft on GitHub | help.github
Open Demo Project | file.openDemoProject
[!mac] ---
+28 -73
View File
@@ -32,12 +32,11 @@ pub const LANGUAGES: &[(&str, &str)] = &[
("Українська", "uk"),
("Español", "es"),
("Português (Brasil)", "pt-br"),
("Русский", "ru"),
("Français", "fr"),
("Deutsch", "de"),
("Čeština", "cs"),
];
/// Settings ▸ Appearance ▸ UI Scale: (label, `appearance.uiScale` percent).
pub const UI_SCALES: &[(&str, &str)] = &[("75%", "75"), ("100%", "100"), ("125%", "125"), ("150%", "150"), ("175%", "175"), ("200%", "200")];
/// Settings ▸ Startup & Repair ▸ Window Graphics: (label, `startup.windowGraphics` value).
pub const WINDOW_GRAPHICS: &[(&str, &str)] = &[("Automatic", "auto"), ("OpenGL (compatibility)", "gl")];
@@ -46,13 +45,27 @@ pub const WINDOW_GRAPHICS: &[(&str, &str)] = &[("Automatic", "auto"), ("OpenGL (
pub const APPEARANCE_MODES: &[(&str, &str)] = &[("Sync with System", "auto"), ("Dark", "dark"), ("Light", "light")];
/// The dark family of themes: (label, `appearance.darkTheme` value).
pub const DARK_THEMES: &[(&str, &str)] = &[("Dark", "dark"), ("Darker", "darker")];
pub const DARK_THEMES: &[(&str, &str)] = &[
("Dark", "dark"),
("Darker", "darker"),
("Catppuccin Frappé", "catppuccinFrappe"),
("Catppuccin Macchiato", "catppuccinMacchiato"),
("Catppuccin Mocha", "catppuccinMocha"),
];
/// The light family of themes: (label, `appearance.lightTheme` value).
pub const LIGHT_THEMES: &[(&str, &str)] = &[("Light", "light")];
pub const LIGHT_THEMES: &[(&str, &str)] = &[("Light", "light"), ("Catppuccin Latte", "catppuccinLatte")];
/// Every theme (the legacy single-theme `appearance.theme`).
pub const THEMES: &[(&str, &str)] = &[("Dark", "dark"), ("Darker", "darker"), ("Light", "light")];
pub const THEMES: &[(&str, &str)] = &[
("Dark", "dark"),
("Darker", "darker"),
("Light", "light"),
("Catppuccin Latte", "catppuccinLatte"),
("Catppuccin Frappé", "catppuccinFrappe"),
("Catppuccin Macchiato", "catppuccinMacchiato"),
("Catppuccin Mocha", "catppuccinMocha"),
];
/// The settings whose value must be one of their choices.
fn choices(key: &str) -> Option<&'static [(&'static str, &'static str)]> {
@@ -176,8 +189,6 @@ page!(Appearance {
dark_theme: String = "dark".into(),
/// The theme used in light appearance: `light`.
light_theme: String = "light".into(),
/// Theme shortcuts in the appearance context menu.
favorite_themes: Vec<String> = Vec::new(),
/// User interface brightness, -1 (darker) … 1 (lighter).
brightness: f64 = 0.0,
/// The size of the whole interface in percent (75–200), on top of the display's own scale.
@@ -188,6 +199,9 @@ page!(Appearance {
use_gradients: bool = true,
/// macOS: draw the menu bar inside the window instead of the system menu bar.
in_window_menu_bar_mac: bool = false,
/// Windows and Linux: keep the system's title bar instead of drawing the window's own.
/// Read before the window opens: it applies from the next launch.
system_title_bar: bool = false,
});
page!(Grids {
@@ -338,42 +352,6 @@ pub struct LabelPref {
pub color: String,
}
/// A Composition Settings preset the user saved (Composition Settings ▸ Preset ▸ Save): the
/// frame size, pixel aspect and frame rate, as in After Effects' own presets.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CompPreset {
pub name: String,
pub width: u32,
pub height: u32,
pub pixel_aspect: f64,
pub frame_rate: f64,
}
impl CompPreset {
/// Longest preset name kept.
pub const MAX_NAME: usize = 128;
/// Values a composition accepts (a hand-edited settings file can hold anything).
pub fn is_valid(&self) -> bool {
let size = 4..=30000;
!self.name.trim().is_empty()
&& self.name.chars().count() <= Self::MAX_NAME
&& size.contains(&self.width)
&& size.contains(&self.height)
&& self.pixel_aspect.is_finite()
&& (0.01..=100.0).contains(&self.pixel_aspect)
&& self.frame_rate.is_finite()
&& (1.0..=999.0).contains(&self.frame_rate)
}
}
/// Saved composition presets, skipping entries that don't parse instead of losing the list.
fn lenient_comp_presets<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<CompPreset>, D::Error> {
let v = Vec::<Value>::deserialize(d)?;
Ok(v.into_iter().filter_map(|x| serde_json::from_value(x).ok()).collect())
}
fn default_labels() -> Vec<LabelPref> {
Label::ALL.iter().skip(1).map(|l| LabelPref { name: l.name().to_string(), color: hex(l.rgb()) }).collect()
}
@@ -432,9 +410,6 @@ pub struct Prefs {
pub preview: crate::preview::PreviewSettings,
/// View ▸ Simulate Output ▸ My Custom RGB.
pub custom_rgb: crate::viewer::CustomRgb,
/// Composition Settings presets the user saved, in the order saved.
#[serde(deserialize_with = "lenient_comp_presets")]
pub comp_presets: Vec<CompPreset>,
/// Top-level keys this version doesn't know (kept for newer versions).
#[serde(flatten)]
pub extra: Extra,
@@ -470,7 +445,6 @@ impl Default for Prefs {
recent_fonts: vec![],
preview: crate::preview::PreviewSettings::default(),
custom_rgb: crate::viewer::CustomRgb::default(),
comp_presets: vec![],
extra: Extra::new(),
}
}
@@ -596,7 +570,6 @@ impl Prefs {
/// Clamp values into their valid ranges and fill missing labels.
pub fn normalize(&mut self) {
self.version = PREFS_VERSION;
self.comp_presets.retain(CompPreset::is_valid);
if !is_choice(WINDOW_GRAPHICS, &self.startup.window_graphics) {
self.startup.window_graphics = Startup::default().window_graphics;
}
@@ -611,8 +584,6 @@ impl Prefs {
a.interval_minutes = a.interval_minutes.clamp(1, 240);
a.max_versions = a.max_versions.clamp(1, 99);
let ap = &mut self.appearance;
let mut seen = std::collections::BTreeSet::new();
ap.favorite_themes.retain(|id| is_choice(THEMES, id) && seen.insert(id.clone()));
if !is_choice(APPEARANCE_MODES, &ap.appearance_mode) {
ap.appearance_mode = Appearance::default().appearance_mode;
}
@@ -669,23 +640,6 @@ impl Prefs {
let all: Vec<&str> = c.iter().map(|(_, v)| *v).collect();
return Err(format!("`{key}` expects one of {}", all.join(", ")));
}
let favorites = if key == "appearance.favoriteThemes" {
Some(&value)
} else if key == "appearance" {
value.get("favoriteThemes")
} else {
None
};
if let Some(favorites) = favorites {
let values = favorites.as_array().ok_or("favorite themes must be a list")?;
let mut seen = std::collections::BTreeSet::new();
for value in values {
let id = value.as_str().ok_or("favorite theme must be a string")?;
if !is_choice(THEMES, id) || !seen.insert(id) {
return Err("unknown or duplicate favorite theme".into());
}
}
}
// Older clients set the single theme (by key, or inside the page object): it still picks
// a visible theme, fixing the mode to its family.
let legacy_theme = key == "appearance.theme" || (key == "appearance" && value.get("theme").is_some() && value.get("appearanceMode").is_none());
@@ -705,8 +659,8 @@ impl Prefs {
Ok(())
}
/// Reset one page (`general`, `labels`, `autoSave`…) or everything (`None`). Open Recent and
/// the saved composition presets are kept.
/// Reset one page (`general`, `labels`, `autoSave`…) or everything (`None`). Open Recent is
/// kept.
pub fn reset(&mut self, page: Option<&str>) -> Result<(), String> {
let d = Prefs::default();
let Some(page) = page else {
@@ -716,7 +670,6 @@ impl Prefs {
recent_presets: std::mem::take(&mut self.recent_presets),
recent_fonts: std::mem::take(&mut self.recent_fonts),
preview: std::mem::take(&mut self.preview),
comp_presets: std::mem::take(&mut self.comp_presets),
..d
};
return Ok(());
@@ -1234,7 +1187,7 @@ pub fn pages() -> Vec<Page> {
s("appearance.lightTheme", "Light Theme", Kind::Choice(LIGHT_THEMES), true),
s("appearance.darkTheme", "Dark Theme", Kind::Choice(DARK_THEMES), true),
s("appearance.brightness", "Brightness", Kind::Slider(-1.0, 1.0), true),
s("appearance.uiScale", "UI Scale", Kind::Int(75, 200, "%"), true),
s("appearance.uiScale", "UI Scale", Kind::Choice(UI_SCALES), true),
Section("Labels and Colors"),
s("appearance.useLabelColorForHandles", "Use Label Color for Layer Handles and Paths", B, true),
s("appearance.useLabelColorForTabs", "Use Label Color for Related Tabs", B, true),
@@ -1242,6 +1195,8 @@ pub fn pages() -> Vec<Page> {
s("appearance.useGradients", "Use Gradients", B, true),
Section("Menu Bar"),
s("appearance.inWindowMenuBarMac", "Use In-Window Menu Bar on macOS", B, true),
Section("Title Bar"),
s("appearance.systemTitleBar", "Use System Title Bar on Windows and Linux", B, true),
],
},
Page {
+1 -3
View File
@@ -116,9 +116,7 @@ pub fn import(proj: &mut Project, psd: &Psd, path: &str, name: &str, retain: boo
}
fn new_comp(cx: &Ctx) -> Comp {
// A PSB can be 300,000 px a side; a composition is at most 30,000 (#788).
let size = |v: u32| effectcraft_project::limits::size(v.into());
Comp::new(size(cx.psd.width), size(cx.psd.height), cx.rate, cx.duration)
Comp::new(cx.psd.width, cx.psd.height, cx.rate, cx.duration)
}
fn build_comp(proj: &mut Project, cx: &mut Ctx, name: &str, nodes: &[Node], parent: Option<ItemId>) -> ItemId {
-5
View File
@@ -203,11 +203,6 @@ pub fn bytes_per_frame(om: &OutputModule, w: u32, h: u32, fps: f64) -> f64 {
};
mbps(rate) * px / 29.97
}
// 24-bit RGB rows padded to even bytes, or 32-bit ARGB.
OutputFormat::Uncompressed => {
let bpp = if om.channels == effectcraft_project::render_queue::Channels::Rgba { 4 } else { 3 };
((w as u64 * bpp).next_multiple_of(2) * h as u64) as f64
}
OutputFormat::WebM => mbps(8.0) * px / fps,
OutputFormat::Gif => w as f64 * h as f64 * 0.5,
OutputFormat::Wav | OutputFormat::Aiff => om.audio_sample_rate as f64 * 4.0 / fps,
+6 -6
View File
@@ -67,11 +67,11 @@ fn add_configure_render() {
let r = s.execute("renderQueue.setOutputModule", json!({"item": id, "format": "png", "channels": "rgba"})).unwrap();
assert!(r["outputPath"].as_str().unwrap().ends_with("_[#####].png"), "{r}");
assert!(s.execute("renderQueue.setOutputModule", json!({"item": id, "format": "jpeg", "channels": "rgba"})).is_err());
let r = s.execute("renderQueue.setOutput", json!({"item": id, "path": crate::tests_stubs::tmp_out("rq/out.mov")})).unwrap();
let r = s.execute("renderQueue.setOutput", json!({"item": id, "path": "/tmp/rq/out.mov"})).unwrap();
assert_eq!(r["output"]["format"], "ProRes", "Output To with .mov switches to ProRes");
assert_eq!(std::path::Path::new(r["outputPath"].as_str().unwrap()), std::path::PathBuf::from(crate::tests_stubs::tmp_out("rq/out.mov")));
assert_eq!(std::path::Path::new(r["outputPath"].as_str().unwrap()), std::path::absolute("/tmp/rq/out.mov").unwrap());
// A second item that fails, a third that is unqueued.
let b = s.execute("renderQueue.add", json!({"format": "gif", "output": crate::tests_stubs::tmp_out("rq/fail.gif")})).unwrap();
let b = s.execute("renderQueue.add", json!({"format": "gif", "output": "/tmp/rq/fail.gif"})).unwrap();
let c = s.execute("renderQueue.add", json!({"output": "/tmp/rq/skip.mp4"})).unwrap();
s.execute("renderQueue.setRender", json!({"item": c["item"], "render": false})).unwrap();
assert_eq!(s.project.render_queue[2].status.label(), "Unqueued");
@@ -79,12 +79,12 @@ fn add_configure_render() {
assert_eq!(r["items"].as_array().unwrap().len(), 2);
assert_eq!(
ex.log.lock().unwrap().iter().map(std::path::PathBuf::from).collect::<Vec<_>>(),
[std::path::PathBuf::from(crate::tests_stubs::tmp_out("rq/out.mov")), std::path::PathBuf::from(crate::tests_stubs::tmp_out("rq/fail.gif"))]
[std::path::absolute("/tmp/rq/out.mov").unwrap(), std::path::absolute("/tmp/rq/fail.gif").unwrap()]
);
let q = &s.project.render_queue;
assert_eq!(q[0].status.label(), "Done");
assert!(q[0].started.is_some() && q[0].render_time.is_some());
assert_eq!(std::path::Path::new(q[0].last_output.as_deref().unwrap()), std::path::PathBuf::from(crate::tests_stubs::tmp_out("rq/out.mov")));
assert_eq!(std::path::Path::new(q[0].last_output.as_deref().unwrap()), std::path::absolute("/tmp/rq/out.mov").unwrap());
assert_eq!(q[1].status, RenderStatus::Failed("boom".into()));
assert_eq!(q[2].status.label(), "Unqueued");
assert!(s.drain_events().iter().any(|e| matches!(e, Event::Toast { error: true, .. })));
@@ -162,7 +162,7 @@ fn without_exporter() {
s.execute("renderQueue.add", json!({})).unwrap();
assert!(!s.is_enabled("renderQueue.render"));
let f = s.execute("renderQueue.formats", json!({})).unwrap();
assert_eq!(f["formats"].as_array().unwrap().len(), 13);
assert_eq!(f["formats"].as_array().unwrap().len(), 12);
assert_eq!(f["formats"][0]["available"], false);
}
+2 -79
View File
@@ -110,10 +110,6 @@ pub struct Widget {
pub read_only: bool,
/// Event handlers the script attached (`onClick`, `onChange`, `onChanging`…).
pub handlers: Vec<String>,
/// The mouse events ([`MOUSE_EVENTS`]) a script listener receives when they happen on this
/// control: its own `addEventListener` listeners and its containers' (events bubble).
/// Frontends send only these.
pub mouse: Vec<String>,
/// The active tab of a tabbed panel (child index).
pub active_tab: usize,
/// What the control's `onDraw` handler painted (ScriptUIGraphics), in control coordinates.
@@ -368,26 +364,16 @@ pub struct ScriptWindow {
pub struct ScriptUiEvent {
pub window: u32,
pub widget: u32,
/// `click`, `change`, `changing`, `close`, or a mouse event ([`MOUSE_EVENTS`]).
/// `click`, `change`, `changing` or `close`.
pub kind: String,
/// The new value: text, number, bool, or selected index(es); for `close`, the result; for
/// mouse events (and a mouse `click`), the pointer: `{x, y, button, detail, screenX, screenY,
/// altKey, ctrlKey, metaKey, shiftKey}` with `x`/`y` relative to the control.
/// The new value: text, number, bool, or selected index(es); for `close`, the result.
pub value: Value,
}
/// The ScriptUI mouse events scripts can listen for (`addEventListener`).
pub const MOUSE_EVENTS: [&str; 6] = ["mousedown", "mouseup", "mousemove", "mouseover", "mouseout", "click"];
/// Hands an event to the script that owns the window; returns what the handler reported
/// (`{ok, output, error}`) and updates [`Session::script_ui`].
pub type ScriptUiDispatch = fn(&mut Session, &ScriptUiEvent) -> std::result::Result<Value, String>;
/// Runs the scripts' `app.scheduleTask` tasks that are due; returns
/// `{ran, output, errors: [{message, line, file}], next (ms until the next task is due, or null)}`
/// and updates [`Session::script_ui`]. Frontends call it every frame (see [`ScriptUi::run_tasks`]).
pub type ScriptTaskRunner = fn(&mut Session) -> Value;
/// The open script windows.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(default)]
@@ -398,16 +384,6 @@ pub struct ScriptUi {
/// The scripting engine's event hand-off (set with [`Session::script`]).
#[serde(skip)]
pub dispatch: Option<ScriptUiDispatch>,
/// The scripting engine's scheduled-task runner (set with [`Session::script`]).
#[serde(skip)]
pub tasks: Option<ScriptTaskRunner>,
/// Bumped whenever the project is closed or replaced (New / Open / Close Project): repeating
/// tasks of scripts without an open window stop then.
#[serde(skip)]
pub project_epoch: u64,
/// The script contexts of this session with scheduled tasks (kept by the scripting engine).
#[serde(skip)]
pub task_hosts: Vec<u32>,
}
impl ScriptUi {
@@ -415,17 +391,6 @@ impl ScriptUi {
self.windows.iter().find(|w| w.id == id)
}
/// Run the scripts' due `app.scheduleTask` tasks ([`ScriptTaskRunner`]); `None` without a
/// scripting engine.
pub fn run_tasks(s: &mut Session) -> Option<Value> {
let f = s.script_ui.tasks?;
// A task may close its ScriptUI panel: forget it then.
let open = crate::commands::scripts::open_panel_names(s);
let r = f(s);
crate::commands::scripts::forget_closed_panels(s, &open);
Some(r)
}
/// Replace the windows a script host published (laying them out).
pub fn publish(&mut self, host: u32, mut windows: Vec<ScriptWindow>) {
for w in &mut windows {
@@ -722,48 +687,6 @@ pub(crate) fn set(s: &mut Session, p: &Value) -> Result<Value> {
dispatch(s, ScriptUiEvent { window, widget, kind: kind.into(), value })
}
/// `script.runTasks`: run the scripts' `app.scheduleTask` tasks that are due now (frontends do
/// it every frame; headless hosts and agents call this).
pub(crate) fn run_tasks(s: &mut Session, _: &Value) -> Result<Value> {
ScriptUi::run_tasks(s).ok_or_else(|| EngineError::Other("scripting is not available in this build".into()))
}
/// `scriptui.mouse`: a mouse event on a control, as the pointer would make it: the script's
/// `addEventListener` listeners on the control and its containers receive a MouseEvent
/// (`clientX`/`clientY` relative to the control, `button`, `detail` = click count, modifier
/// keys). A `click` also does what clicking the control does (toggle a checkbox, press a button).
pub(crate) fn mouse(s: &mut Session, p: &Value) -> Result<Value> {
let c = "scriptui.mouse";
let window = window_p(s, p, c)?;
let widget = widget_p(s, window, p, c)?;
let ty = p.get("type").and_then(Value::as_str).unwrap_or("click").to_ascii_lowercase();
let Some(ty) = MOUSE_EVENTS.iter().find(|t| **t == ty) else {
return Err(crate::commands::bad(c, format!("unknown mouse event `{ty}` (one of {})", MOUSE_EVENTS.join(", "))));
};
let w = s.script_ui.window(window).and_then(|w| w.root.find(widget)).cloned().unwrap_or_default();
if !w.enabled {
return Err(crate::commands::bad(c, format!("control {widget} is disabled")));
}
// Hostile input stays finite and bounded.
const FAR: f64 = 1.0e6;
let num = |k: &str, lo: f64, hi: f64, d: f64| p.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()).map_or(d, |v| v.clamp(lo, hi));
let flag = |k: &str| p.get(k).and_then(Value::as_bool).unwrap_or(false);
let clicks = if matches!(*ty, "mousemove" | "mouseover" | "mouseout") { 0.0 } else { 1.0 };
let value = json!({
"x": num("x", -FAR, FAR, w.bounds[2] / 2.0),
"y": num("y", -FAR, FAR, w.bounds[3] / 2.0),
"screenX": num("screenX", -FAR, FAR, 0.0),
"screenY": num("screenY", -FAR, FAR, 0.0),
"button": num("button", 0.0, 2.0, 0.0).round(),
"detail": num("detail", 0.0, 1000.0, clicks).round(),
"altKey": flag("altKey"),
"ctrlKey": flag("ctrlKey"),
"metaKey": flag("metaKey"),
"shiftKey": flag("shiftKey"),
});
dispatch(s, ScriptUiEvent { window, widget, kind: (*ty).to_string(), value })
}
/// `scriptui.close`: close a window (a dialog's `show()` returns `result`, default 2 = Cancel).
pub(crate) fn close(s: &mut Session, p: &Value) -> Result<Value> {
let window = window_p(s, p, "scriptui.close")?;
+2 -4
View File
@@ -88,9 +88,7 @@ pub fn bindables(ui: &[UiCommand]) -> Vec<Bindable> {
let entries = crate::menus::entries();
for (path, e) in &entries {
let key = binding_key(&e.command, &e.params);
// A plain entry also gets its command's further default keys.
let alt = if e.params.is_null() { crate::commands::find(&e.command).map_or(&[][..], |c| c.alt_shortcuts) } else { &[] };
let defaults = e.shortcut.as_deref().into_iter().chain(alt.iter().copied()).filter_map(normalize).collect();
let defaults = e.shortcut.iter().filter_map(|s| normalize(s)).collect();
push(
Bindable {
key,
@@ -108,7 +106,7 @@ pub fn bindables(ui: &[UiCommand]) -> Vec<Bindable> {
if !c.journal || entries.iter().any(|(_, e)| e.command == c.id) {
continue;
}
let defaults = c.shortcut.into_iter().chain(c.alt_shortcuts.iter().copied()).filter_map(normalize).collect();
let defaults = c.shortcut.and_then(normalize).into_iter().collect();
push(
Bindable {
key: c.id.to_string(),
-19
View File
@@ -190,25 +190,6 @@ pub fn cpu_cores() -> usize {
std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1)
}
/// The graphics lines of Settings ▸ GPU Information and Help ▸ System Report: whether the GPU
/// compositor runs (and on which adapter), else why compositing is on the CPU, and the adapter
/// the window itself got. Without this a card the compositor couldn't use looked undetected (#596).
pub fn gpu_summary(s: &crate::Session) -> String {
let mut text = match s.accel.as_ref().map(|a| a.name()) {
Some(name) => format!("Compositing: GPU, {name}.\n"),
None => {
let threads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1);
let why = s.accel_note.as_deref().unwrap_or("no reason was recorded");
format!("Compositing: CPU, {threads} threads (pure Rust, rayon).\nGPU compositing is off: {why}\n")
}
};
match &s.window_adapter {
Some(adapter) => text.push_str(&format!("Window graphics: {adapter}\n")),
None => text.push_str("Window graphics: not known yet (the window has not drawn its first frame).\n"),
}
text
}
#[cfg(test)]
mod tests {
use super::*;
+2 -112
View File
@@ -280,57 +280,6 @@ fn toolbar_fill_and_stroke_paint_selected_shape_layers() {
let _ = s.render(cid, s.time(), Default::default());
}
/// The toolbar's Fill Options / Stroke Options change a selected shape layer's paint kind (#732):
/// None switches the fill off, a gradient turns the Fill into a Gradient Fill that keeps its
/// opacity, Solid Color turns it back, and a Stroke kind adds a stroke where there is none.
#[test]
fn toolbar_fill_and_stroke_kinds_change_selected_shape_layers() {
let mut s = Session::default();
s.execute("comp.new", json!({"width": 400, "height": 300, "duration": 2})).unwrap();
let sh = s.execute("layer.newShape", json!({"kind": "rect", "fill": "#ff0000"})).unwrap()["layer"].as_u64().unwrap();
let items = |s: &Session| -> Vec<(String, bool)> {
let l = s.active_comp().unwrap().layer(effectcraft_project::LayerId(sh)).unwrap();
l.props.group("contents/group/contents").unwrap().groups().map(|g| (g.match_id.clone(), g.enabled)).collect()
};
let kinds = |s: &mut Session| {
let p = s.execute("shape.fillStroke", json!({})).unwrap();
(p["fillType"].as_str().unwrap().to_string(), p["strokeType"].as_str().unwrap().to_string())
};
assert_eq!(kinds(&mut s), ("solid".into(), "none".into()));
let cid = s.active_comp_id().unwrap();
let centre = |s: &Session| s.render(cid, s.time(), Default::default()).get(200, 150);
let red = centre(&s);
// None switches the fill off: nothing is drawn, and the item stays.
s.execute("shape.fillStroke", json!({"fillType": "none"})).unwrap();
assert_eq!(items(&s), [("rect".into(), true), ("fill".into(), false)]);
assert_eq!(kinds(&mut s).0, "none");
assert_ne!(centre(&s), red, "the fill is off");
// A radial gradient: the Fill becomes a Gradient Fill (switched on) that keeps its opacity.
s.execute("shape.fillStroke", json!({"fillType": "solid", "fill": "#ff0000"})).unwrap();
assert_eq!(centre(&s), red);
let opacity = |s: &Session, m: &str| {
let l = s.active_comp().unwrap().layer(effectcraft_project::LayerId(sh)).unwrap();
l.props.group(&format!("contents/group/contents/{m}")).unwrap().get("opacity").unwrap().value.as_f64()
};
s.execute("prop.set", json!({"layer": sh, "path": "contents/group/contents/fill/opacity", "value": 40})).unwrap();
s.execute("shape.fillStroke", json!({"fillType": "radial"})).unwrap();
assert_eq!(items(&s), [("rect".into(), true), ("gfill".into(), true)]);
assert_eq!((kinds(&mut s).0.as_str(), opacity(&s, "gfill")), ("radial", 40.0));
s.execute("shape.fillStroke", json!({"fillType": "linear"})).unwrap();
assert_eq!(kinds(&mut s).0, "linear");
s.execute("shape.fillStroke", json!({"fillType": "solid", "fill": "#00ff00"})).unwrap();
assert_eq!(items(&s), [("rect".into(), true), ("fill".into(), true)]);
assert_eq!(opacity(&s, "fill"), 40.0);
// A Stroke kind adds a stroke (before the fill); None switches it off again; undo is per step.
s.execute("shape.fillStroke", json!({"strokeType": "linear"})).unwrap();
assert_eq!(items(&s), [("rect".into(), true), ("gstroke".into(), true), ("fill".into(), true)]);
s.execute("shape.fillStroke", json!({"strokeType": "none"})).unwrap();
assert_eq!(kinds(&mut s), ("solid".into(), "none".into()));
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(kinds(&mut s), ("solid".into(), "linear".into()));
assert!(s.execute("shape.fillStroke", json!({"fillType": "plaid"})).is_err());
}
/// Selecting a mask in the Timeline (its row or its Mask Path) selects all of its points, so the
/// viewer drags the whole mask (#203); a shape's Path item works the same way.
#[test]
@@ -379,9 +328,8 @@ fn save_and_open_roundtrip() {
#[test]
fn help_links_emit_urls() {
let mut s = Session::default();
let r = s.execute("help.discord", json!({})).unwrap();
assert_eq!(r["url"], "https://discord.gg/artcraft");
assert!(s.drain_events().iter().any(|e| matches!(e, crate::Event::OpenUrl(u) if u.contains("discord"))));
s.execute("help.github", json!({})).unwrap();
assert!(s.drain_events().iter().any(|e| matches!(e, crate::Event::OpenUrl(u) if u.contains("github"))));
assert_eq!(s.execute("help.github", json!({})).unwrap()["url"], "https://github.com/storytold/effectcraft");
}
@@ -628,61 +576,3 @@ fn new_text_rejects_malformed_positions_without_creating_a_layer() {
s.execute("layer.newText", json!({"text": "No explicit position"})).unwrap();
assert_eq!(s.active_comp().unwrap().layers.len(), 2);
}
/// The active comp and the open comp tabs are saved with the project and restored on open (#748).
#[test]
fn reopening_a_project_restores_its_active_comp() {
use effectcraft_project::ItemId;
let dir = std::env::temp_dir().join(format!("ec-test-748-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("view.ecproj").to_string_lossy().to_string();
let mut s = Session::default();
let a = s.execute("comp.new", json!({"name": "CompA"})).unwrap()["comp"].as_u64().unwrap();
let b = s.execute("comp.new", json!({"name": "CompB"})).unwrap()["comp"].as_u64().unwrap();
let c = s.execute("comp.new", json!({"name": "CompC"})).unwrap()["comp"].as_u64().unwrap();
s.execute("comp.open", json!({"comp": "CompB"})).unwrap();
s.state.open_comps.retain(|id| id.0 != c);
s.execute("file.saveAs", json!({"path": path})).unwrap();
let mut s2 = Session::default();
s2.execute("file.open", json!({"path": path})).unwrap();
assert_eq!(s2.state.active_comp, Some(ItemId(b)));
assert_eq!(s2.state.open_comps, vec![ItemId(a), ItemId(b)]);
assert!(!s2.is_dirty());
// A file from before the view was saved opens on its first comp, as it always did.
let text = std::fs::read_to_string(&path).unwrap();
let mut old: serde_json::Value = serde_json::from_str(&text).unwrap();
let obj = old.as_object_mut().unwrap();
obj.remove("activeComp");
obj.remove("openComps");
std::fs::write(&path, serde_json::to_string_pretty(&old).unwrap()).unwrap();
let mut s3 = Session::default();
s3.execute("file.open", json!({"path": path})).unwrap();
assert_eq!((s3.state.active_comp, s3.state.open_comps.clone()), (Some(ItemId(a)), vec![ItemId(a)]));
// Ids that no longer name a comp (edited by hand, or written by another tool) fall back.
obj_set(&mut old, json!(999), json!([999, b, "x"]));
std::fs::write(&path, serde_json::to_string_pretty(&old).unwrap()).unwrap();
let mut s4 = Session::default();
s4.execute("file.open", json!({"path": path})).unwrap();
assert_eq!((s4.state.active_comp, s4.state.open_comps.clone()), (Some(ItemId(b)), vec![ItemId(b)]));
obj_set(&mut old, json!(999), json!([]));
std::fs::write(&path, serde_json::to_string_pretty(&old).unwrap()).unwrap();
let mut s5 = Session::default();
s5.execute("file.open", json!({"path": path})).unwrap();
assert_eq!((s5.state.active_comp, s5.state.open_comps.clone()), (Some(ItemId(a)), vec![ItemId(a)]));
// XML copies carry it too.
let xml = dir.join("view.ecprojx").to_string_lossy().to_string();
s.execute("file.saveAs", json!({"path": xml})).unwrap();
let mut s6 = Session::default();
s6.execute("file.open", json!({"path": xml})).unwrap();
assert_eq!(s6.state.active_comp, Some(ItemId(b)));
let _ = std::fs::remove_dir_all(dir);
fn obj_set(v: &mut serde_json::Value, active: serde_json::Value, open: serde_json::Value) {
let o = v.as_object_mut().unwrap();
o.insert("activeComp".into(), active);
o.insert("openComps".into(), open);
}
}
-22
View File
@@ -243,25 +243,3 @@ fn demo_has_3d_showcase() {
let lit = img.data.iter().filter(|p| p[3] > 0.99 && p[0] + p[1] + p[2] > 0.3).count();
assert!(lit > 2_000, "{lit}");
}
/// #670: the views of the panes of a multi-view layout are chosen per pane, kept per comp, and
/// editor states saved before panes existed load with the default Top / Front / Right.
#[test]
fn layout_panes_choose_their_view() {
use effectcraft_render::three_d::{View3D, Views3D};
let mut s = Session::default();
s.execute("comp.new", json!({"name": "C", "width": 640, "height": 360, "duration": 2})).unwrap();
let cid = s.active_comp_id().unwrap();
assert_eq!(s.state.views3d.get(&cid).cloned().unwrap_or_default().pane(0), View3D::Top);
let r = s.execute("view.setPaneView", json!({"pane": 2, "view": "bottom"})).unwrap();
assert_eq!(r["views"], json!(["activeCamera", "top", "bottom", "right"]));
assert_eq!(s.state.views3d[&cid].current, View3D::ActiveCamera, "the main view is untouched");
s.execute("view.setPaneView", json!({"pane": 0, "view": "custom1"})).unwrap();
assert_eq!(s.state.views3d[&cid].current, View3D::Custom1);
assert!(s.execute("view.setPaneView", json!({"pane": 4, "view": "top"})).is_err());
assert!(s.execute("view.setPaneView", json!({"pane": 1, "view": "nope"})).is_err());
let back: crate::EditorState = serde_json::from_value(serde_json::to_value(&s.state).unwrap()).unwrap();
assert_eq!(back.views3d[&cid].pane(1), View3D::Bottom);
let old: Views3D = serde_json::from_value(json!({"current": "Front"})).unwrap();
assert_eq!((old.current, old.pane(0), old.pane(1), old.pane(2)), (View3D::Front, View3D::Top, View3D::Front, View3D::Right));
}
-74
View File
@@ -1,74 +0,0 @@
//! Built-in animation presets (Effects & Presets ▸ * Animation Presets ▸ Transitions).
use effectcraft_time::Tick;
use serde_json::json;
use crate::Session;
use crate::render::RenderOpts;
/// A 160×90 comp with one white full-frame solid; returns (session, solid).
fn session() -> (Session, u64) {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "T", "width": 160, "height": 90, "frameRate": 30, "duration": 5})).unwrap();
let solid = s.execute("layer.newSolid", json!({"name": "S", "color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
(s, solid)
}
/// Share of the comp's pixels that are mostly opaque at `secs`.
fn coverage(s: &Session, secs: f64) -> f64 {
let cid = s.active_comp_id().unwrap();
let img = s.render(cid, Tick::from_seconds_f64(secs), RenderOpts { scale: 1.0, ..Default::default() });
img.data.iter().filter(|p| p[3] > 0.5).count() as f64 / img.data.len().max(1) as f64
}
#[test]
fn every_transition_preset_applies_and_transitions() {
let (mut s, solid) = session();
let list = s.execute("anim.builtinPresets", json!({"category": "Transitions"})).unwrap();
let list = list.as_array().unwrap().clone();
assert!(list.len() >= 20, "{}", list.len());
assert_eq!(crate::animation_presets().len(), list.len());
let start = 0.5;
for p in &list {
let id = p["id"].as_str().unwrap();
let incoming = match p["direction"].as_str() {
Some("in") => true,
Some("out") => false,
d => panic!("{id}: direction {d:?}"),
};
s.execute("time.set", json!({"time": start})).unwrap();
let r = s.execute("anim.applyBuiltinPreset", json!({"layers": [solid], "preset": id})).unwrap();
assert_eq!(r["skipped"], json!([]), "{id}: every path in the preset exists and takes its values");
let (a, b) = (coverage(&s, start), coverage(&s, start + 2.0));
let (hidden, shown) = if incoming { (a, b) } else { (b, a) };
assert!(hidden < 0.15, "{id}: hidden end covers {hidden:.3}");
assert!(shown > 0.97, "{id}: shown end covers {shown:.3}");
// Keys start at the current time: before it the layer holds the first key's state.
assert!((coverage(&s, 0.0) - a).abs() < 0.02, "{id}");
s.execute("edit.undo", json!({})).unwrap();
let l = &s.active_comp().unwrap().layers[0];
assert_eq!(l.effects().map_or(0, |fx| fx.groups().count()), 0, "{id}: undo removes the effects");
assert!(l.props.prop("transform/opacity").unwrap().keys.is_empty(), "{id}: undo removes the keys");
}
}
#[test]
fn transition_preset_by_name_and_bad_input() {
let (mut s, solid) = session();
// By name; a second application numbers the effect like Effect ▸ apply does.
s.execute("anim.applyBuiltinPreset", json!({"layers": [solid], "preset": "Wipe In from Left"})).unwrap();
let r = s.execute("anim.applyBuiltinPreset", json!({"layers": [solid], "preset": "wipeInFromLeft"})).unwrap();
assert_eq!(r["effects"].as_array().unwrap().len(), 1);
let names: Vec<String> = s.active_comp().unwrap().layers[0].effects().unwrap().groups().map(|g| g.name.clone()).collect();
assert_eq!(names, ["Linear Wipe", "Linear Wipe 2"]);
let fx = s.active_comp().unwrap().layers[0].effects().unwrap().groups().next().unwrap().clone();
let completion = fx.prop("completion").unwrap();
assert_eq!(completion.keys.len(), 2);
assert_eq!(completion.keys[0].time, Tick::ZERO);
// Unknown presets and missing params are errors, not panics.
assert!(s.execute("anim.applyBuiltinPreset", json!({"layers": [solid], "preset": "nope"})).is_err());
assert!(s.execute("anim.applyBuiltinPreset", json!({"layers": [solid]})).is_err());
// A layer without effects (a camera) refuses effect presets.
let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap();
assert!(s.execute("anim.applyBuiltinPreset", json!({"layers": [cam], "preset": "clockReveal"})).is_err());
}
+1 -31
View File
@@ -1,4 +1,4 @@
//! Composition ▸ Save Frame As ▸ Photoshop Layers… / ProEXR…, and Copy Frame to Clipboard.
//! Composition ▸ Save Frame As ▸ Photoshop Layers… / ProEXR….
use serde_json::json;
@@ -121,33 +121,3 @@ fn proexr_values_at_or_below_one_are_unchanged() {
assert!((got[0] - 1.0).abs() < 1e-4 && (got[1] - 0.1329).abs() < 1e-3 && got[2].abs() < 1e-6, "{got:?}");
}
}
/// Composition ▸ Copy Frame to Clipboard (After Effects 26.3): the frame, as Save Frame As
/// renders it, goes to the frontend as an image for the system clipboard.
#[test]
fn copy_frame_hands_the_rendered_frame_to_the_frontend() {
let mut s = frame();
s.drain_events();
let r = s.execute("comp.copyFrame", json!({})).unwrap();
assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(32), Some(32)));
let events = s.drain_events();
let Some((width, height, rgba)) = events.iter().find_map(|e| match e {
crate::Event::CopyImage { width, height, rgba } => Some((*width, *height, rgba)),
_ => None,
}) else {
panic!("no CopyImage event in {events:?}")
};
assert_eq!((width, height, rgba.len()), (32, 32, 32 * 32 * 4));
// Red in the corner; red × (50% blue multiply) in the middle; opaque everywhere.
assert_eq!(&rgba[..4], &[255, 0, 0, 255]);
let mid = &rgba[(16 * 32 + 16) * 4..][..4];
assert!(mid[0] > 100 && mid[0] < 160 && mid[2] < 13 && mid[3] == 255, "{mid:?}");
assert!(rgba.chunks(4).all(|p| p[3] == 255));
// The pixels are not serialized into the event stream.
let json = serde_json::to_value(events.iter().find(|e| matches!(e, crate::Event::CopyImage { .. })).unwrap()).unwrap();
assert!(json["CopyImage"].get("rgba").is_none(), "{json}");
// Scaled, and refused without a composition.
assert_eq!(s.execute("comp.copyFrame", json!({"scale": 0.5})).unwrap()["width"], 16);
assert!(Session::default().execute("comp.copyFrame", json!({})).is_err());
assert!(crate::menus::entries().iter().any(|(path, e)| e.command == "comp.copyFrame" && path.first().map(String::as_str) == Some("Composition")));
}
-342
View File
@@ -1,342 +0,0 @@
//! A project-file fuzzer (gaps G3, #779): File ▸ Open must turn any bytes into a project or an
//! error, never a panic, an abort or a hang (AGENTS.md "Never crash").
//!
//! Dependency-free, deterministic and seeded: seed projects (empty, and the demo cut down to one
//! layer at a time, each as `.ecproj` JSON and as `.ecprojx` XML) are mutated byte-wise (flips,
//! deletions, splices, truncation, interesting tokens, deep nesting) and structurally (a JSON
//! node replaced by an extreme number, another type or another subtree; members dropped,
//! elements duplicated), then opened through the real `file.open` path, followed by the queries
//! the app and agents run on a fresh project and a thumbnail-size render of the first frame.
//! Each input runs on its own thread under `catch_unwind` with a deadline, so a panic or a hang
//! is reported (with the iteration to reproduce it) instead of taking the test run down; a stack
//! overflow can't be caught and aborts the run, which fails it all the same.
//!
//! The default run is bounded (iterations and seconds) to stay fast in `cargo test`; a longer run:
//! `EFFECTCRAFT_FUZZ_ITERS=200000 EFFECTCRAFT_FUZZ_SECS=600 cargo test -p effectcraft-engine fuzz_`
//! (`EFFECTCRAFT_FUZZ_SEED` picks another sequence, `EFFECTCRAFT_FUZZ_START` the first iteration).
//! The `mutate` + `open_input` pair is what a cargo-fuzz target would call.
use std::sync::Arc;
use std::time::{Duration, Instant};
use serde_json::{Value, json};
use crate::{Services, Session};
const PATH: &str = "/fuzz/input.ecproj";
/// splitmix64: tiny, good enough for picking mutations.
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
/// `0..n` (`0` when `n` is 0).
fn below(&mut self, n: usize) -> usize {
if n == 0 { 0 } else { (self.next() % n as u64) as usize }
}
fn chance(&mut self, one_in: usize) -> bool {
self.below(one_in) == 0
}
}
/// One file, served from memory; everything else is missing.
struct OneFile(Vec<u8>);
impl Services for OneFile {
fn read_file(&self, path: &str) -> std::io::Result<Vec<u8>> {
if path == PATH { Ok(self.0.clone()) } else { Err(std::io::Error::new(std::io::ErrorKind::NotFound, path.to_string())) }
}
fn write_file(&self, _: &str, _: &[u8]) -> std::io::Result<()> {
Err(std::io::Error::other("the fuzzer writes nothing"))
}
fn exists(&self, path: &str) -> bool {
path == PATH
}
fn stamp(&self, _: &str) -> Option<(u64, u128)> {
None
}
fn list_dir(&self, _: &str) -> Vec<String> {
vec![]
}
fn is_dir(&self, _: &str) -> bool {
false
}
}
/// Open `bytes` as a project file, then run what the app and agents read first: `Ok(true)` when
/// it opened, `Ok(false)` when it was refused. `Err` only for a crash: a panic in the open
/// itself, or one the command guard caught in a query.
fn open_input(bytes: Vec<u8>) -> Result<bool, String> {
let mut s = Session { services: Arc::new(OneFile(bytes)), ..Default::default() };
if crate::commands::open_for_tests(&mut s, &json!({ "path": PATH })).is_err() {
return Ok(false); // refused: fine
}
let crashed = |r: crate::Result<Value>| match r {
Err(e) if e.to_string().contains("internal error") => Err(e.to_string()),
_ => Ok(()),
};
crashed(s.execute("project.summary", json!({})))?;
let comps: Vec<_> = s.project.comps().map(|(id, c)| (id.0, c.layers.iter().map(|l| l.id.0).collect::<Vec<_>>())).collect();
for (comp, layers) in comps.into_iter().take(4) {
crashed(s.execute("comp.info", json!({ "comp": comp })))?;
for layer in layers.into_iter().take(8) {
crashed(s.execute("layer.tree", json!({ "comp": comp, "layer": layer, "depth": 3, "time": 1.0 })))?;
}
}
// The first frame the viewer shows, at thumbnail size.
if let Some(id) = s.state.active_comp
&& let Some(c) = s.project.comp(id)
{
let scale = (32.0 / f64::from(c.width.max(c.height).max(1))).min(1.0);
effectcraft_render::render_frame(&s.project, id, effectcraft_time::Tick::from_seconds_f64(1.0), scale);
}
Ok(true)
}
/// Run one input on its own thread: a panic becomes its message, a hang a timeout.
fn run_guarded(bytes: Vec<u8>, deadline: Duration) -> Result<bool, String> {
let (tx, rx) = std::sync::mpsc::channel();
let spawned = std::thread::Builder::new().name("fuzz-open".into()).stack_size(8 << 20).spawn(move || {
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| open_input(bytes)));
let r = match r {
Ok(r) => r,
Err(p) => {
Err(format!("panic: {}", p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default()))
}
};
let _ = tx.send(r);
});
if let Err(e) = spawned {
return Err(format!("couldn't start the fuzz thread: {e}"));
}
rx.recv_timeout(deadline).unwrap_or_else(|_| Err(format!("hang: no result within {deadline:?}")))
}
/// Small projects covering what a project holds: the empty project, and the demo cut down to
/// one layer of one comp at a time (shape, text, solid, precomp, camera, light, null, effects,
/// masks, expressions; other comps keep their first layer), each as JSON and as XML. The whole
/// demo (700 kB) would make every input slow.
fn seeds() -> Vec<Vec<u8>> {
let demo = crate::demo::demo_project();
let mut projects = vec![effectcraft_project::Project::default()];
for (cid, c) in demo.comps() {
for i in 0..c.layers.len() {
let mut p = demo.clone();
for (id, item) in p.items.iter_mut() {
if let effectcraft_project::ItemKind::Comp(c) = &mut item.kind {
let keep = if id == cid { i } else { 0 };
let layers = &mut Arc::make_mut(c).layers;
*layers = layers.get(keep).cloned().into_iter().collect();
}
}
projects.push(p);
}
}
let mut out = vec![];
for p in projects {
let json = p.to_file_json().unwrap();
out.push(crate::xml_project::to_xml(&json).unwrap().into_bytes());
out.push(json.into_bytes());
}
out
}
#[rustfmt::skip]
const TOKENS: &[&str] = &[
"{", "}", "[", "]", "\"", ",", ":", "null", "true", "-1", "0", "1e308", "-1e308", "1e-320", "18446744073709551615",
"-9223372036854775808", "4294967295", "4294967296", "\\u0000", "\\ud800", "<", ">", "</", "/>", "<arr>", "</arr>", "<obj>",
"<m n=\"", "</m>", "<num>", "</num>", "<str>", "</str>", "<null/>", "<!--", "-->", "<?", "?>", "&", ";", "&#x110000;",
"&#xD800;", "&#;", "&#x;", "&amp", "\u{feff}", "é", "\u{1F600}",
];
const NUMBERS: &[f64] = &[0.0, -0.0, 1.0, -1.0, 0.5, -0.5, 2.0, 1e-300, 1e15, 1e300, -1e300, 4294967295.0, 4294967296.0, 65536.0, 254016000000.0];
const INTEGERS: &[i64] = &[0, -1, 1, 2, 3, 255, 256, 65535, 2147483647, -2147483648, i64::MAX, i64::MIN, 9_007_199_254_740_993];
/// Every node of `v`, depth first (the root is 0).
fn count(v: &Value) -> usize {
1 + match v {
Value::Array(a) => a.iter().map(count).sum(),
Value::Object(m) => m.values().map(count).sum(),
_ => 0,
}
}
/// The `k`-th node (depth first; the root is 0).
fn nth<'a>(v: &'a mut Value, k: &mut usize) -> Option<&'a mut Value> {
if *k == 0 {
return Some(v);
}
*k -= 1;
match v {
Value::Array(a) => a.iter_mut().find_map(|x| nth(x, k)),
Value::Object(m) => m.values_mut().find_map(|x| nth(x, k)),
_ => None,
}
}
fn node_mut(v: &mut Value, k: usize) -> Option<&mut Value> {
nth(v, &mut { k })
}
fn interesting(r: &mut Rng, around: &Value) -> Value {
match r.below(7) {
0 => json!(NUMBERS[r.below(NUMBERS.len())]),
1 => json!(INTEGERS[r.below(INTEGERS.len())]),
2 => match around.as_f64() {
Some(x) => json!(x * [-1.0, 2.0, 1e6, 1e-6, 0.0][r.below(5)]),
None => Value::Null,
},
3 => json!(["", "#999", "@0", "effects/#1", "../../x", "\u{0}", "transform/position", &"a".repeat(r.below(300))][r.below(8)]),
4 => Value::Bool(r.chance(2)),
5 => json!([]),
_ => Value::Null,
}
}
/// A structural mutation of a project's JSON (when the input still parses).
fn mutate_tree(r: &mut Rng, v: &mut Value) {
let n = count(v);
for _ in 0..1 + r.below(4) {
let k = r.below(n.max(1));
let donor = node_mut(v, r.below(n.max(1))).map(|d| d.clone()).unwrap_or(Value::Null);
let Some(node) = node_mut(v, k) else { return };
match r.below(6) {
0 | 1 => *node = interesting(r, node),
2 => *node = donor,
3 => match node {
Value::Object(m) if !m.is_empty() => {
let key = m.keys().nth(r.below(m.len())).cloned().unwrap_or_default();
m.remove(&key);
}
Value::Array(a) if !a.is_empty() => {
a.remove(r.below(a.len()));
}
_ => *node = Value::Null,
},
4 => {
if let Value::Array(a) = node
&& !a.is_empty()
{
let i = r.below(a.len());
let x = a[i].clone();
a.insert(r.below(a.len() + 1), x);
}
}
_ => {
if let Value::Object(m) = node {
m.insert(["id", "parent", "source", "schema", "nextId", "layers", "items", "kind"][r.below(8)].into(), interesting(r, &Value::Null));
}
}
}
}
}
fn mutate_bytes(r: &mut Rng, b: &mut Vec<u8>) {
for _ in 0..1 + r.below(3) {
let len = b.len();
match r.below(8) {
0 if len > 0 => {
let i = r.below(len);
b[i] ^= 1 << r.below(8);
}
1 if len > 0 => {
let i = r.below(len);
b[i] = r.next() as u8;
}
2 if len > 0 => {
let a = r.below(len);
let e = (a + 1 + r.below(64)).min(len);
b.drain(a..e);
}
3 if len > 0 => {
let a = r.below(len);
let e = (a + 1 + r.below(256)).min(len);
let chunk = b[a..e].to_vec();
let at = r.below(len + 1);
b.splice(at..at, chunk);
}
4 => {
let t = TOKENS[r.below(TOKENS.len())].as_bytes();
let at = r.below(len + 1);
b.splice(at..at, t.iter().copied());
}
5 if len > 0 => b.truncate(r.below(len)),
6 => {
// Deep nesting: what a recursive reader overflows its stack on.
let (open, close) = [("[", "]"), ("<arr>", "</arr>"), ("<obj><m n=\"a\">", "</m></obj>")][r.below(3)];
let depth = [200, 5_000, 200_000][r.below(3)];
// Where the reader expects a value: after an opening token of the same kind.
let opens: Vec<usize> = b.windows(open.len().min(5)).enumerate().filter(|(_, w)| *w == &open.as_bytes()[..w.len()]).map(|(i, _)| i).collect();
let at = if opens.is_empty() { r.below(len + 1) } else { opens[r.below(opens.len())] };
let mut nest = open.repeat(depth).into_bytes();
nest.extend(close.repeat(if r.chance(2) { depth } else { 0 }).into_bytes());
b.splice(at..at, nest);
}
_ => {}
}
}
}
/// One fuzz input from the seeds, decided by the iteration's own generator.
fn mutate(r: &mut Rng, seeds: &[Vec<u8>]) -> Vec<u8> {
let mut b = seeds[r.below(seeds.len())].clone();
if r.chance(2) {
let text = String::from_utf8_lossy(&b).to_string();
let json = if crate::xml_project::is_xml(&text) { crate::xml_project::from_xml(&text).ok() } else { Some(text) };
if let Some(mut v) = json.and_then(|j| serde_json::from_str::<Value>(&j).ok()) {
mutate_tree(r, &mut v);
let j = v.to_string();
b = if r.chance(3) { crate::xml_project::to_xml(&j).unwrap_or(j).into_bytes() } else { j.into_bytes() };
}
}
if r.chance(3) || b.is_empty() {
mutate_bytes(r, &mut b);
}
b
}
fn env_u64(name: &str, default: u64) -> u64 {
std::env::var(name).ok().and_then(|v| v.trim().parse().ok()).unwrap_or(default)
}
#[test]
fn fuzz_file_open_never_crashes() {
let seed = env_u64("EFFECTCRAFT_FUZZ_SEED", 0x00EC_F022);
let start = env_u64("EFFECTCRAFT_FUZZ_START", 0);
let iters = env_u64("EFFECTCRAFT_FUZZ_ITERS", 3_000);
let budget = Duration::from_secs(env_u64("EFFECTCRAFT_FUZZ_SECS", 6));
let seeds = seeds();
// The unmutated seeds open.
for s in &seeds {
assert_eq!(run_guarded(s.clone(), Duration::from_secs(30)), Ok(true));
}
let began = Instant::now();
let mut failures = vec![];
let (mut ran, mut opened) = (0, 0);
for i in start..start + iters {
if began.elapsed() > budget {
break;
}
let mut r = Rng(seed ^ i.wrapping_mul(0xD6E8_FEB8_6659_FD93));
let input = mutate(&mut r, &seeds);
ran += 1;
match run_guarded(input.clone(), Duration::from_secs(10)) {
Ok(o) => opened += usize::from(o),
Err(e) => {
let head = String::from_utf8_lossy(&input[..input.len().min(200)]).to_string();
failures.push(format!("iteration {i} (seed {seed:#x}, {} bytes): {e}\n input starts: {head:?}", input.len()));
if failures.len() >= 5 {
break;
}
}
}
}
assert!(failures.is_empty(), "File ▸ Open crashed on {} of {ran} fuzz inputs:\n{}", failures.len(), failures.join("\n"));
eprintln!("fuzz_file_open: {ran} inputs ({opened} opened) in {:?}, no crashes", began.elapsed());
}
-104
View File
@@ -1,104 +0,0 @@
//! Sizes, frame rates and motion-blur samples stay within the project limits when a project is
//! opened and when a command sets them (#788).
use effectcraft_project::{ItemKind, limits};
use effectcraft_render::RenderOpts;
use effectcraft_time::{FrameRate, Tick};
use serde_json::{Value, json};
use crate::{Event, Session};
fn tmp(name: &str) -> String {
let d = std::env::temp_dir().join(format!("effectcraft-limits-tests-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
d.join(name).to_string_lossy().to_string()
}
/// The saved project with `edit` applied to the JSON of every item of kind `kind`.
fn tamper(text: &str, kind: &str, edit: impl Fn(&mut Value)) -> String {
let mut v: Value = serde_json::from_str(text).unwrap();
let items = v["items"].as_object_mut().unwrap();
for it in items.values_mut().filter(|it| it["kind"]["type"] == kind) {
edit(&mut it["kind"]);
}
serde_json::to_string(&v).unwrap()
}
/// A project whose comp is 4 billion × 0 px at 10^18 fps, -5 s long with 4 billion motion-blur
/// samples, holding a solid of 4 billion px.
fn hostile_project() -> String {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 32, "frameRate": 30, "duration": 2})).unwrap();
s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 64, "height": 2})).unwrap();
let text = s.project.to_file_json().unwrap();
let text = tamper(&text, "Comp", |c| {
c["width"] = json!(4_000_000_000u64);
c["height"] = json!(0);
c["frame_rate"] = json!({"num": 1_000_000_000_000_000_000i64, "den": 1});
c["duration"] = json!(-5);
c["motion_blur_samples"] = json!(4_000_000_000u64);
});
tamper(&text, "Solid", |so| so["width"] = json!(4_000_000_000u64))
}
#[test]
fn a_hostile_project_opens_within_the_limits_and_says_what_changed() {
let json_path = tmp("Hostile.ecproj");
let text = hostile_project();
std::fs::write(&json_path, &text).unwrap();
let xml_path = tmp("Hostile.ecprojx");
std::fs::write(&xml_path, crate::xml_project::to_xml(&text).unwrap()).unwrap();
for path in [json_path, xml_path] {
let mut s = Session::default();
let r = s.execute("file.open", json!({"path": path})).unwrap();
let repaired = r["repaired"].as_array().unwrap();
assert!(repaired.iter().any(|n| n.as_str().unwrap().contains("4000000000 × 0 px → 30000 × 1 px")), "{path}: {repaired:?}");
let toast = s.drain_events().into_iter().any(|e| matches!(e, Event::Toast { message, .. } if message.contains("outside EffectCraft's limits")));
assert!(toast, "{path}");
let (cid, c) = s.project.comps().next().map(|(id, c)| (*id, c.clone())).unwrap();
assert_eq!((c.width, c.height), (limits::MAX_SIZE, 1));
assert_eq!(c.frame_rate.as_f64(), limits::MAX_FPS);
assert_eq!(c.duration, c.frame_rate.frame_duration(), "at least one frame");
assert_eq!(c.work_area, (Tick::ZERO, c.duration));
assert_eq!(c.motion_blur_samples, limits::MOTION_BLUR_SAMPLES.1);
let solid = s.project.items.values().find_map(|i| if let ItemKind::Solid(so) = &i.kind { Some(so.clone()) } else { None }).unwrap();
assert_eq!((solid.width, solid.height), (limits::MAX_SIZE, 2));
// The comp renders at Full resolution, at its (capped) size.
let img = s.render(cid, Tick::ZERO, RenderOpts { scale: 1.0, ..Default::default() });
assert_eq!((img.width, img.height), (limits::MAX_SIZE, 1));
}
}
#[test]
fn a_project_within_the_limits_opens_unchanged() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 32, "frameRate": 24, "duration": 2})).unwrap();
let text = s.project.to_file_json().unwrap();
let (p, notes) = effectcraft_project::Project::from_json_repaired(&text).unwrap();
assert!(notes.is_empty(), "{notes:?}");
assert_eq!(p, *s.project);
}
#[test]
fn commands_cap_sizes_at_30000_px() {
let mut s = Session::default();
// 2^32 + 10 used to wrap to 10 px.
s.execute("comp.new", json!({"name": "Main", "width": 4_294_967_306u64, "height": 32, "pixelAspect": 1e9})).unwrap();
let c = s.active_comp().unwrap().clone();
assert_eq!((c.width, c.height, c.pixel_aspect), (limits::MAX_SIZE, 32, limits::MAX_PIXEL_ASPECT));
s.execute("comp.settings", json!({"width": 64, "height": 4_294_967_306u64})).unwrap();
assert_eq!((s.active_comp().unwrap().width, s.active_comp().unwrap().height), (64, limits::MAX_SIZE));
// New Solid: 0 and 4 billion px are brought to 1 and 30,000; the layer matches its solid.
s.execute("layer.newSolid", json!({"width": 0, "height": 4_000_000_000u64})).unwrap();
let solid = |s: &Session| s.project.items.values().find_map(|i| if let ItemKind::Solid(so) = &i.kind { Some(so.clone()) } else { None }).unwrap();
assert_eq!((solid(&s).width, solid(&s).height), (1, limits::MAX_SIZE));
// Layer Settings on the new (selected) solid layer.
s.execute("layer.settings", json!({"width": 4_000_000_000u64, "height": 8})).unwrap();
assert_eq!((solid(&s).width, solid(&s).height), (limits::MAX_SIZE, 8));
s.execute("file.importSolid", json!({"width": 99_999_999, "height": 4})).unwrap();
assert!(s.project.items.values().any(|i| matches!(&i.kind, ItemKind::Solid(so) if (so.width, so.height) == (limits::MAX_SIZE, 4))));
// A placeholder can't ask for a huge frame or a zero frame rate either.
s.execute("file.importPlaceholder", json!({"width": 4_000_000_000u64, "height": 8, "frameRate": 0})).unwrap();
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));
}
+1 -1
View File
@@ -74,7 +74,7 @@ fn help_tutorials() {
s.execute("help.onlineTutorials", json!({})).unwrap();
s.execute("help.inAppTutorials", json!({})).unwrap();
let ev = s.drain_events();
assert!(ev.iter().any(|e| matches!(e, Event::OpenUrl(u) if u == crate::links::APP_PAGE)));
assert!(ev.iter().any(|e| matches!(e, Event::OpenUrl(u) if u == crate::links::GITHUB)));
assert!(ev.iter().any(|e| matches!(e, Event::Frontend { command, .. } if command == "help.inAppTutorials")));
}

Some files were not shown because too many files have changed in this diff Show More