Compare commits
286 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 593d923f09 | |||
| 660f26e2fd | |||
| fe23624202 | |||
| 8b3d7e8516 | |||
| 115cf2b972 | |||
| 91efb894f7 | |||
| 470ea7a3af | |||
| e2f314f753 | |||
| 9efe75330c | |||
| bfea3d1253 | |||
| af671f6997 | |||
| 84c6281da9 | |||
| cb59a959f4 | |||
| 61aaf83612 | |||
| bbc3a231c7 | |||
| d1e6aa2e85 | |||
| 6baff9f5fe | |||
| ac37493026 | |||
| 5f386cc97c | |||
| 37c4e98502 | |||
| b6919500ee | |||
| f28901c6e3 | |||
| 50a89c48e9 | |||
| 2ea7220e49 | |||
| 617412b555 | |||
| 6dae51622f | |||
| 6b69881826 | |||
| b2d05582e0 | |||
| bb7f9b421f | |||
| fe439f6e9c | |||
| 8dc91891e9 | |||
| 6bbdd8cdcc | |||
| 7864ea41ae | |||
| 8e6c3e0e68 | |||
| bfb282d47c | |||
| af374a924d | |||
| 777b59a391 | |||
| fbcb5b44b0 | |||
| 0d6adb07bf | |||
| 9fa0036144 | |||
| fa8a1376f9 | |||
| 302da28fcc | |||
| ce3e8f1a99 | |||
| 6ace9f7b0e | |||
| cc45d65204 | |||
| e2db76e507 | |||
| 3e34dcb0d6 | |||
| 54d68a90a6 | |||
| e0b770a9ea | |||
| a1c90b80f2 | |||
| 02c134fb41 | |||
| 2a7170de87 | |||
| b93e7b79e4 | |||
| 0ad9b5fc9b | |||
| 7a03b6fcd1 | |||
| 5dd47132a1 | |||
| ab12890a9e | |||
| ccd7a7c583 | |||
| 559bd55a9b | |||
| debbb1d913 | |||
| efa023f693 | |||
| 34ad8c7e9c | |||
| 79b73b307c | |||
| cafd18eef4 | |||
| fae82306b0 | |||
| 3f8af1a3b6 | |||
| ac1aca7225 | |||
| 2e2c8539e4 | |||
| 0b2d85b811 | |||
| 6d4e721d2c | |||
| 42ef79a2fd | |||
| f41fdf0680 | |||
| 709877ffc6 | |||
| 4dd74c4f6b | |||
| 6caa67971b | |||
| 2599237ef5 | |||
| b7e85034ab | |||
| 255d888236 | |||
| 1c7935b8c2 | |||
| 61f5b937dc | |||
| d6bd27db8f | |||
| 5237ebf596 | |||
| 80febbd418 | |||
| 6304956628 | |||
| a1516b11ad | |||
| 90da5d6724 | |||
| c11740f461 | |||
| 30f7ce8ae5 | |||
| d100e8af32 | |||
| 03a27f6eab | |||
| d1e88ee52b | |||
| ff0ee5a727 | |||
| e8e896e1d6 | |||
| 2cf28e5e24 | |||
| 4e8e6e10b1 | |||
| 30523a81a5 | |||
| 58b09391bd | |||
| 225bbad013 | |||
| 909572dffd | |||
| 565fd4118d | |||
| 5be3b8f9b2 | |||
| 1210ba91f4 | |||
| 24f98a0242 | |||
| 9ebabccbec | |||
| be1d9928e0 | |||
| dfd6e9300f | |||
| b2ce1240c6 | |||
| 78b32b61d4 | |||
| 3d02e65f2b | |||
| 05f0d9ec62 | |||
| 261a5b125b | |||
| d57459a7d0 | |||
| 50ab738f62 | |||
| 5a836f05fc | |||
| 2a8ecfb822 | |||
| 7f9aa28c03 | |||
| d47d3c7b34 | |||
| d9dddb90b7 | |||
| 15387619ee | |||
| 3e9aa820d1 | |||
| bcb9ee1fa5 | |||
| fef320e9dd | |||
| c72e47b7dc | |||
| 4c9a9a9cfa | |||
| 41e8bf48d9 | |||
| b14096c335 | |||
| cbaaf654d6 | |||
| 25dcdcd2d8 | |||
| 8176c86043 | |||
| 27e095e60e | |||
| e98788690c | |||
| e5ff011296 | |||
| 4aa2c6ba8b | |||
| 64b7088867 | |||
| a4a117c1f7 | |||
| adaa8d65bb | |||
| 15d50597dd | |||
| 0ae711ba3a | |||
| 686280cc5f | |||
| 196dd80104 | |||
| d7fe6d2e56 | |||
| 4482ad9634 | |||
| 162a2e2e01 | |||
| b84b5f47ed | |||
| 62dd3ae2ed | |||
| edfa8ee51c | |||
| dff86891d0 | |||
| 25be439a0b | |||
| 69d2b53892 | |||
| e0a23e8763 | |||
| 5df37babc3 | |||
| c644c5f7e9 | |||
| d9f80ea76f | |||
| c72a556b89 | |||
| 011e242b74 | |||
| cf61eba87a | |||
| 94f74075f9 | |||
| 09b3042e20 | |||
| 91b078777e | |||
| b693a6286b | |||
| 650ea35c18 | |||
| 01c03fa8da | |||
| 14b6b9c242 | |||
| 1888f0aaf3 | |||
| e108f460c4 | |||
| 59bd63c282 | |||
| c8968cee1f | |||
| 8927ae5781 | |||
| 050bc4723e | |||
| 189dec0644 | |||
| ffc481a4e4 | |||
| 50eddc1207 | |||
| 4c5d775761 | |||
| 8e1d742a9d | |||
| 16d9d7d30b | |||
| a78961a346 | |||
| 373f27d38d | |||
| d7a03ef3a7 | |||
| f6d42e2d67 | |||
| a47995aabb | |||
| 3d7f928465 | |||
| 183d052c6a | |||
| 64fabbb2fc | |||
| fa7ffb6d09 | |||
| 55f4d163f8 | |||
| 1b7ab07822 | |||
| 51501bbd21 | |||
| 8ac8612ace | |||
| 74b95b0a9d | |||
| 1b7f08198f | |||
| 73404a2ad2 | |||
| 5d4b644865 | |||
| f19f3a8a39 | |||
| b58197ff82 | |||
| 826d82007d | |||
| f4c5015b35 | |||
| 4a3889270b | |||
| 1226576a92 | |||
| ba2770a258 | |||
| 01400f0a89 | |||
| b0c0c61aa0 | |||
| 41c92d3856 | |||
| f45f57da7d | |||
| c39f29882c | |||
| c81a9f1061 | |||
| afaa4bdeeb | |||
| 0467e1101b | |||
| 40e9e7786b | |||
| d347afb2cb | |||
| 8b25dc713f | |||
| 3cdc7af9ff | |||
| 74b842cc45 | |||
| 47972e95a8 | |||
| 88bf89ea5d | |||
| be2c73ece9 | |||
| 6d757e0061 | |||
| a7249ad1a4 | |||
| 64ffb404e3 | |||
| d88bfe9f7c | |||
| 04e087ae58 | |||
| e1bcaab0a0 | |||
| 3e0519de5f | |||
| be6134e8ad | |||
| afdb958199 | |||
| 9da66d9063 | |||
| c03b0a7aa7 | |||
| 3df7e8f77c | |||
| 683ec372d9 | |||
| bee798c190 | |||
| db11dffa18 | |||
| d648026ffd | |||
| 271000b294 | |||
| 765ba157be | |||
| e7ada5bd16 | |||
| b747ffc986 | |||
| a0decc80dd | |||
| 884cb9987b | |||
| 7085816c98 | |||
| 132bd21078 | |||
| 8763fc0138 | |||
| 575642a5ed | |||
| 1c06a793a5 | |||
| ea0414e280 | |||
| 88278ea871 | |||
| e29f34029c | |||
| f0bd916543 | |||
| 5ba0c60a15 | |||
| 557c0bdbba | |||
| 81bec748ec | |||
| bfe384e400 | |||
| 7afe723260 | |||
| fb5a5e9ac0 | |||
| cf8b9aebc9 | |||
| 1fdf817cc9 | |||
| 7aee1d6275 | |||
| 0ae960102c | |||
| 977532f135 | |||
| c733277263 | |||
| d58253cc5a | |||
| 238f62606b | |||
| ddcca91d50 | |||
| 5661d5111c | |||
| f9db3afd34 | |||
| 3b7f1ace28 | |||
| 00383eed99 | |||
| b47f02eaba | |||
| a482981c57 | |||
| c88d2b3a7f | |||
| f179b2f2d2 | |||
| 4c81cf04f2 | |||
| f29436580e | |||
| 31370e6144 | |||
| 42d8120147 | |||
| 8a9b46ba71 | |||
| 5c1484b6e7 | |||
| a81c98d33c | |||
| fe5c2b29b0 | |||
| a6945de659 | |||
| 9ce7d12721 | |||
| ec1bc8cc99 | |||
| fa733bfb04 | |||
| 983a2f0ebf | |||
| 67305fbcd1 | |||
| 66df1696ac | |||
| 55ba002acf | |||
| 726d7e47a1 |
@@ -0,0 +1,28 @@
|
||||
# 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,
|
||||
dropline ^crates/ui-egui/src/i18n(?:\.rs|/ui\.rs)$ :: \s*\(".*(?:ArtCraft|Discord)
|
||||
sub (?m)^ \(\n "[^"\n]*ArtCraft[^"\n]*",\n "[^"\n]*",\n \),\n =>
|
||||
sub \(#612, reported on Discord\) => (#612, reported by a user)
|
||||
sub Reported on Discord: => Reported by a user:
|
||||
@@ -2,15 +2,19 @@
|
||||
# (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: [main]
|
||||
branches: [release]
|
||||
pull_request:
|
||||
paths:
|
||||
- Cargo.toml
|
||||
- Cargo.lock
|
||||
- "**/Cargo.toml"
|
||||
- .github/workflows/freebsd.yml
|
||||
- packaging/freebsd/**
|
||||
- packaging/env.sh
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
|
||||
@@ -34,3 +34,9 @@ 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 }}
|
||||
|
||||
@@ -4,19 +4,17 @@
|
||||
# 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: [main]
|
||||
branches: [release]
|
||||
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:
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule "tools/craft-skills"]
|
||||
path = tools/craft-skills
|
||||
url = https://git.marcospaulo.dev.br/storytold/craft-skills.git
|
||||
@@ -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
|
||||
|
||||
|
||||
+18
-34
@@ -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,13 +58,15 @@ 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`.
|
||||
- UI icons drawn in code: `crates/ui-egui/src/icons.rs`, including the original filled favorite star (2026-10-11, EffectCraft contributors).
|
||||
- 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`
|
||||
@@ -101,22 +103,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).
|
||||
|
||||
@@ -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
@@ -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
+42
-37
@@ -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.8.0"
|
||||
version = "0.9.3"
|
||||
dependencies = [
|
||||
"cpal",
|
||||
"effectcraft-engine",
|
||||
@@ -1159,6 +1159,7 @@ dependencies = [
|
||||
"objc2-foundation 0.3.2",
|
||||
"pollster 0.4.0",
|
||||
"rfd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"winit",
|
||||
"winresource",
|
||||
@@ -1167,7 +1168,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-automation"
|
||||
version = "0.8.0"
|
||||
version = "0.9.3"
|
||||
dependencies = [
|
||||
"effectcraft-engine",
|
||||
"effectcraft-host",
|
||||
@@ -1179,14 +1180,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-av1enc"
|
||||
version = "0.8.0"
|
||||
version = "0.9.3"
|
||||
dependencies = [
|
||||
"filmcraft-av1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-cli"
|
||||
version = "0.8.0"
|
||||
version = "0.9.3"
|
||||
dependencies = [
|
||||
"cpu-time",
|
||||
"effectcraft-automation",
|
||||
@@ -1202,7 +1203,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-color"
|
||||
version = "0.8.0"
|
||||
version = "0.9.3"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -1210,7 +1211,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-effects"
|
||||
version = "0.8.0"
|
||||
version = "0.9.3"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-geom",
|
||||
@@ -1227,7 +1228,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-engine"
|
||||
version = "0.8.0"
|
||||
version = "0.9.3"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-effects",
|
||||
@@ -1260,7 +1261,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-export"
|
||||
version = "0.8.0"
|
||||
version = "0.9.3"
|
||||
dependencies = [
|
||||
"effectcraft-av1enc",
|
||||
"effectcraft-color",
|
||||
@@ -1290,7 +1291,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-expr"
|
||||
version = "0.8.0"
|
||||
version = "0.9.3"
|
||||
dependencies = [
|
||||
"boa_engine",
|
||||
"effectcraft-color",
|
||||
@@ -1307,7 +1308,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-geom"
|
||||
version = "0.8.0"
|
||||
version = "0.9.3"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -1315,7 +1316,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-gpu"
|
||||
version = "0.8.0"
|
||||
version = "0.9.3"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"effectcraft-color",
|
||||
@@ -1337,14 +1338,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-hevcenc"
|
||||
version = "0.8.0"
|
||||
version = "0.9.3"
|
||||
dependencies = [
|
||||
"filmcraft-hevc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-host"
|
||||
version = "0.8.0"
|
||||
version = "0.9.3"
|
||||
dependencies = [
|
||||
"effectcraft-engine",
|
||||
"effectcraft-export",
|
||||
@@ -1368,7 +1369,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-interchange"
|
||||
version = "0.8.0"
|
||||
version = "0.9.3"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-keyframe",
|
||||
@@ -1387,7 +1388,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-keyframe"
|
||||
version = "0.8.0"
|
||||
version = "0.9.3"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-geom",
|
||||
@@ -1399,7 +1400,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-lottie"
|
||||
version = "0.8.0"
|
||||
version = "0.9.3"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-effects",
|
||||
@@ -1416,7 +1417,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-media"
|
||||
version = "0.8.0"
|
||||
version = "0.9.3"
|
||||
dependencies = [
|
||||
"effectcraft-model",
|
||||
"effectcraft-pdf",
|
||||
@@ -1430,6 +1431,7 @@ dependencies = [
|
||||
"filmcraft-codecs",
|
||||
"filmcraft-color",
|
||||
"filmcraft-frame",
|
||||
"filmcraft-isobmff",
|
||||
"filmcraft-media",
|
||||
"filmcraft-time",
|
||||
"gif",
|
||||
@@ -1442,10 +1444,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-model"
|
||||
version = "0.8.0"
|
||||
version = "0.9.3"
|
||||
dependencies = [
|
||||
"effectcraft-geom",
|
||||
"image",
|
||||
"miniz_oxide 0.8.9",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
@@ -1453,14 +1456,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-opusenc"
|
||||
version = "0.8.0"
|
||||
version = "0.9.3"
|
||||
dependencies = [
|
||||
"filmcraft-opus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-path"
|
||||
version = "0.8.0"
|
||||
version = "0.9.3"
|
||||
dependencies = [
|
||||
"effectcraft-geom",
|
||||
"effectcraft-keyframe",
|
||||
@@ -1472,7 +1475,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-pdf"
|
||||
version = "0.8.0"
|
||||
version = "0.9.3"
|
||||
dependencies = [
|
||||
"effectcraft-raster",
|
||||
"effectcraft-svg",
|
||||
@@ -1486,7 +1489,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-plugin"
|
||||
version = "0.8.0"
|
||||
version = "0.9.3"
|
||||
dependencies = [
|
||||
"effectcraft-effects",
|
||||
"effectcraft-keyframe",
|
||||
@@ -1498,7 +1501,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-project"
|
||||
version = "0.8.0"
|
||||
version = "0.9.3"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-geom",
|
||||
@@ -1511,14 +1514,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-psd"
|
||||
version = "0.8.0"
|
||||
version = "0.9.3"
|
||||
dependencies = [
|
||||
"miniz_oxide 0.8.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-raster"
|
||||
version = "0.8.0"
|
||||
version = "0.9.3"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-geom",
|
||||
@@ -1527,7 +1530,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-render"
|
||||
version = "0.8.0"
|
||||
version = "0.9.3"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-effects",
|
||||
@@ -1549,7 +1552,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-script"
|
||||
version = "0.8.0"
|
||||
version = "0.9.3"
|
||||
dependencies = [
|
||||
"boa_engine",
|
||||
"effectcraft-engine",
|
||||
@@ -1557,14 +1560,16 @@ dependencies = [
|
||||
"effectcraft-keyframe",
|
||||
"effectcraft-path",
|
||||
"effectcraft-project",
|
||||
"effectcraft-psd",
|
||||
"effectcraft-render",
|
||||
"effectcraft-time",
|
||||
"serde_json",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-segment"
|
||||
version = "0.8.0"
|
||||
version = "0.9.3"
|
||||
dependencies = [
|
||||
"miniz_oxide 0.8.9",
|
||||
"ndarray",
|
||||
@@ -1573,7 +1578,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-svg"
|
||||
version = "0.8.0"
|
||||
version = "0.9.3"
|
||||
dependencies = [
|
||||
"effectcraft-geom",
|
||||
"effectcraft-path",
|
||||
@@ -1585,7 +1590,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-text"
|
||||
version = "0.8.0"
|
||||
version = "0.9.3"
|
||||
dependencies = [
|
||||
"effectcraft-keyframe",
|
||||
"harfrust",
|
||||
@@ -1597,7 +1602,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-time"
|
||||
version = "0.8.0"
|
||||
version = "0.9.3"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -1605,7 +1610,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-track"
|
||||
version = "0.8.0"
|
||||
version = "0.9.3"
|
||||
dependencies = [
|
||||
"effectcraft-raster",
|
||||
"effectcraft-segment",
|
||||
@@ -1617,7 +1622,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-ui-egui"
|
||||
version = "0.8.0"
|
||||
version = "0.9.3"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"effectcraft-color",
|
||||
@@ -1649,11 +1654,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-vp9enc"
|
||||
version = "0.8.0"
|
||||
version = "0.9.3"
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-web"
|
||||
version = "0.8.0"
|
||||
version = "0.9.3"
|
||||
dependencies = [
|
||||
"effectcraft-engine",
|
||||
"effectcraft-export",
|
||||
@@ -6463,7 +6468,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.8.0"
|
||||
version = "0.9.3"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/*"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.8.0"
|
||||
version = "0.9.3"
|
||||
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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 — 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
|
||||
|
||||
[](https://www.star-history.com/?repos=storytold%2Feffectcraft&type=date&legend=top-left)
|
||||
|
||||
+9
-2
@@ -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, 26%). No language has had
|
||||
Also shipped: Traditional Chinese (partial, 28%) and Ukrainian (partial, 29%). No language has had
|
||||
native-speaker review.
|
||||
|
||||
## Upcoming
|
||||
@@ -113,10 +113,17 @@ 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
|
||||
|
||||
@@ -14,9 +14,13 @@ 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 }
|
||||
@@ -32,8 +36,6 @@ 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.
|
||||
|
||||
@@ -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| eprintln!("effectcraft: audio output: {e}"),
|
||||
|e| log::warn!("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() {
|
||||
eprintln!("effectcraft: audio output: {e}");
|
||||
log::warn!("audio output: {e}");
|
||||
return;
|
||||
}
|
||||
let _ = stop_rx.recv();
|
||||
|
||||
@@ -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) => {
|
||||
eprintln!("effectcraft: control server failed to bind 127.0.0.1:{port}: {e}");
|
||||
log::error!("control server failed to bind 127.0.0.1:{port}: {e}");
|
||||
return rx;
|
||||
}
|
||||
};
|
||||
eprintln!("effectcraft: control server listening on 127.0.0.1:{port}");
|
||||
log::info!("control server listening on 127.0.0.1:{port}");
|
||||
std::thread::spawn(move || accept_loop(listener, tx, ctx));
|
||||
rx
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ 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;
|
||||
@@ -44,6 +45,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()
|
||||
@@ -88,13 +104,24 @@ fn parse_args(args: impl IntoIterator<Item = String>, env_port: Option<String>)
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
// Help ▸ Enable Logging writes through this logger; warnings feed the compatibility report.
|
||||
// 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.
|
||||
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.
|
||||
@@ -105,12 +132,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([1680.0, 1020.0])
|
||||
.with_min_inner_size([960.0, 600.0])
|
||||
.with_inner_size(window::DEFAULT_SIZE)
|
||||
.with_min_inner_size(window::MIN_SIZE)
|
||||
.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())
|
||||
@@ -120,8 +152,8 @@ fn main() -> eframe::Result {
|
||||
wgpu_options: wgpu_options(launch.backends),
|
||||
..Default::default()
|
||||
};
|
||||
// Start-up milestones: with `RUST_LOG=info` they go to stderr, so a window that never
|
||||
// appears shows how far start-up got (#234).
|
||||
// Start-up milestones go to stderr and the session log, 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.
|
||||
@@ -170,11 +202,14 @@ 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})) {
|
||||
eprintln!("effectcraft: {e}");
|
||||
log::warn!("{p}: {e}");
|
||||
}
|
||||
} else if demo {
|
||||
let _ = session.execute("file.openDemoProject", json!({}));
|
||||
@@ -183,13 +218,15 @@ fn main() -> eframe::Result {
|
||||
if !media.is_empty()
|
||||
&& let Err(e) = session.execute("file.import", json!({"paths": media}))
|
||||
{
|
||||
eprintln!("effectcraft: {e}");
|
||||
log::warn!("import: {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 {
|
||||
@@ -264,6 +301,7 @@ 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,
|
||||
}))
|
||||
@@ -315,8 +353,12 @@ 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."
|
||||
);
|
||||
log::error!("{message}");
|
||||
eprintln!("effectcraft: {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}");
|
||||
}
|
||||
let _ = rfd::MessageDialog::new()
|
||||
.set_level(rfd::MessageLevel::Error)
|
||||
.set_title("EffectCraft")
|
||||
@@ -333,6 +375,8 @@ 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,
|
||||
@@ -347,6 +391,7 @@ 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");
|
||||
@@ -360,6 +405,7 @@ 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();
|
||||
}
|
||||
|
||||
@@ -400,7 +446,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)
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
//! 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -54,3 +54,13 @@ 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"
|
||||
|
||||
@@ -681,6 +681,12 @@ 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
|
||||
|
||||
@@ -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")), ("rescan", get(a, "rescan"))]))?)
|
||||
json_reply(b.exec("text.fonts", obj(&[("query", get(a, "query")), ("variable", get(a, "variable")), ("rescan", get(a, "rescan"))]))?)
|
||||
}
|
||||
|
||||
/// `effect.apply` on one layer, then set parameters on the new instance by param id: one batch,
|
||||
@@ -703,12 +703,13 @@ 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) 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\"}`.",
|
||||
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\"}`.",
|
||||
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."}
|
||||
}),
|
||||
&[],
|
||||
|
||||
@@ -323,11 +323,40 @@ fn wild(pat: &str, name: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a selection: comma-separated names (optionally quoted) or `<hex>` hashes / wildcards.
|
||||
/// 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.
|
||||
pub fn selection_hashes(sel: &str, manifest: Option<&[(String, u32)]>) -> Vec<u32> {
|
||||
let mut out = vec![];
|
||||
for tok in sel.split(',') {
|
||||
let t = tok.trim().trim_matches('"').trim_matches('\'');
|
||||
for t in selection_names(sel) {
|
||||
let t = t.as_str();
|
||||
if t.is_empty() {
|
||||
continue;
|
||||
}
|
||||
@@ -677,6 +706,25 @@ 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();
|
||||
|
||||
@@ -43,16 +43,27 @@ fn tritone(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
b
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// 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.
|
||||
pub fn bc_modern(v: f32, b: f32, c: f32) -> f32 {
|
||||
if v >= 1.0 {
|
||||
return 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) }
|
||||
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
|
||||
}
|
||||
|
||||
fn brightness_contrast(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
@@ -1181,6 +1192,34 @@ 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];
|
||||
|
||||
@@ -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, posterized_time, time_frames, timewarp_crop, timewarp_plan};
|
||||
pub use time_fx::{PixelMotion, TimewarpPlan, TwStep, pmb_plan, 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,6 +264,21 @@ 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]) {
|
||||
@@ -281,6 +296,27 @@ 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 {
|
||||
|
||||
@@ -429,7 +429,8 @@ 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.
|
||||
/// With Original. The noise stays inside the layer's alpha, so a masked solid shows it only within
|
||||
/// its masks (#939).
|
||||
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 {
|
||||
@@ -440,7 +441,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] * k)
|
||||
[0, 1, 2, 3].map(|c| orig[c] * blend_orig + out[c] * orig[3] * k)
|
||||
}
|
||||
|
||||
fn render(ctx: &EffectCtx, mut b: Buf, turbulent: bool) -> Buf {
|
||||
@@ -574,6 +575,28 @@ 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", &[]);
|
||||
|
||||
@@ -219,28 +219,63 @@ 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 v = (y as f64 + 0.5 - c.1) / h + 0.5;
|
||||
let dy = y as f64 + 0.5 - c.1;
|
||||
for (x, px) in row.iter_mut().enumerate() {
|
||||
let s = (x as f64 + 0.5 - c.0) / r;
|
||||
if s.abs() >= 1.0 {
|
||||
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 {
|
||||
*px = [0.0; 4];
|
||||
continue;
|
||||
}
|
||||
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 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 front = || face((-bq + root) / a, false);
|
||||
let back = || face((-bq - root) / a, true);
|
||||
*px = match mode {
|
||||
1 => front(),
|
||||
2 => back(),
|
||||
@@ -362,6 +397,9 @@ 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());
|
||||
@@ -527,6 +565,31 @@ 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]);
|
||||
|
||||
@@ -583,6 +583,13 @@ 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)));
|
||||
@@ -591,7 +598,7 @@ pub fn timewarp_flow(a: &Image, b: &Image, pm: &PixelMotion) -> Flow {
|
||||
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, 4);
|
||||
let mut flow = effectcraft_raster::flow::block_flow_window(&ga, &gb, pm.tile, pm.window, radius);
|
||||
smooth_flow(&mut flow, pm);
|
||||
flow
|
||||
}
|
||||
@@ -923,12 +930,71 @@ fn wide_time(ctx: &EffectCtx, b: Buf) -> Buf {
|
||||
mean_of(ctx, b, ×)
|
||||
}
|
||||
|
||||
fn pixel_motion_blur(ctx: &EffectCtx, b: Buf) -> Buf {
|
||||
fn shutter_times(ctx: &EffectCtx) -> Vec<f64> {
|
||||
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();
|
||||
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, ×)
|
||||
(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, ×)?;
|
||||
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,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
{
|
||||
"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 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
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.
|
||||
@@ -0,0 +1,177 @@
|
||||
//! 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),
|
||||
]
|
||||
}
|
||||
@@ -5,7 +5,8 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use effectcraft_keyframe::{Interp, Keyframe, Value as KV};
|
||||
use effectcraft_project::{GroupKind, LayerId, Node, PropGroup, Property, Uid};
|
||||
use effectcraft_project::build::{self, Ids};
|
||||
use effectcraft_project::{GroupKind, Layer, LayerId, LayerSource, Node, Project, PropGroup, Property, Uid};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -293,6 +294,9 @@ 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]),
|
||||
@@ -301,6 +305,50 @@ 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)?;
|
||||
@@ -312,10 +360,27 @@ 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![];
|
||||
@@ -335,6 +400,23 @@ 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})));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()?).map_err(EngineError::Other)?;
|
||||
let xml = crate::xml_project::to_xml(&s.project.to_file_json_with_view(&s.saved_view())?).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 = format!("file://{}", path.replace(' ', "%20"));
|
||||
let url = super::layer_menu::file_url(path);
|
||||
s.events.push(Event::OpenUrl(url));
|
||||
Ok(json!({"opened": path}))
|
||||
}
|
||||
@@ -288,7 +288,12 @@ fn enable_logging(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn reveal_log(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let file = crate::logging::current_file().unwrap_or_else(|| log_file(s));
|
||||
// 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 path = file.to_string_lossy().to_string();
|
||||
let url = super::layer_menu::folder_url(&path);
|
||||
s.events.push(Event::OpenUrl(url.clone()));
|
||||
@@ -316,6 +321,8 @@ pub const IMPORT_FORMATS: &[&str] = &[
|
||||
"Opus",
|
||||
"glTF/GLB",
|
||||
"OBJ",
|
||||
"STL",
|
||||
"FBX",
|
||||
"Lottie JSON",
|
||||
];
|
||||
|
||||
@@ -614,3 +621,25 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +109,7 @@ 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,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Composition menu.
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_project::{Comp, ItemId, ItemKind, Marker};
|
||||
use effectcraft_project::{Comp, ItemId, ItemKind, Marker, limits};
|
||||
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 !(0.001..=1000.0).contains(&f) => Err(bad(cmd, "frameRate: fps from 0.001 to 1000")),
|
||||
Some(f) if !(limits::MIN_FPS..=limits::MAX_FPS).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 = (w as u32).clamp(4, 30000);
|
||||
c.width = limits::size(w).max(4);
|
||||
}
|
||||
if let Some(h) = p.get("height").and_then(Value::as_u64) {
|
||||
c.height = (h as u32).clamp(4, 30000);
|
||||
c.height = limits::size(h).max(4);
|
||||
}
|
||||
// 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 = pa.max(0.1);
|
||||
c.pixel_aspect = limits::pixel_aspect(pa.max(limits::MIN_PIXEL_ASPECT));
|
||||
}
|
||||
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 as u32).clamp(2, 64);
|
||||
c.motion_blur_samples = n.clamp(limits::MOTION_BLUR_SAMPLES.0.into(), limits::MOTION_BLUR_SAMPLES.1.into()) as u32;
|
||||
}
|
||||
if let Some(n) = p.get("adaptiveSampleLimit").and_then(Value::as_u64) {
|
||||
c.motion_blur_adaptive_limit = (n as u32).clamp(16, 256);
|
||||
c.motion_blur_adaptive_limit = n.clamp(limits::ADAPTIVE_SAMPLE_LIMIT.0.into(), limits::ADAPTIVE_SAMPLE_LIMIT.1.into()) as u32;
|
||||
}
|
||||
if let Some(b) = p.get("preserveFrameRate").and_then(Value::as_bool) {
|
||||
c.preserve_frame_rate = b;
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
//! Composition menu, part two: crop to region of interest / layer bounds, Save Frame As ▸ File,
|
||||
//! Responsive Design — Time.
|
||||
//! Copy Frame to Clipboard, Responsive Design — Time.
|
||||
|
||||
use effectcraft_project::Marker;
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, bad, comp_id, f_p, has_comp, has_layers, layers_p, str_p};
|
||||
use crate::{EngineError, Result, Session, cmd};
|
||||
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};
|
||||
|
||||
fn has_roi(s: &Session) -> std::result::Result<(), String> {
|
||||
has_comp(s)?;
|
||||
@@ -17,7 +18,8 @@ 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());
|
||||
let (w, h) = (r[2].round().max(1.0) as u32, r[3].round().max(1.0) as u32);
|
||||
// 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]));
|
||||
s.edit(label, None, |proj, st| {
|
||||
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
c.width = w;
|
||||
@@ -120,6 +122,26 @@ 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)?;
|
||||
@@ -143,6 +165,65 @@ 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),
|
||||
@@ -156,6 +237,18 @@ 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,6 +405,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
label: "Generate Fill Layer",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
alt_shortcuts: &[],
|
||||
params: P,
|
||||
enabled: has_layers,
|
||||
run: generate,
|
||||
@@ -415,6 +416,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
label: "Content-Aware Fill Layer...",
|
||||
menu: &["Layer", "New"],
|
||||
shortcut: None,
|
||||
alt_shortcuts: &[],
|
||||
params: P,
|
||||
enabled: always,
|
||||
run: open_panel,
|
||||
|
||||
@@ -531,6 +531,42 @@ 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();
|
||||
@@ -773,7 +809,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 = format!("file://{path}");
|
||||
let url = super::layer_menu::file_url(&path);
|
||||
s.events.push(crate::Event::OpenUrl(url.clone()));
|
||||
Ok(json!({"url": url}))
|
||||
}
|
||||
@@ -818,6 +854,15 @@ 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),
|
||||
@@ -893,3 +938,25 @@ 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")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,8 +43,18 @@ 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 comp.layers.iter().enumerate() {
|
||||
for (i, l) in ctx.comp.layers.iter().enumerate() {
|
||||
if !keep_going() {
|
||||
return None;
|
||||
}
|
||||
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()) {
|
||||
@@ -52,20 +62,23 @@ pub fn errors(s: &Session, cid: ItemId, t: Tick) -> Vec<ExprError> {
|
||||
}
|
||||
});
|
||||
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(t))).err(),
|
||||
Some(h) => h.eval(ctx, l, p, &p.value_at(l.layer_time(ctx.time))).err(),
|
||||
None => None,
|
||||
}
|
||||
} else {
|
||||
s.expr_check.and_then(|check| check(&e.text).err())
|
||||
check.and_then(|check| check(&e.text).err())
|
||||
};
|
||||
if let Some(message) = msg {
|
||||
out.push(error_of(cid, i, l, p, message, !e.enabled));
|
||||
out.push(error_of(ctx.comp_id, i, l, p, message, !e.enabled));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
keep_going().then_some(out)
|
||||
}
|
||||
|
||||
fn error_of(cid: ItemId, i: usize, l: &Layer, p: &Property, message: String, disabled: bool) -> ExprError {
|
||||
|
||||
@@ -43,10 +43,19 @@ 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 = Project::from_json(&text).map_err(|e| EngineError::Other(format!("{path} is not a project EffectCraft can open: {e}")))?;
|
||||
let (mut proj, repaired) =
|
||||
Project::from_json_repaired(&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)) {
|
||||
@@ -60,7 +69,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}))
|
||||
Ok(json!({"path": path, "savedBy": saved_by, "repaired": repaired}))
|
||||
}
|
||||
|
||||
/// Footage saved with a relative path (imported by the CLI or an agent as `clip.mp4`, or written
|
||||
@@ -89,7 +98,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()?;
|
||||
let json = s.project.to_file_json_with_view(&s.saved_view())?;
|
||||
if crate::xml_project::is_xml_path(path) { crate::xml_project::to_xml(&json).map_err(EngineError::Other) } else { Ok(json) }
|
||||
}
|
||||
|
||||
|
||||
@@ -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};
|
||||
use effectcraft_project::{AlphaMode, Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid, limits};
|
||||
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(FrameRate::from_f64).unwrap_or(FrameRate::FPS_29_97)
|
||||
f_p(p, "frameRate").map(|f| limits::frame_rate(FrameRate::from_f64(f), FrameRate::FPS_29_97)).unwrap_or(FrameRate::FPS_29_97)
|
||||
}
|
||||
|
||||
fn placeholder_footage(p: &Value) -> Footage {
|
||||
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;
|
||||
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);
|
||||
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(|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),
|
||||
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),
|
||||
pixel_aspect: 1.0,
|
||||
}
|
||||
}
|
||||
@@ -620,7 +620,8 @@ impl Interpretation {
|
||||
),
|
||||
};
|
||||
it.invert_alpha = super::b_p(p, "invertAlpha");
|
||||
it.loops = p.get("loop").and_then(Value::as_u64).map(|v| v.clamp(1, 9999) as u32);
|
||||
// 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.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"))?),
|
||||
|
||||
@@ -179,7 +179,8 @@ 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;
|
||||
l.out_point = (at + len).min(comp.duration);
|
||||
// Keep the full marked length past the comp's end (After Effects); the comp just doesn't show it.
|
||||
l.out_point = at + len;
|
||||
let lid = l.id;
|
||||
let cm = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
cm.layers.insert(0, l);
|
||||
@@ -206,8 +207,12 @@ 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()}),
|
||||
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}),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
//! Frontend-only commands: viewer zoom/resolution/overlays, panels, workspaces, dialogs and app
|
||||
//! Frontend-only commands: viewer zoom/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 super::{CommandSpec, always, frontend, has_comp, has_layers};
|
||||
use crate::cmd;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, frontend, has_comp, has_layers};
|
||||
use crate::{Result, Session, cmd};
|
||||
|
||||
macro_rules! fe {
|
||||
($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr) => {
|
||||
@@ -35,6 +37,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",
|
||||
@@ -57,11 +69,6 @@ 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),
|
||||
@@ -71,7 +78,9 @@ 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),
|
||||
fe!("view.fullScreen", "Enter Full Screen", ["View"], Some("Ctrl+Cmd+F"), "{}", 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.newViewer",
|
||||
"New Viewer",
|
||||
@@ -116,5 +125,28 @@ 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}))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ 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, Project, PropGroup, Quality, Solid, TrackMatte,
|
||||
Comp, FrameBlend, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, MaskMode, MatteKind, Node, Project, PropGroup, Quality, Solid, TrackMatte,
|
||||
limits,
|
||||
};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
@@ -92,8 +93,9 @@ 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] });
|
||||
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);
|
||||
// 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 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);
|
||||
@@ -114,7 +116,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),
|
||||
v => Ok(v.map(limits::pixel_aspect)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,7 +297,10 @@ 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;
|
||||
l.out_point = fr.snap_nearest((start + dur.unwrap_or(comp.duration)).min(comp.duration)).max(start + fr.frame_duration());
|
||||
// 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());
|
||||
place(&mut l, position);
|
||||
insert_layer_at(proj, st, cid, l, index)
|
||||
})?;
|
||||
@@ -377,6 +382,9 @@ 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)?;
|
||||
@@ -394,6 +402,38 @@ 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 {
|
||||
@@ -857,9 +897,13 @@ 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;
|
||||
@@ -891,6 +935,14 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1186,6 +1238,26 @@ 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),
|
||||
@@ -1203,16 +1275,7 @@ fn add_shape_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let uid = g.uid;
|
||||
proj.next_id = next;
|
||||
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());
|
||||
shape_item_target(proj, cid, lid, group_uid)?.children.push(g.into());
|
||||
Ok(uid)
|
||||
})?;
|
||||
// The new item's property path, ready for prop.set / add_keyframe (`contents/trim#2/end`).
|
||||
@@ -1220,6 +1283,20 @@ 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).
|
||||
@@ -1404,8 +1481,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(|v| v.max(1) as u32);
|
||||
let h = p.get("height").and_then(Value::as_u64).map(|v| v.max(1) as u32);
|
||||
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 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);
|
||||
@@ -1508,7 +1585,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?}",
|
||||
"{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)}",
|
||||
has_layers,
|
||||
set_switch
|
||||
),
|
||||
@@ -1530,7 +1607,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Layer Timing",
|
||||
[],
|
||||
None,
|
||||
"{layers?, op?: moveInToTime|moveOutToTime|trimInToTime|trimOutToTime, delta?, start?, in?, out?, merge?}",
|
||||
"{layers?, op?: moveInToTime|moveOutToTime|trimInToTime|trimOutToTime, delta?, start?, in?, out?, inDelta?, outDelta? (seconds, per layer), merge?}",
|
||||
has_layers,
|
||||
timing
|
||||
),
|
||||
@@ -1570,7 +1647,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, group?: uid|path} → {uid, path}",
|
||||
"{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)",
|
||||
has_layers,
|
||||
add_shape_item
|
||||
),
|
||||
|
||||
@@ -661,7 +661,24 @@ 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();
|
||||
format!("file://{dir}")
|
||||
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
|
||||
}
|
||||
|
||||
fn reveal_in_finder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -845,3 +862,21 @@ 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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
mod align;
|
||||
mod align_data;
|
||||
mod anim;
|
||||
mod anim_presets;
|
||||
pub mod anim_tools;
|
||||
mod animation;
|
||||
pub(crate) mod app_more;
|
||||
@@ -43,7 +44,7 @@ pub mod panels_cmds;
|
||||
pub(crate) mod path_nulls;
|
||||
mod paths;
|
||||
mod project_items;
|
||||
mod prop;
|
||||
pub(crate) mod prop;
|
||||
mod prop_groups;
|
||||
mod proxy;
|
||||
pub mod puppet;
|
||||
@@ -75,6 +76,8 @@ 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;
|
||||
@@ -98,6 +101,8 @@ 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,
|
||||
@@ -108,8 +113,22 @@ 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, params: $params, enabled: $en, run: $run, journal: true }
|
||||
$crate::commands::CommandSpec {
|
||||
id: $id,
|
||||
label: $label,
|
||||
menu: &[$($m),*],
|
||||
shortcut: $sc,
|
||||
alt_shortcuts: &[],
|
||||
params: $params,
|
||||
enabled: $en,
|
||||
run: $run,
|
||||
journal: true,
|
||||
}
|
||||
};
|
||||
}
|
||||
#[macro_export]
|
||||
@@ -120,6 +139,7 @@ macro_rules! query {
|
||||
label: $label,
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
alt_shortcuts: &[],
|
||||
params: $params,
|
||||
enabled: $crate::commands::always,
|
||||
run: $run,
|
||||
@@ -162,6 +182,7 @@ 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());
|
||||
@@ -221,6 +242,11 @@ 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();
|
||||
@@ -442,17 +468,34 @@ pub(crate) fn merge_p(p: &Value) -> Option<&str> {
|
||||
str_p(p, "merge")
|
||||
}
|
||||
|
||||
/// `comp`: item id or name; default the active comp.
|
||||
/// `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.
|
||||
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).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)),
|
||||
_ => s.active_comp_id().ok_or(EngineError::NoComp),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a layer reference: id (number), `#n` / index (1-based), or name.
|
||||
/// 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.
|
||||
pub(crate) fn resolve_layer(comp: &Comp, v: &Value) -> Option<LayerId> {
|
||||
match v {
|
||||
Value::Number(n) => {
|
||||
@@ -463,7 +506,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)
|
||||
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))))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Advanced 3D: model layers (File ▸ Import of glTF/OBJ, Layer ▸ New ▸ 3D primitives), the
|
||||
//! Advanced 3D: model layers (File ▸ Import of glTF/OBJ/STL/FBX, 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 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",
|
||||
"{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",
|
||||
has_comp,
|
||||
new_model
|
||||
),
|
||||
|
||||
@@ -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",
|
||||
"wiggle", "merge", "front", "bevel", "side", "back",
|
||||
];
|
||||
|
||||
/// Selected shape items (contents entries) of shape layers: (layer, uid).
|
||||
|
||||
@@ -230,8 +230,20 @@ 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. One undo step (with `merge`,
|
||||
/// `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`,
|
||||
/// 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")?;
|
||||
@@ -244,16 +256,41 @@ 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 = set(s, p)?;
|
||||
for (olid, ouid) in twins {
|
||||
let mut q = twin_params(p, olid, ouid);
|
||||
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) };
|
||||
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)?
|
||||
};
|
||||
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);
|
||||
// (a layer that can't take it, e.g. a clamped or static property, keeps its value)
|
||||
let _ = set(s, &q);
|
||||
}
|
||||
@@ -1140,7 +1177,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)}",
|
||||
"{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)}",
|
||||
has_layers,
|
||||
set_selected
|
||||
),
|
||||
|
||||
@@ -46,6 +46,13 @@ 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();
|
||||
@@ -80,6 +87,7 @@ 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<_>>(),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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|webm|png|jpeg|tiff|exr|gif|wav|aiff";
|
||||
const FORMATS: &str = "format: h264|hevc|av1|prores|uncompressed|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|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|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)}",
|
||||
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|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|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?}",
|
||||
has_items,
|
||||
set_output_module
|
||||
),
|
||||
|
||||
@@ -384,6 +384,15 @@ 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",
|
||||
@@ -409,6 +418,15 @@ 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",
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
//! 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::Value as KV;
|
||||
use effectcraft_keyframe::{Gradient, 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};
|
||||
|
||||
@@ -224,15 +225,157 @@ pub fn first_paint(layer: &Layer, lt: Tick) -> (Option<[f64; 4]>, Option<[f64; 4
|
||||
(fill, stroke, width)
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
/// 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) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
if add_stroke && c.groups().any(|g| PATHS.contains(&g.match_id.as_str())) && !c.groups().any(is_stroke) {
|
||||
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) {
|
||||
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());
|
||||
}
|
||||
@@ -252,7 +395,7 @@ fn paint(c: &mut PropGroup, ids: &mut Ids, lt: Tick, fill: Option<[f64; 4]>, str
|
||||
"gstroke" => set(g, "width", width.map(KV::Scalar)),
|
||||
"group" => {
|
||||
if let Some(sub) = g.sub_mut("contents") {
|
||||
paint(sub, ids, lt, fill, stroke, width, add_stroke, depth + 1);
|
||||
paint(sub, ids, lt, e, depth + 1);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -260,10 +403,20 @@ fn paint(c: &mut PropGroup, ids: &mut Ids, lt: Tick, fill: Option<[f64; 4]>, str
|
||||
}
|
||||
}
|
||||
|
||||
/// `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): `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): `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.
|
||||
fn fill_stroke(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "shape.fillStroke";
|
||||
let (cid, lids) = layers_p(s, p)?;
|
||||
@@ -275,8 +428,10 @@ 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));
|
||||
if fill.is_some() || stroke.is_some() || width.is_some() {
|
||||
let add_stroke = width.map_or(stroke.is_some(), |w| w > 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() {
|
||||
let t = s.time();
|
||||
s.edit("Fill and Stroke", merge_p(p), |proj, _| {
|
||||
let mut next = proj.next_id;
|
||||
@@ -284,7 +439,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, fill, stroke, width, add_stroke, 0);
|
||||
paint(contents, &mut Ids(&mut next), lt, &e, 0);
|
||||
}
|
||||
}
|
||||
proj.next_id = next;
|
||||
@@ -292,8 +447,10 @@ 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 (fill, stroke, width) = first_paint(l, l.layer_time(s.time()));
|
||||
Ok(json!({"fill": fill, "stroke": stroke, "strokeWidth": width}))
|
||||
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()}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -303,7 +460,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Fill and Stroke",
|
||||
[],
|
||||
None,
|
||||
"{layers?, fill?: color, stroke?: color, strokeWidth?, merge?} → {fill, stroke, strokeWidth} (first layer)",
|
||||
"{layers?, fillType?: none|solid|linear|radial, strokeType?, fill?: color, stroke?: color, strokeWidth?, merge?} → {fill, stroke, strokeWidth, fillType, strokeType} (first layer)",
|
||||
has_layers,
|
||||
fill_stroke
|
||||
),
|
||||
|
||||
@@ -139,12 +139,18 @@ 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 [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 ends = |radial: bool| gradient_ends(radial, bounds);
|
||||
let mut items = vec![];
|
||||
let (s, width) = (&paint.stroke, paint.stroke_width);
|
||||
let stroke = match s.kind {
|
||||
|
||||
@@ -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, params: $params, enabled: not_yet, run: not_yet_run, journal: true }
|
||||
CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: None, alt_shortcuts: &[], 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), params: $params, enabled: not_yet, run: not_yet_run, journal: true }
|
||||
CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: Some($sc), alt_shortcuts: &[], params: $params, enabled: not_yet, run: not_yet_run, journal: true }
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -115,6 +115,13 @@ 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;
|
||||
@@ -246,7 +253,7 @@ fn enable_per_char(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// JSON → property value shaped like `like`.
|
||||
fn json_value(v: &Value, like: &KV) -> Option<KV> {
|
||||
pub(super) fn json_value(v: &Value, like: &KV) -> Option<KV> {
|
||||
Some(match v {
|
||||
Value::Bool(b) => match like {
|
||||
KV::Bool(_) => KV::Bool(*b),
|
||||
@@ -431,7 +438,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|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|frontColor|bevelColor|sideColor|backColor|perChar3d], name?}",
|
||||
has_text_layer,
|
||||
add_animator
|
||||
),
|
||||
|
||||
@@ -506,7 +506,9 @@ 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);
|
||||
@@ -514,8 +516,9 @@ 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")});
|
||||
let mut v = json!({"family": f, "styles": styles, "origin": origin.get(&f).copied().unwrap_or("system"), "variable": variable.contains(&f)});
|
||||
if let Some(n) = native.get(&f) {
|
||||
v["nativeName"] = json!(n);
|
||||
}
|
||||
@@ -559,7 +562,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"List Fonts",
|
||||
[],
|
||||
None,
|
||||
"{query?, rescan?} → {count, families: [{family, styles, origin: bundled|system|user, nativeName?}], added} (rescan picks up fonts installed since launch)",
|
||||
"{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)",
|
||||
always_ok,
|
||||
fonts
|
||||
),
|
||||
|
||||
@@ -320,6 +320,21 @@ 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> {
|
||||
@@ -706,6 +721,15 @@ 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),
|
||||
|
||||
@@ -39,7 +39,10 @@ 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 => {
|
||||
let parsed = effectcraft_time::parse_timecode(text, fr, fr.supports_drop_frame(), current).map_err(|e| e.0)?;
|
||||
// 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 relative = text.trim().starts_with('+') || text.trim().starts_with('-');
|
||||
if relative {
|
||||
Ok(parsed)
|
||||
@@ -61,8 +64,9 @@ 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)?;
|
||||
let t = if let Some(f) = p.get("frame").and_then(Value::as_i64) {
|
||||
comp.frame_rate.tick_of(f)
|
||||
// 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)
|
||||
} 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))?;
|
||||
@@ -169,10 +173,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", [], 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.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.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"}))),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! View menu commands that change the project (guides) or headless-relevant editor state (the
|
||||
//! region of interest).
|
||||
//! View menu commands that change the project (guides, each comp's Resolution) or
|
||||
//! headless-relevant editor state (the region of interest).
|
||||
|
||||
use effectcraft_project::Guide;
|
||||
use effectcraft_project::{Guide, Resolution};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, comp_id, f_p, has_comp, str_p};
|
||||
@@ -75,6 +75,24 @@ 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) {
|
||||
@@ -101,12 +119,26 @@ 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),
|
||||
|
||||
@@ -210,7 +210,13 @@ 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();
|
||||
assert_eq!(paths, ["/nonexistent/ec/abs.png", clip.to_str().unwrap(), "media/ec-relative-gone.png"]);
|
||||
// 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!(!o.is_dirty(), "finding the files is no edit");
|
||||
let r = o.execute("footage.check", json!({"wait": true})).unwrap();
|
||||
assert_eq!(r["missing"], 2);
|
||||
|
||||
@@ -341,7 +341,8 @@ 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();
|
||||
self.project = a.state[target].clone();
|
||||
let cur = std::mem::replace(&mut self.project, a.state[target].clone());
|
||||
self.keep_view_settings(&cur);
|
||||
self.history.undo = undo;
|
||||
self.history.redo = redo;
|
||||
self.history.branches = branches;
|
||||
|
||||
@@ -350,6 +350,15 @@ 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 {
|
||||
@@ -698,9 +707,38 @@ 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();
|
||||
@@ -711,6 +749,7 @@ 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();
|
||||
@@ -850,6 +889,26 @@ 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 });
|
||||
}
|
||||
@@ -980,6 +1039,7 @@ 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);
|
||||
@@ -1046,6 +1106,8 @@ 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;
|
||||
@@ -1068,10 +1130,14 @@ 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;
|
||||
@@ -1124,6 +1190,11 @@ 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()
|
||||
}
|
||||
@@ -1153,6 +1224,8 @@ 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
|
||||
@@ -1193,14 +1266,21 @@ 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.
|
||||
/// to Display), a separator, then every family. Each row says whether the family is variable,
|
||||
/// for the menu's Variable Fonts filter.
|
||||
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 row = |f: &String, recent: bool| FontRow { family: f.clone(), display: native.get(f).cloned().unwrap_or_else(|| f.clone()), recent };
|
||||
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 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 });
|
||||
out.push(FontRow { family: String::new(), display: "-".into(), recent: false, variable: false });
|
||||
}
|
||||
out.extend(all.iter().map(|f| row(f, false)));
|
||||
out
|
||||
|
||||
@@ -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}")
|
||||
}
|
||||
|
||||
+301
-27
@@ -1,25 +1,47 @@
|
||||
//! 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.
|
||||
//! 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.
|
||||
//!
|
||||
//! 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.
|
||||
//! 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.
|
||||
|
||||
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};
|
||||
use std::sync::{Mutex, OnceLock, PoisonError};
|
||||
|
||||
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 `RUST_LOG` filter for stderr: (target prefix or every target, level).
|
||||
static STDERR: OnceLock<Vec<(Option<String>, LevelFilter)>> = OnceLock::new();
|
||||
/// The filter for standard error and the session log: (target prefix or every target, level).
|
||||
static FILTER: 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>,
|
||||
@@ -32,15 +54,16 @@ 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")) || to_stderr(m)
|
||||
m.level() <= log::Level::Warn || (ENABLED.load(Ordering::Relaxed) && m.target().starts_with("effectcraft")) || shown(m)
|
||||
}
|
||||
fn log(&self, r: &log::Record) {
|
||||
if !self.enabled(r.metadata()) {
|
||||
return;
|
||||
}
|
||||
let line = format!("{} {:<5} {}: {}", unix_secs(), r.level(), r.target(), r.args());
|
||||
if to_stderr(r.metadata()) {
|
||||
eprintln!("{line}");
|
||||
let line = format_line(×tamp(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 Ok(mut st) = STATE.lock() else { return };
|
||||
if r.level() <= log::Level::Warn {
|
||||
@@ -59,22 +82,62 @@ impl log::Log for Logger {
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
fn unix_secs() -> u64 {
|
||||
web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
||||
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}")
|
||||
}
|
||||
|
||||
/// 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 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));
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
/// 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()))
|
||||
/// 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()))
|
||||
}
|
||||
|
||||
/// `RUST_LOG`'s directives, as env_logger reads them: `level`, `target=level` or a bare target
|
||||
@@ -110,9 +173,112 @@ pub fn install_panic_hook() {
|
||||
}));
|
||||
}
|
||||
|
||||
/// The log file inside `config_dir` (the settings folder), or the temporary folder.
|
||||
/// 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.
|
||||
pub fn log_path(config_dir: Option<&Path>) -> PathBuf {
|
||||
config_dir.map(Path::to_path_buf).unwrap_or_else(std::env::temp_dir).join("Logs").join("EffectCraft Log.txt")
|
||||
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(¬e);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn file logging on (writing to `file`) or off.
|
||||
@@ -123,7 +289,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 ({} {})", unix_secs(), std::env::consts::OS, env!("CARGO_PKG_VERSION"))?;
|
||||
writeln!(f, "{} INFO effectcraft: logging enabled ({} {})", timestamp(unix_millis()), std::env::consts::OS, env!("CARGO_PKG_VERSION"))?;
|
||||
}
|
||||
if let Ok(mut st) = STATE.lock() {
|
||||
st.file = Some(file);
|
||||
@@ -136,7 +302,7 @@ pub fn is_enabled() -> bool {
|
||||
ENABLED.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// The current log file (set once logging was enabled).
|
||||
/// The current Enable Logging file (set once logging was enabled).
|
||||
pub fn current_file() -> Option<PathBuf> {
|
||||
STATE.lock().ok().and_then(|s| s.file.clone())
|
||||
}
|
||||
@@ -148,7 +314,8 @@ pub fn recent() -> Vec<String> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{LevelFilter, level_for, parse_filter};
|
||||
use super::*;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
|
||||
/// `RUST_LOG` picks what start-up prints to stderr (#234).
|
||||
#[test]
|
||||
@@ -162,4 +329,111 @@ 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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", "hdr"];
|
||||
const IMAGE: &[&str] = &["png", "jpg", "jpeg", "tif", "tiff", "exr", "bmp", "webp", "psd", "psb", "svg", "tga", "dpx", "cin", "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"];
|
||||
const MODEL: &[&str] = &["gltf", "glb", "obj", "stl", "fbx"];
|
||||
|
||||
/// Kind of file from its extension.
|
||||
pub fn kind_of(path: &str) -> Option<&'static str> {
|
||||
|
||||
@@ -322,6 +322,9 @@ 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),
|
||||
@@ -693,6 +696,7 @@ 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
|
||||
---
|
||||
@@ -1021,6 +1025,14 @@ 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"]}
|
||||
@@ -1138,6 +1150,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
|
||||
@@ -1218,11 +1244,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] ---
|
||||
|
||||
@@ -32,11 +32,12 @@ 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")];
|
||||
|
||||
@@ -45,13 +46,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)]> {
|
||||
@@ -175,6 +190,8 @@ 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.
|
||||
@@ -185,6 +202,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 {
|
||||
@@ -335,6 +355,42 @@ 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()
|
||||
}
|
||||
@@ -393,6 +449,9 @@ 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,
|
||||
@@ -428,6 +487,7 @@ impl Default for Prefs {
|
||||
recent_fonts: vec![],
|
||||
preview: crate::preview::PreviewSettings::default(),
|
||||
custom_rgb: crate::viewer::CustomRgb::default(),
|
||||
comp_presets: vec![],
|
||||
extra: Extra::new(),
|
||||
}
|
||||
}
|
||||
@@ -553,6 +613,7 @@ 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;
|
||||
}
|
||||
@@ -567,6 +628,8 @@ 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;
|
||||
}
|
||||
@@ -623,6 +686,23 @@ 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());
|
||||
@@ -642,8 +722,8 @@ impl Prefs {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reset one page (`general`, `labels`, `autoSave`…) or everything (`None`). Open Recent is
|
||||
/// kept.
|
||||
/// Reset one page (`general`, `labels`, `autoSave`…) or everything (`None`). Open Recent and
|
||||
/// the saved composition presets are kept.
|
||||
pub fn reset(&mut self, page: Option<&str>) -> Result<(), String> {
|
||||
let d = Prefs::default();
|
||||
let Some(page) = page else {
|
||||
@@ -653,6 +733,7 @@ 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(());
|
||||
@@ -1170,7 +1251,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::Choice(UI_SCALES), true),
|
||||
s("appearance.uiScale", "UI Scale", Kind::Int(75, 200, "%"), 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),
|
||||
@@ -1178,6 +1259,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 {
|
||||
|
||||
@@ -116,7 +116,9 @@ pub fn import(proj: &mut Project, psd: &Psd, path: &str, name: &str, retain: boo
|
||||
}
|
||||
|
||||
fn new_comp(cx: &Ctx) -> Comp {
|
||||
Comp::new(cx.psd.width, cx.psd.height, cx.rate, cx.duration)
|
||||
// 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)
|
||||
}
|
||||
|
||||
fn build_comp(proj: &mut Project, cx: &mut Ctx, name: &str, nodes: &[Node], parent: Option<ItemId>) -> ItemId {
|
||||
|
||||
@@ -203,6 +203,11 @@ 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,
|
||||
|
||||
@@ -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": "/tmp/rq/out.mov"})).unwrap();
|
||||
let r = s.execute("renderQueue.setOutput", json!({"item": id, "path": crate::tests_stubs::tmp_out("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::absolute("/tmp/rq/out.mov").unwrap());
|
||||
assert_eq!(std::path::Path::new(r["outputPath"].as_str().unwrap()), std::path::PathBuf::from(crate::tests_stubs::tmp_out("rq/out.mov")));
|
||||
// A second item that fails, a third that is unqueued.
|
||||
let b = s.execute("renderQueue.add", json!({"format": "gif", "output": "/tmp/rq/fail.gif"})).unwrap();
|
||||
let b = s.execute("renderQueue.add", json!({"format": "gif", "output": crate::tests_stubs::tmp_out("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::absolute("/tmp/rq/out.mov").unwrap(), std::path::absolute("/tmp/rq/fail.gif").unwrap()]
|
||||
[std::path::PathBuf::from(crate::tests_stubs::tmp_out("rq/out.mov")), std::path::PathBuf::from(crate::tests_stubs::tmp_out("rq/fail.gif"))]
|
||||
);
|
||||
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::absolute("/tmp/rq/out.mov").unwrap());
|
||||
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!(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(), 12);
|
||||
assert_eq!(f["formats"].as_array().unwrap().len(), 13);
|
||||
assert_eq!(f["formats"][0]["available"], false);
|
||||
}
|
||||
|
||||
|
||||
@@ -110,6 +110,10 @@ 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.
|
||||
@@ -364,16 +368,26 @@ pub struct ScriptWindow {
|
||||
pub struct ScriptUiEvent {
|
||||
pub window: u32,
|
||||
pub widget: u32,
|
||||
/// `click`, `change`, `changing` or `close`.
|
||||
/// `click`, `change`, `changing`, `close`, or a mouse event ([`MOUSE_EVENTS`]).
|
||||
pub kind: String,
|
||||
/// The new value: text, number, bool, or selected index(es); for `close`, the result.
|
||||
/// 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.
|
||||
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)]
|
||||
@@ -384,6 +398,16 @@ 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 {
|
||||
@@ -391,6 +415,17 @@ 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 {
|
||||
@@ -687,6 +722,48 @@ 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")?;
|
||||
|
||||
@@ -88,7 +88,9 @@ 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);
|
||||
let defaults = e.shortcut.iter().filter_map(|s| normalize(s)).collect();
|
||||
// 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();
|
||||
push(
|
||||
Bindable {
|
||||
key,
|
||||
@@ -106,7 +108,7 @@ pub fn bindables(ui: &[UiCommand]) -> Vec<Bindable> {
|
||||
if !c.journal || entries.iter().any(|(_, e)| e.command == c.id) {
|
||||
continue;
|
||||
}
|
||||
let defaults = c.shortcut.and_then(normalize).into_iter().collect();
|
||||
let defaults = c.shortcut.into_iter().chain(c.alt_shortcuts.iter().copied()).filter_map(normalize).collect();
|
||||
push(
|
||||
Bindable {
|
||||
key: c.id.to_string(),
|
||||
|
||||
@@ -190,6 +190,25 @@ 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::*;
|
||||
|
||||
+111
-3
@@ -280,6 +280,57 @@ 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]
|
||||
@@ -328,9 +379,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");
|
||||
}
|
||||
|
||||
@@ -577,3 +627,61 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,3 +243,25 @@ 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));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
//! 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,4 +1,4 @@
|
||||
//! Composition ▸ Save Frame As ▸ Photoshop Layers… / ProEXR….
|
||||
//! Composition ▸ Save Frame As ▸ Photoshop Layers… / ProEXR…, and Copy Frame to Clipboard.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
@@ -121,3 +121,33 @@ 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")));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
//! 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/>", "<!--", "-->", "<?", "?>", "&", ";", "�",
|
||||
"�", "&#;", "&#x;", "&", "\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());
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
//! 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));
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user