Compare commits

..

No commits in common. "v0.9.2" and "ci-packaging-lint" have entirely different histories.

805 changed files with 7049 additions and 89581 deletions

View File

@ -1,32 +0,0 @@
# Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
name: FreeBSD
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every push
# or dependency change: a drifted dependency still shows up within a day, and always before a
# release ships. PRs that change the FreeBSD packaging itself still run it.
on:
schedule:
- cron: "47 2 * * *"
push:
branches: [release]
pull_request:
paths:
- .github/workflows/freebsd.yml
- packaging/freebsd/**
- packaging/env.sh
workflow_dispatch:
concurrency:
group: freebsd-${{ github.ref }}
# A push to main doesn't cancel the run in progress (the newest waiting run replaces older ones),
# so main gets complete runs however often it moves; PR pushes do cancel.
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
jobs:
freebsd:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main

View File

@ -1,6 +1,5 @@
# Quick static checks for the release pipeline (seconds, no Rust build): workflow syntax,
# packaging shell/PowerShell scripts, and the Linux desktop/AppStream/Flatpak metadata. Plus a
# Windows MSI install test with stub binaries (install folder, upgrade, repair, uninstall).
# packaging shell/PowerShell scripts, and the Linux desktop/AppStream/Flatpak metadata.
name: Packaging lint
on:
@ -20,5 +19,35 @@ concurrency:
jobs:
lint:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
runs-on: ubuntu-latest
env:
ACTIONLINT_VERSION: 1.7.12
steps:
- uses: actions/checkout@v4
- name: Install tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml
curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
| tar -xz -C "$RUNNER_TEMP" actionlint
- name: actionlint
run: |
"$RUNNER_TEMP/actionlint" -color
- name: shellcheck
run: shellcheck packaging/*.sh packaging/*/*.sh
- name: PowerShell
shell: pwsh
run: |
foreach ($f in Get-ChildItem packaging/windows/*.ps1) {
$errors = $null
[System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null
if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 }
Write-Output "ok $($f.Name)"
}
- name: XML, desktop entry, AppStream, Flatpak manifest
run: |
xmllint --noout packaging/windows/effectcraft.wxs packaging/macos/*.plist* packaging/linux/*.xml*
desktop-file-validate packaging/linux/ai.storyteller.effectcraft.desktop
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.effectcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.effectcraft.metainfo.xml"
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.effectcraft.metainfo.xml"
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.effectcraft.yml')); assert m['id'] == 'ai.storyteller.effectcraft' and m['command'] == 'effectcraft'"

View File

@ -1,3 +1,9 @@
# Release pipeline: every push to `release` builds signed installers for every platform and
# creates or updates the draft GitHub Release "EffectCraft v<version>". See docs/releasing.md.
#
# All jobs run in the `release` environment (restricted to the `release` branch), which holds
# the signing secrets. Every secret is optional: missing ones produce unsigned artifacts and a
# warning, never a failed build.
name: Release
on:
@ -11,32 +17,271 @@ on:
default: ""
permissions:
contents: write
contents: read
concurrency:
group: release-${{ github.ref }}
# Never cancel a run mid-notarization or mid-upload; a newer push waits its turn.
cancel-in-progress: false
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
RUST_BACKTRACE: 1
EFFECTCRAFT_BUILD_SHA: ${{ github.sha }}
INPUT_VERSION: ${{ inputs.version }}
jobs:
version:
name: version
runs-on: ubuntu-latest
environment: release
outputs:
version: ${{ steps.v.outputs.version }}
date: ${{ steps.v.outputs.date }}
prerelease: ${{ steps.v.outputs.prerelease }}
steps:
- uses: actions/checkout@v4
- id: v
name: Resolve version
run: |
. packaging/env.sh
v="${INPUT_VERSION#v}"
v="${v:-$VERSION}"
if ! printf '%s' "$v" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then
echo "::error::'$v' is not a version like 1.2.3 or 1.2.3-rc.1"
exit 1
fi
case "$v" in *-*) pre=true ;; *) pre=false ;; esac
{
echo "version=$v"
echo "date=$(date -u +%Y-%m-%d)"
echo "prerelease=$pre"
} >>"$GITHUB_OUTPUT"
echo "### EffectCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
macos:
name: macOS universal
needs: version
runs-on: macos-15
environment: release
env:
EFFECTCRAFT_VERSION: ${{ needs.version.outputs.version }}
EFFECTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin, x86_64-apple-darwin
- uses: Swatinem/rust-cache@v2
with:
key: release-macos-universal
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$EFFECTCRAFT_VERSION"
- name: Import signing certificate
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: packaging/macos/import-cert.sh
- name: Build, sign, notarize
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: packaging/macos/package.sh --arch universal
- uses: actions/upload-artifact@v4
with:
name: release-macos
path: dist/release/*
if-no-files-found: error
- name: Remove temporary keychain
if: always()
run: |
if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi
windows:
name: Windows ${{ matrix.arch }}
needs: version
runs-on: windows-latest
environment: release
strategy:
fail-fast: false
matrix:
include:
- { arch: x64, target: x86_64-pc-windows-msvc }
- { arch: x86, target: i686-pc-windows-msvc }
env:
EFFECTCRAFT_VERSION: ${{ needs.version.outputs.version }}
EFFECTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
with:
key: release-windows-${{ matrix.arch }}
- name: Apply version override
if: inputs.version != ''
shell: bash
run: cargo xtask version set "$EFFECTCRAFT_VERSION"
- name: Install WiX
shell: pwsh
run: dotnet tool install --global wix --version 5.0.2
- name: Build, sign, package
shell: pwsh
env:
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 }}
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
- uses: actions/upload-artifact@v4
with:
name: release-windows-${{ matrix.arch }}
path: dist/release/*
if-no-files-found: error
linux:
name: Linux ${{ matrix.arch }}
needs: version
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+,
# Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md.
runs-on: ${{ matrix.runner }}
environment: release
strategy:
fail-fast: false
matrix:
include:
- { arch: x86_64, runner: ubuntu-22.04, deb: amd64 }
- { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 }
env:
EFFECTCRAFT_VERSION: ${{ needs.version.outputs.version }}
EFFECTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
NFPM_VERSION: 2.47.0
steps:
- uses: actions/checkout@v4
- name: Install build and packaging tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev libasound2-dev \
desktop-file-utils appstream python3-yaml
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
key: release-linux-${{ matrix.arch }}
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$EFFECTCRAFT_VERSION"
- name: Build and package (AppImage, deb, rpm, tar.gz)
run: packaging/linux/package.sh
- name: Check the packages
run: |
dpkg-deb --info dist/release/*.deb
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/effectcraft$|metainfo|applications'
ldd target/release/effectcraft
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.effectcraft.yml')); assert m['id'] == 'ai.storyteller.effectcraft'"
for appimage in dist/release/*.AppImage; do
chmod +x "$appimage"
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
done
- uses: actions/upload-artifact@v4
with:
name: release-linux-${{ matrix.arch }}
path: dist/release/*
if-no-files-found: error
web:
name: Web (wasm)
needs: version
runs-on: ubuntu-latest
environment: release
env:
EFFECTCRAFT_VERSION: ${{ needs.version.outputs.version }}
EFFECTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
WASM_BINDGEN_VERSION: 0.2.129
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
with:
key: release-web
- name: Install wasm-bindgen CLI
run: |
# Must match the wasm-bindgen version in Cargo.lock exactly (cargo xtask web checks it).
if ! { command -v wasm-bindgen >/dev/null && wasm-bindgen --version | grep -q "$WASM_BINDGEN_VERSION"; }; then
cargo install wasm-bindgen-cli --version "$WASM_BINDGEN_VERSION" --locked
fi
wasm-bindgen --version
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$EFFECTCRAFT_VERSION"
- name: Build and zip
run: packaging/web/package.sh
- uses: actions/upload-artifact@v4
with:
name: release-web
path: dist/release/*
if-no-files-found: error
release:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
with:
app-name: effectcraft
app-title: EffectCraft
version: ${{ inputs.version }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
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 }}
name: Draft GitHub Release
needs: [version, macos, windows, linux, web]
runs-on: ubuntu-latest
environment: release
permissions:
contents: write
env:
VERSION: ${{ needs.version.outputs.version }}
PRERELEASE: ${{ needs.version.outputs.prerelease }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
steps:
- uses: actions/download-artifact@v4
with:
pattern: release-*
path: artifacts
merge-multiple: true
- name: Checksums
working-directory: artifacts
run: |
sha256sum -- * | tee SHA256SUMS.txt
- name: Create or update the draft release
working-directory: artifacts
run: |
tag="v$VERSION"
title="EffectCraft v$VERSION"
pre=()
if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi
if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then
if [ "$draft" != "true" ]; then
echo "::error::Release $tag is already published. Bump the version (cargo xtask version set) before pushing to release."
exit 1
fi
echo "Updating draft $tag: replacing its assets"
gh release view "$tag" --json assets --jq '.assets[].name' | while read -r asset; do
gh release delete-asset "$tag" "$asset" --yes
done
gh release edit "$tag" --draft --title "$title" --target "$GITHUB_SHA" --prerelease="$PRERELEASE"
gh release upload "$tag" --clobber -- *
else
echo "Creating draft $tag"
gh release create "$tag" --draft --title "$title" --target "$GITHUB_SHA" --generate-notes "${pre[@]}" -- *
fi
{
echo "Draft release **$title** at \`${GITHUB_SHA::9}\`:"
echo
sed 's/^/ /' SHA256SUMS.txt
} >>"$GITHUB_STEP_SUMMARY"

View File

@ -1,35 +0,0 @@
# Windows on ARM: package the ARM64 release the way release.yml does (cross-compiled on an x64
# runner, unsigned here), then install that MSI on a Windows 11 ARM64 runner, check the installed
# binaries are ARM64, run the CLI natively, and uninstall. (A native ARM64 `cargo test` job is left
# out for now: PdfCraft saw an intermittent access violation in egui UI tests there.)
name: Windows ARM64
# Nightly on main and on pushes to `release` (dependency and runner-image drift shows up within a
# day, and always before a release ships), and on PRs that change the Windows packaging itself.
on:
schedule:
- cron: "13 5 * * *"
push:
branches: [release]
pull_request:
paths:
- .github/workflows/windows-arm64.yml
- packaging/windows/**
workflow_dispatch:
concurrency:
group: windows-arm64-${{ github.ref }}
# A push to main doesn't cancel the run in progress (the newest waiting run replaces older ones),
# so main gets complete runs however often it moves; PR pushes do cancel.
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
jobs:
windows-arm64:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
with:
app-name: effectcraft
app-dir: EffectCraft

2
.gitignore vendored
View File

@ -1,6 +1,6 @@
*~
plan/
target/
/target
/dist
/log
.DS_Store

View File

@ -105,40 +105,11 @@ reproduce an image (for example, point lists traced from someone else's icon).
See `CLAUDE.md`: pure Rust, dependency layering (`cargo xtask layers`), exact `Tick` time, everything is
a command, everything is agent-drivable, and the quality gates (`cargo xtask ci`) before every commit.
Shared real-file test corpora (Photoshop-authored PSDs, etc.) live in
[`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in
[craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md).
Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified,
as PhotoCraft does with `cargo xtask corpus`.
## See also
- [ROADMAP.md](ROADMAP.md): stage, headline numbers and what's next
- [docs/gaps.md](docs/gaps.md): every known shortfall against After Effects, ranked, and the prioritised workstreams (G1–G9); read it before choosing work
- [docs/target-app-parity.md](docs/target-app-parity.md): the parity numbers and how they were measured, with the per-area docs it links (UI, file formats, hardware, localization, effects, scripting); progress docs follow [craftrules `standards/progress-docs.md`](https://github.com/storytold/craftrules/blob/main/standards/progress-docs.md)
- [CONTRIBUTING.md](CONTRIBUTING.md): setup, gates, commits, how to add things
- [docs/architecture.md](docs/architecture.md): layers, data model, commands, pipeline
- [docs/testing.md](docs/testing.md): oracle tests, criteria, benchmarks
- [docs/agents.md](docs/agents.md): driving EffectCraft over MCP / the control channel, and the agent work loop
- [docs/control-protocol.md](docs/control-protocol.md): control-channel method reference
- [docs/contributors.md](docs/contributors.md): the About window's contributor and model credits
- [ATTRIBUTION.md](ATTRIBUTION.md): asset index
## Contributor credits (About window)
- About ▸ Contributors/Models are compiled into the binary from `contributors/contributors.json`
(commit stats; generated, never hand-edit) and `contributors/people.toml` (names people chose for
themselves). See `docs/contributors.md`.
- **Agents working for a contributor:** when you prepare a PR, check whether your human's GitHub
username has a `[people.<username>]` entry in `contributors/people.toml`. If not, ask them once
whether they want to be credited by more than their username: a real name, a display name, and/or
their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry
(copy the template at the top of the file, or run
`python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`)
and include it in their PR, committed as them. If no, change nothing: they are credited as
`@username` anyway.
- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
GitHub profiles). Never hand-edit `contributors.json`.
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
re-verifies who wrote each `people.toml` entry; `--check` only verifies).

View File

@ -17,7 +17,6 @@ iconography, images or other Adobe assets; open licences only; every asset attri
| `assets/fonts/NotoSerif-Regular.ttf` | The Noto Project Authors | https://github.com/notofonts/latin-greek-cyrillic | OFL-1.1 (`assets/fonts/OFL-NotoSerif.txt`) |
| `assets/fonts/JetBrainsMono-Regular.ttf` | The JetBrains Mono Project Authors | https://github.com/JetBrains/JetBrainsMono | OFL-1.1 (`assets/fonts/OFL-JetBrainsMono.txt`) |
| `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`) |
@ -58,51 +57,19 @@ Screenshots of EffectCraft itself, rendered headlessly, showing only procedurall
## Code-drawn assets (original work, MIT OR Apache-2.0)
- UI icons drawn in code: `crates/ui-egui/src/icons.rs`, including the original filled favorite star (2026-10-11, EffectCraft contributors).
- UI icons drawn in code: `crates/ui-egui/src/icons.rs`.
- Procedural demo content (solids, shapes, gradients, text animations): `crates/engine/src/demo.rs`.
- Built-in project templates (Lower Third, Title Card, Logo Reveal, Kinetic Type, Social Square, Vertical Story,
Slideshow, 3D Text Orbit) built from engine commands, and their thumbnails rendered by our own renderer:
`crates/engine/src/templates.rs`.
- Text animator presets (Typewriter, Fade Up Characters, Bounce In Words, Tracking In, Scramble, Blur In, Jitter,
Drop In Lines): `crates/engine/presets/text_animators.json` (sidecar `.attribution`).
- Transition animation presets (23: wipes, clock and fan reveals, blinds, dissolves, card flips, CC wipes, fades):
`crates/engine/presets/transitions.json` (sidecar `.attribution`).
- Sample scripts (File ▸ Scripts) and ScriptUI panel: `crates/engine/scripts/Rename Layers.jsx`,
`crates/engine/scripts/Sort Layers by In Point.jsx`, `crates/engine/scripts/Create Null at Selected Layers.jsx`,
`crates/engine/scripts/Render Queue Batch.jsx`, `crates/engine/scripts/ScriptUI Panels/Layer Tools.jsx`
(sidecars `.attribution`).
- Bundled extension (ScriptUI panel) with its twelve built-in ease presets:
`extensions/scriptui-panels/Ease Presets.jsx` (sidecar `.attribution`).
- Example effect plug-in: `examples/plugins/posterize-bands` (plug-in API v1, WebAssembly).
## Trained models (optional downloads, not bundled)
The repository and the installers contain no model weights. These models are downloaded only when
a user asks (Settings ▸ Roto Brush, Settings ▸ Face Tracking, or the `roto.model.*` /
`face.model.*` commands), unmodified from their authors' official location, checked against a
pinned SHA-256 and stored in the user's `models` folder with a `<file>.NOTICE.txt` naming their
authors, licence and source. EffectCraft runs them with its own Rust implementation and is not
affiliated with or endorsed by their authors.
| Model | Authors | Source | Licence |
|---|---|---|---|
| MobileSAM (`mobile_sam.pt`) | Chaoning Zhang et al., building on Meta AI's Segment Anything and Microsoft's TinyViT | https://github.com/ChaoningZhang/MobileSAM | Apache-2.0 |
| MediaPipe Face Landmarker (`face_landmarker.task`: BlazeFace short-range detector, Face Mesh V2, blendshapes) | Google LLC | https://developers.google.com/edge/mediapipe/solutions/vision/face_landmarker (model cards: https://storage.googleapis.com/mediapipe-assets/MediaPipe%20BlazeFace%20Model%20Card%20(Short%20Range).pdf, https://storage.googleapis.com/mediapipe-assets/Model%20Card%20MediaPipe%20Face%20Mesh%20V2.pdf) | Apache-2.0 |
## Formats and parameters from other open-source projects
Written in Rust for EffectCraft; no code is copied. They follow published specifications and
configuration from these Apache-2.0 projects:
- `crates/segment/src/tflite.rs` reads TensorFlow Lite files using the field layout of TensorFlow
Lite's `schema.fbs` (The TensorFlow Authors, https://github.com/tensorflow/tensorflow,
Apache-2.0).
- `crates/segment/src/mediapipe.rs` follows MediaPipe's published face landmarker graph: the SSD
anchor layout, detection decoding, crop sizes and the face-mesh point indices it uses (Google
LLC, https://github.com/google-ai-edge/mediapipe, Apache-2.0).
- `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
@ -120,5 +87,3 @@ under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt). Forks and
| `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).

View File

@ -1,13 +1,12 @@
# EffectCraft — instructions for agents
EffectCraft is a clean-room, open-source, pure-Rust motion graphics and visual effects compositor targeting Adobe After Effects parity (and beyond). Native on macOS, Windows, Linux; web via WASM. Sibling of `../photocraft` (Photoshop), `../pdfcraft` (Acrobat), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom) and `../designcraft` (InDesign), with the same conventions.
EffectCraft is a clean-room, open-source, pure-Rust motion graphics and visual effects compositor targeting Adobe After Effects parity (and beyond). Native on macOS, Windows, Linux; web via WASM. Sibling of `../photocraft` (Photoshop), `../printcraft` (Acrobat), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom) and `../designcraft` (InDesign), with the same conventions.
## Start every session here
`plan/` is maintainer-local (gitignored). Public equivalents: [`ROADMAP.md`](ROADMAP.md), [`docs/`](docs/).
1. Read `plan/STATUS.md` (current milestone, next task, running agents, blockers).
2. Read the task in `plan/execution-plan.md` §3, the relevant section of `plan/architecture.md`, and the crate README/docs you touch. Feature ids: `plan/aftereffects/feature-catalog.md`; effects: `plan/aftereffects/effects-list.md`; UI reference: `plan/aftereffects/README.md`.
3. Read [`docs/gaps.md`](docs/gaps.md): every known shortfall, ranked, and the prioritised workstreams; the numbers are in [`docs/target-app-parity.md`](docs/target-app-parity.md). Fidelity, reliability and stability (G1–G3) come before new features.
4. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask.
3. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask.
## Non-negotiables
**Read [`AGENTS.md`](AGENTS.md) first; its rules override everything here.** No Adobe assets, every asset licensed + attributed (`cargo xtask assets`).

View File

@ -8,7 +8,7 @@ work apply to everyone, people and AI agents alike, and they are not negotiable.
You need [Rust](https://rustup.rs/) 1.95 or newer.
```sh
cargo run -p effectcraft -- --demo # the desktop app, with the demo project
cargo run -p effectcraft # the desktop app, with the demo project
cargo test --workspace
cargo xtask ci # what every commit must pass
```

220
Cargo.lock generated
View File

@ -962,15 +962,6 @@ dependencies = [
"libc",
]
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675"
dependencies = [
"egui",
"serde",
]
[[package]]
name = "crc32fast"
version = "1.5.2"
@ -1143,7 +1134,7 @@ dependencies = [
[[package]]
name = "effectcraft"
version = "0.9.2"
version = "0.2.0"
dependencies = [
"cpal",
"effectcraft-engine",
@ -1151,24 +1142,20 @@ dependencies = [
"effectcraft-ui-egui",
"eframe",
"egui",
"fmv-macos-events",
"log",
"muda",
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"pollster 0.4.0",
"rfd",
"serde",
"serde_json",
"winit",
"winresource",
"zbus",
]
[[package]]
name = "effectcraft-automation"
version = "0.9.2"
version = "0.2.0"
dependencies = [
"effectcraft-engine",
"effectcraft-host",
@ -1180,14 +1167,14 @@ dependencies = [
[[package]]
name = "effectcraft-av1enc"
version = "0.9.2"
version = "0.2.0"
dependencies = [
"filmcraft-av1",
]
[[package]]
name = "effectcraft-cli"
version = "0.9.2"
version = "0.2.0"
dependencies = [
"cpu-time",
"effectcraft-automation",
@ -1203,7 +1190,7 @@ dependencies = [
[[package]]
name = "effectcraft-color"
version = "0.9.2"
version = "0.2.0"
dependencies = [
"proptest",
"serde",
@ -1211,14 +1198,13 @@ dependencies = [
[[package]]
name = "effectcraft-effects"
version = "0.9.2"
version = "0.2.0"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
"effectcraft-keyframe",
"effectcraft-project",
"effectcraft-raster",
"effectcraft-segment",
"effectcraft-track",
"log",
"rayon",
@ -1228,7 +1214,7 @@ dependencies = [
[[package]]
name = "effectcraft-engine"
version = "0.9.2"
version = "0.2.0"
dependencies = [
"effectcraft-color",
"effectcraft-effects",
@ -1243,7 +1229,6 @@ dependencies = [
"effectcraft-psd",
"effectcraft-raster",
"effectcraft-render",
"effectcraft-segment",
"effectcraft-svg",
"effectcraft-text",
"effectcraft-time",
@ -1261,11 +1246,10 @@ dependencies = [
[[package]]
name = "effectcraft-export"
version = "0.9.2"
version = "0.2.0"
dependencies = [
"effectcraft-av1enc",
"effectcraft-color",
"effectcraft-effects",
"effectcraft-hevcenc",
"effectcraft-keyframe",
"effectcraft-media",
@ -1291,7 +1275,7 @@ dependencies = [
[[package]]
name = "effectcraft-expr"
version = "0.9.2"
version = "0.2.0"
dependencies = [
"boa_engine",
"effectcraft-color",
@ -1308,7 +1292,7 @@ dependencies = [
[[package]]
name = "effectcraft-geom"
version = "0.9.2"
version = "0.2.0"
dependencies = [
"proptest",
"serde",
@ -1316,7 +1300,7 @@ dependencies = [
[[package]]
name = "effectcraft-gpu"
version = "0.9.2"
version = "0.2.0"
dependencies = [
"bytemuck",
"effectcraft-color",
@ -1328,24 +1312,21 @@ dependencies = [
"effectcraft-render",
"effectcraft-text",
"effectcraft-time",
"js-sys",
"log",
"pollster 0.4.0",
"wasm-bindgen",
"web-time",
"wgpu",
]
[[package]]
name = "effectcraft-hevcenc"
version = "0.9.2"
version = "0.2.0"
dependencies = [
"filmcraft-hevc",
]
[[package]]
name = "effectcraft-host"
version = "0.9.2"
version = "0.2.0"
dependencies = [
"effectcraft-engine",
"effectcraft-export",
@ -1360,8 +1341,6 @@ dependencies = [
"effectcraft-script",
"effectcraft-svg",
"effectcraft-time",
"exr",
"gif",
"image",
"rayon",
"serde_json",
@ -1369,7 +1348,7 @@ dependencies = [
[[package]]
name = "effectcraft-interchange"
version = "0.9.2"
version = "0.2.0"
dependencies = [
"effectcraft-color",
"effectcraft-keyframe",
@ -1388,7 +1367,7 @@ dependencies = [
[[package]]
name = "effectcraft-keyframe"
version = "0.9.2"
version = "0.2.0"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@ -1400,7 +1379,7 @@ dependencies = [
[[package]]
name = "effectcraft-lottie"
version = "0.9.2"
version = "0.2.0"
dependencies = [
"effectcraft-color",
"effectcraft-effects",
@ -1417,7 +1396,7 @@ dependencies = [
[[package]]
name = "effectcraft-media"
version = "0.9.2"
version = "0.2.0"
dependencies = [
"effectcraft-model",
"effectcraft-pdf",
@ -1431,10 +1410,8 @@ dependencies = [
"filmcraft-codecs",
"filmcraft-color",
"filmcraft-frame",
"filmcraft-isobmff",
"filmcraft-media",
"filmcraft-time",
"gif",
"image",
"log",
"rayon",
@ -1444,11 +1421,10 @@ dependencies = [
[[package]]
name = "effectcraft-model"
version = "0.9.2"
version = "0.2.0"
dependencies = [
"effectcraft-geom",
"image",
"miniz_oxide 0.8.9",
"serde",
"serde_json",
"thiserror 2.0.21",
@ -1456,14 +1432,14 @@ dependencies = [
[[package]]
name = "effectcraft-opusenc"
version = "0.9.2"
version = "0.2.0"
dependencies = [
"filmcraft-opus",
]
[[package]]
name = "effectcraft-path"
version = "0.9.2"
version = "0.2.0"
dependencies = [
"effectcraft-geom",
"effectcraft-keyframe",
@ -1475,7 +1451,7 @@ dependencies = [
[[package]]
name = "effectcraft-pdf"
version = "0.9.2"
version = "0.2.0"
dependencies = [
"effectcraft-raster",
"effectcraft-svg",
@ -1489,7 +1465,7 @@ dependencies = [
[[package]]
name = "effectcraft-plugin"
version = "0.9.2"
version = "0.2.0"
dependencies = [
"effectcraft-effects",
"effectcraft-keyframe",
@ -1501,7 +1477,7 @@ dependencies = [
[[package]]
name = "effectcraft-project"
version = "0.9.2"
version = "0.2.0"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@ -1514,14 +1490,14 @@ dependencies = [
[[package]]
name = "effectcraft-psd"
version = "0.9.2"
version = "0.2.0"
dependencies = [
"miniz_oxide 0.8.9",
]
[[package]]
name = "effectcraft-raster"
version = "0.9.2"
version = "0.2.0"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@ -1530,7 +1506,7 @@ dependencies = [
[[package]]
name = "effectcraft-render"
version = "0.9.2"
version = "0.2.0"
dependencies = [
"effectcraft-color",
"effectcraft-effects",
@ -1552,7 +1528,7 @@ dependencies = [
[[package]]
name = "effectcraft-script"
version = "0.9.2"
version = "0.2.0"
dependencies = [
"boa_engine",
"effectcraft-engine",
@ -1560,25 +1536,14 @@ dependencies = [
"effectcraft-keyframe",
"effectcraft-path",
"effectcraft-project",
"effectcraft-psd",
"effectcraft-render",
"effectcraft-time",
"serde_json",
"web-time",
]
[[package]]
name = "effectcraft-segment"
version = "0.9.2"
dependencies = [
"miniz_oxide 0.8.9",
"ndarray",
"rayon",
]
[[package]]
name = "effectcraft-svg"
version = "0.9.2"
version = "0.2.0"
dependencies = [
"effectcraft-geom",
"effectcraft-path",
@ -1590,7 +1555,7 @@ dependencies = [
[[package]]
name = "effectcraft-text"
version = "0.9.2"
version = "0.2.0"
dependencies = [
"effectcraft-keyframe",
"harfrust",
@ -1602,7 +1567,7 @@ dependencies = [
[[package]]
name = "effectcraft-time"
version = "0.9.2"
version = "0.2.0"
dependencies = [
"proptest",
"serde",
@ -1610,10 +1575,9 @@ dependencies = [
[[package]]
name = "effectcraft-track"
version = "0.9.2"
version = "0.2.0"
dependencies = [
"effectcraft-raster",
"effectcraft-segment",
"rayon",
"serde",
"serde_json",
@ -1622,9 +1586,8 @@ dependencies = [
[[package]]
name = "effectcraft-ui-egui"
version = "0.9.2"
version = "0.2.0"
dependencies = [
"craft-ui",
"effectcraft-color",
"effectcraft-engine",
"effectcraft-geom",
@ -1639,7 +1602,6 @@ dependencies = [
"eframe",
"egui",
"egui_kittest",
"exr",
"image",
"kurbo",
"log",
@ -1647,18 +1609,16 @@ dependencies = [
"rayon",
"serde",
"serde_json",
"sys-locale",
"toml",
"web-time",
]
[[package]]
name = "effectcraft-vp9enc"
version = "0.9.2"
version = "0.2.0"
[[package]]
name = "effectcraft-web"
version = "0.9.2"
version = "0.2.0"
dependencies = [
"effectcraft-engine",
"effectcraft-export",
@ -1772,7 +1732,6 @@ dependencies = [
"raw-window-handle",
"smithay-clipboard",
"web-time",
"webbrowser",
"winit",
]
@ -2300,17 +2259,6 @@ dependencies = [
"zlib-rs",
]
[[package]]
name = "fmv-macos-events"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90642cf66dde7f205dc8b42dfe57b47d0f3a29f571b832742883ef512e23fe55"
dependencies = [
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
]
[[package]]
name = "fnv"
version = "1.0.7"
@ -2365,15 +2313,6 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
dependencies = [
"percent-encoding",
]
[[package]]
name = "futures-concurrency"
version = "7.7.1"
@ -2780,27 +2719,6 @@ dependencies = [
"zerovec",
]
[[package]]
name = "idna"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
dependencies = [
"idna_adapter",
"smallvec",
"utf8_iter",
]
[[package]]
name = "idna_adapter"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
dependencies = [
"icu_normalizer",
"icu_properties",
]
[[package]]
name = "image"
version = "0.25.10"
@ -3132,16 +3050,6 @@ dependencies = [
"libc",
]
[[package]]
name = "matrixmultiply"
version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7"
dependencies = [
"autocfg",
"rawpointer",
]
[[package]]
name = "memchr"
version = "2.8.3"
@ -3259,21 +3167,6 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "ndarray"
version = "0.17.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d"
dependencies = [
"matrixmultiply",
"num-complex",
"num-integer",
"num-traits",
"portable-atomic",
"portable-atomic-util",
"rawpointer",
]
[[package]]
name = "ndk"
version = "0.9.0"
@ -4276,12 +4169,6 @@ dependencies = [
"objc2-quartz-core 0.3.2",
]
[[package]]
name = "rawpointer"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
[[package]]
name = "rayon"
version = "1.12.0"
@ -4984,15 +4871,6 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "sys-locale"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
dependencies = [
"libc",
]
[[package]]
name = "tag_ptr"
version = "0.1.0"
@ -5309,18 +5187,6 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "url"
version = "2.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
dependencies = [
"form_urlencoded",
"idna",
"percent-encoding",
"serde",
]
[[package]]
name = "utf16_iter"
version = "1.0.5"
@ -5709,22 +5575,6 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "webbrowser"
version = "1.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94"
dependencies = [
"jni 0.22.4",
"log",
"ndk-context",
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"url",
"web-sys",
]
[[package]]
name = "weezl"
version = "0.1.12"
@ -6468,7 +6318,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.9.2"
version = "0.2.0"
dependencies = [
"serde_json",
]

View File

@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*"]
[workspace.package]
version = "0.9.2"
version = "0.2.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@ -22,7 +22,6 @@ effectcraft-project = { path = "crates/project" }
effectcraft-text = { path = "crates/text" }
effectcraft-effects = { path = "crates/effects" }
effectcraft-track = { path = "crates/track" }
effectcraft-segment = { path = "crates/segment" }
effectcraft-model = { path = "crates/model" }
effectcraft-render = { path = "crates/render" }
effectcraft-gpu = { path = "crates/gpu" }
@ -98,15 +97,11 @@ bytemuck = "1"
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"] }
egui = "0.36"
eframe = { version = "0.36", default-features = false }
egui_kittest = "0.36"
rfd = "0.17"
cpal = "0.16"
# The operating system's interface language (MIT/Apache-2.0, no dependencies on desktop).
sys-locale = "0.3"
# Local co-development against a FilmCraft checkout next to this repo: uncomment.
# [patch."https://github.com/storytold/filmcraft"]

131
README.md
View File

@ -56,7 +56,6 @@
<a href="#get-started">Get started</a> ·
<a href="#where-it-stands">Status</a> ·
<a href="#how-its-made">How it's made</a> ·
<a href="#downloads">Downloads</a> ·
<a href="#the-crafting-apps">The Crafting Apps</a> ·
<a href="#license-and-credits">License</a>
</p>
@ -86,9 +85,7 @@ then to go further in a few places where it matters to us:
The panels, menus and shortcuts follow After Effects, so your muscle memory carries over:
Project, Composition, Timeline, Effect Controls, Properties, Effects & Presets, Character,
Paragraph, Align, Info, Preview, Audio and the Render Queue, docked the way you expect. Drag
footage, comps and effects between them: onto the comp viewer, where they land under the pointer,
or into the Timeline, between layers and at the time you point to.
Paragraph, Align, Info, Preview, Audio and the Render Queue, docked the way you expect.
- **Layers of every kind:** solids, shapes, text, footage, nested compositions, nulls,
adjustment layers, cameras and lights; parenting, track mattes, all 38 blend modes, motion blur.
@ -100,8 +97,8 @@ or into the Timeline, between layers and at the time you point to.
- **Shapes and masks:** shape layers with trim paths, repeaters, round corners, offset, zig zag,
twist, wiggle, merge paths and gradient strokes; masks drawn with the pen tool, with modes,
feather, expansion and vertex editing in the viewer.
- **Text:** point and paragraph text with real shaping in any font installed on your machine, the
Character and Paragraph panels, and text animators with range selectors.
- **Text:** point and paragraph text with real shaping, the Character and Paragraph panels, and
text animators with range selectors.
- **Expressions:** JavaScript with the After Effects object model (`wiggle`, `loopOut`,
`thisComp.layer("…")`, vector maths on arrays), an inline editor and the pick-whip.
- **Timeline like you know it:** twirl layers open to Transform, masks, effects and the rest; drag
@ -182,64 +179,29 @@ See [docs/agents.md](docs/agents.md) and [docs/control-protocol.md](docs/control
## Get started
Installers for each version are on the [Releases](https://github.com/storytold/effectcraft/releases)
page: a universal macOS app; Windows MSIs and portable zips for x64, x86 and ARM64; Linux
AppImage, Flatpak, deb, rpm and tar.gz for x86_64 and aarch64, plus a riscv64 tar.gz; a FreeBSD x86_64 tarball; and the
web build. [Downloads](#downloads) lists every file. The Windows ARM64 build runs natively on
Windows on ARM, without x64 emulation. CI installs that MSI on Windows 11 ARM64 hardware, checks
both programs are ARM64 and runs the command-line tool there, but it doesn't open the app's window
or run the test suite natively on ARM64 yet, so please report anything that behaves differently.
On Gentoo, the community [::snakebyte overlay](https://github.com/switch87/snakebyte-overlay) packages
the Linux release as `media-video/effectcraft-bin` (not maintained by the EffectCraft team):
```sh
eselect repository add snakebyte git https://github.com/switch87/snakebyte-overlay.git
emaint sync -r snakebyte
echo 'media-video/effectcraft-bin ~amd64' >> /etc/portage/package.accept_keywords/effectcraft
emerge --ask media-video/effectcraft-bin
```
To build it yourself you need [Rust](https://rustup.rs/) 1.95 or newer.
You need [Rust](https://rustup.rs/) 1.95 or newer.
```sh
git clone https://github.com/storytold/effectcraft
cd effectcraft
cargo run --release -p effectcraft # the app (add `-- --demo` to open the demo project)
cargo run --release -p effectcraft # the app, with the demo project open
cargo run --release -p effectcraft-cli -- render --out intro.mp4 # render the demo headless
cargo xtask web --serve 8765 # the browser build on http://127.0.0.1:8765/ (docs/web.md)
```
New to it? [docs/footage.md](docs/footage.md) covers importing footage and image sequences and
Interpret Footage.
To work on it: `cargo test --workspace`, and `cargo xtask ci` before every commit (format, lints,
tests, layering, asset attribution and the WebAssembly build). See [CONTRIBUTING.md](CONTRIBUTING.md),
[docs/architecture.md](docs/architecture.md) and [docs/testing.md](docs/testing.md).
## Where it stands
EffectCraft is young: its first commit was on 1 October 2026. Nearly every After Effects feature
exists, including all of its effects, but existing is not the same as behaving exactly like After
Effects, and we haven't measured that yet. Today it's a good place to try things and to tell us
what breaks. It isn't yet a replacement for After Effects on client work. In particular:
- **After Effects projects can't be opened.** Projects are saved as `.ecproj`, readable versioned
JSON, but EffectCraft can't open `.aep` / `.aepx` files, and After Effects plug-ins don't run.
- **Behaviour still differs from After Effects in places.** Nothing yet compares our renders with
After Effects automatically, so please report differences.
- **macOS is the most tested platform.** Linux and Windows users have hit basic interaction
bugs, which we're fixing first.
- **The trained models are new.** Roto Brush can use MobileSAM (an optional 40 MB download in
Settings ▸ Roto Brush) and face tracking can use Google's MediaPipe Face Landmarker (an optional
3.8 MB download in Settings ▸ Face Tracking). Neither is compared with After Effects yet.
EffectCraft is in **alpha**: about 91% of After Effects' features exist, and we estimate it is
about 45% of the way to replacing After Effects for professional work. The [ROADMAP](ROADMAP.md)
has the plan, [docs/target-app-parity.md](docs/target-app-parity.md) the measurement and
[docs/gaps.md](docs/gaps.md) every known shortfall. The web build ([docs/web.md](docs/web.md)) runs the full app in the browser.
[Bug reports](https://github.com/storytold/effectcraft/issues) are the most useful thing you can
send us right now.
EffectCraft is young. The core of After Effects is in place and gets deeper every week: what is
done and what comes next is in the [ROADMAP](ROADMAP.md). Still to come: learned models for
Roto Brush and face tracking (both use classical methods today), GPU versions of the remaining
CPU-only effects, the Advanced 3D Extended Viewer, and shared-memory threads in the web build. The
web build ([docs/web.md](docs/web.md)) runs the full app in the browser, with renders, analyses
and viewer frames in Web Workers. Projects are saved as `.ecproj`, readable versioned JSON; After
Effects `.aep` files cannot be opened.
## How it's made
@ -252,69 +214,6 @@ send us right now.
- **One family.** EffectCraft shares its time model and text engine design with FilmCraft, and
gets its video and audio codecs from it.
## 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.
**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.
### Windows
| Build | Installer | Portable |
|---|---|---|
| x64 (64-bit Intel/AMD) | `effectcraft-<ver>-windows-x64.msi` | `effectcraft-<ver>-windows-x64-portable.zip` |
| arm64 (Snapdragon and other ARM PCs) | `effectcraft-<ver>-windows-arm64.msi` | `effectcraft-<ver>-windows-arm64-portable.zip` |
| x86 (32-bit) | `effectcraft-<ver>-windows-x86.msi` | `effectcraft-<ver>-windows-x86-portable.zip` |
Installers and executables are code-signed. The installer asks where to install EffectCraft
(Program Files by default), and later versions install into the same folder. To install silently
into a folder of your choice: `msiexec /i effectcraft-<ver>-windows-x64.msi /qn INSTALLFOLDER="D:\Apps\EffectCraft"`.
### macOS
| Build | File | Notes |
|---|---|---|
| App, universal (Apple silicon + Intel) | `effectcraft-<ver>-macos-universal.dmg` | Signed and notarized |
| Command-line tool, universal | `effectcraft-cli-<ver>-macos-universal.zip` | Signed and notarized |
### Linux
| Format | x86_64 | aarch64 (ARM64) | Notes |
|---|---|---|---|
| AppImage | `effectcraft-<ver>-linux-x86_64.AppImage` | `effectcraft-<ver>-linux-aarch64.AppImage` | Runs anywhere; updates itself with [AppImageUpdate](https://github.com/AppImageCommunity/AppImageUpdate) (`.zsync` files) |
| Flatpak | `effectcraft-<ver>-linux-x86_64.flatpak` | `effectcraft-<ver>-linux-aarch64.flatpak` | Sandboxed; `flatpak install --user <file>` |
| Debian/Ubuntu | `effectcraft-<ver>-linux-x86_64.deb` | `effectcraft-<ver>-linux-aarch64.deb` | |
| Fedora/RHEL/openSUSE | `effectcraft-<ver>-linux-x86_64.rpm` | `effectcraft-<ver>-linux-aarch64.rpm` | |
| Tarball | `effectcraft-<ver>-linux-x86_64.tar.gz` | `effectcraft-<ver>-linux-aarch64.tar.gz` | Unpack anywhere |
RISC-V 64-bit: `effectcraft-<ver>-linux-riscv64.tar.gz` (tarball only; needs glibc 2.39+, e.g. Ubuntu 24.04+ or Debian 13+).
**File drag-and-drop on Linux:** The current eframe/winit 0.30 native Wayland backend does not
deliver file-hover or file-drop events. On Hyprland and other native Wayland sessions, use
**File ▸ Import ▸ File…** (which accepts multiple files), or start EffectCraft via XWayland if
XWayland is installed and `DISPLAY` is set:
```sh
env -u WAYLAND_DISPLAY effectcraft
```
When the backend delivers events (X11/XWayland, Windows and macOS), dropping media anywhere
in the window imports it, and a drop-target hint appears while hovering. On native Wayland the
app cannot detect an attempt that generates no event; it shows a startup notice with the
File ▸ Import fallback instead.
### FreeBSD
| Build | File |
|---|---|
| x86_64 | `effectcraft-<ver>-freebsd-x86_64.tar.gz` |
### Web (WebAssembly)
| Build | File | Notes |
|---|---|---|
| Static site | `effectcraft-web-<ver>.zip` | Runs in a modern browser; host it on any static server |
## The Crafting Apps
EffectCraft is one of the **Crafting Apps**: free, open-source creative tools from the
@ -327,7 +226,7 @@ stand on its own.
| <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/printcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.printcraft.png" alt="" width="32" height="32"> | **PrintCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/printcraft) | [Website](https://getartcraft.com/apps/printcraft) |
| <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) |
@ -374,7 +273,3 @@ Forks and modified versions must remove them.
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
</p>
## Star history
[![Star History Chart](https://api.star-history.com/svg?repos=storytold/effectcraft&type=Date&legend=top-left)](https://www.star-history.com/?repos=storytold%2Feffectcraft&type=date&legend=top-left)

View File

@ -1,155 +1,38 @@
# EffectCraft Roadmap
**Stage: alpha** · next: beta, ~30 points of "ready for real work" and ~350–620 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (full re-measure against After Effects 2026 26.5; standard progress-docs layout) · **Target:** Adobe After Effects 2026 (26.5.0.89)
# Roadmap
EffectCraft aims to do what After Effects does, with the same panels, menus and behaviour, written
from scratch in Rust. This page is the one-page summary; the evidence is in
[docs/target-app-parity.md](docs/target-app-parity.md), the work list in
[docs/gaps.md](docs/gaps.md), and the plan in [docs/roadmap.md](docs/roadmap.md).
from scratch in Rust. This is where it stands. The milestones overlap; several are worked on at
once.
## Headline numbers
| | Value | Kind |
| | Milestone | State |
|---|---|---|
| **Feature breadth** | **≈ 91%** | Estimated, weighted by area; the self-graded 92-item checklist says ≈ 99% |
| **Ready for real work** | **≈ 45%** (40–50%) | Estimated; one live fidelity measurement so far |
| **Mainstream practitioner** | **≈ 47%** | Estimated: the typical motion designer's weekly work ([how](docs/target-app-parity.md#mainstream-practitioner-and-essentials-user)) |
| **Essentials user** | **≈ 52%** | Estimated: core features only ([how](docs/target-app-parity.md#essentials-user)) |
| Remaining to **beta** | **≈ 350–620 Opus 5.5 agent-hours** | Estimated |
| Remaining to **full parity** | **≈ 850–1,500 Opus 5.5 agent-hours** | Estimated; ≈ 70% parallelises |
| M0 | Skeleton: crates, compositor, the After Effects style shell, control channel, `cargo xtask` | Done (web build: [docs/web.md](docs/web.md)) |
| M1 | Keyframes: temporal and spatial interpolation, Easy Ease, roving, velocity | Done |
| M2 | Compositing: 38 blend modes, track mattes, parenting, adjustment layers | Done |
| M3 | Project operations: settings dialogs, layer commands, `.ecproj`, undo, After Effects menu bar | Done (unsaved-changes prompts, Solid / Layer Settings, lock-aware layer commands, safe project files; View ▸ New Viewer still opens only the one locked viewer) |
| M4 | Preview: precomps, motion blur, cached playback | Mostly done (Preserve frame rate / resolution when nested, one motion blur gate, RAM preview fixes, Cache Frames When Idle, motion blur of collapsed precomps and of animated shape / text content; left: content-keyed RAM preview frames) |
| M5 | Timeline depth: graph editor, keyframe clipboard and dialogs, time remapping, pick-whips, expression editor | Done |
| M6 | Shapes, masks and footage: shape operators, masks and the pen tool, video and image import | Done |
| M7 | 3D: 3D layers, cameras, lights, shadows, depth of field, 3D views and camera tools | In review |
| M8 | Expressions with the After Effects object model | Done |
| M9 | Text and effects: text animators, layer styles, 306 effects (all 298 of After Effects') incl. time and audio effects | Done (range, wiggly and expression selectors, per-character 3D, text on a path) |
| M10 | Export: render queue, H.264, ProRes, image sequences, GIF, audio | Done |
| M11 | Animation tools: audio playback, meters and waveforms, Lottie import and export, presets, Motion Sketch, Wiggler, Smoother | Done |
| M12 | Performance: layer cache, parallel and GPU compositing, disk cache, motion tracking (done); GPU versions of the remaining CPU-only effects | Mostly done |
| M13 | Puppet tools, paint, Roto Brush, motion tracking, a plug-in API, Timeline depth | Done (Roto Brush and face tracking use classical models; learned models are still to come) |
| M15 | The web app (WebAssembly, WebGPU) | Done: browser storage, Web Audio, renders and analyses in Web Workers, viewer frames and GPU effects in frame workers with their own WebGPU devices, the disk cache in the Origin Private File System, a storage manager, offline install ([docs/web.md](docs/web.md)) |
| M14 | Built for agents: MCP server, command-line tool, control channel; Settings, keyboard shortcut editor, auto-save and crash recovery ([docs/preferences.md](docs/preferences.md)) | Done |
| Audience | Ready % | Hours to ~95% | Work that dominates |
|---|---:|---:|---|
| Full target | ≈ 45% | ≈ 850–1,500 h | `.aep`, fidelity corpus, bug backlog, performance, formats, hardware, localization |
| Mainstream practitioner | ≈ 47% | ≈ 280–530 h | `.aep` exchange, stability, everyday fidelity and bugs, preview speed |
| Essentials user | ≈ 52% | ≈ 150–300 h | Opening tutorial `.aep` files, start-up stability, core bugs, preview with sound |
## How far from full parity
About 60–70% of these hours parallelise ([detail](docs/target-app-parity.md#readiness-by-audience)).
Measured feature by feature in [docs/parity.md](docs/parity.md) (4 October 2026):
Hours are one Opus 5.5 agent working sequentially, calibrated on this repository's history
([how](docs/target-app-parity.md#calibration-of-the-hours)).
### Stage
**Alpha**, because all six core workflows of the [alpha gate](docs/roadmap.md#alpha-gate)
(set up, animate, text and shapes, composite, preview, save and deliver) work end to end on macOS
and the work saves and reopens, and ready for real work (≈ 45%) is above the ≈ 40% bar. But: After Effects projects (`.aep`) can't be opened, which
alone rules out beta; fidelity against After Effects is measured for one narrow case (scalar ease,
168 samples); 134 issues are open, eight of them crash reports. Beta needs ≈ 75% ready and `.aep`
opening reliably: the `.aep` owner decision and importer (80–160 h), the fidelity harness and
P0/P1 corpus (60–100 h), the crash and bug backlog (40–80 h), performance (40–70 h),
Linux/Windows reliability (30–50 h) and depth in animation, masks, effects and text (100–160 h).
## By dimension
| Dimension | % | Hours | Doc |
|---|---:|---:|---|
| Features: breadth | 91% | — | [target-app-parity.md](docs/target-app-parity.md#by-feature-area) |
| Features: ready for real work | 50% | 360–660 | [target-app-parity.md](docs/target-app-parity.md#by-feature-area) |
| UI/UX fidelity | 65% | 60–120 | [ui-parity.md](docs/ui-parity.md) |
| File formats | 50% | 130–230 | [file-format-parity.md](docs/file-format-parity.md) |
| Hardware | 40% | 60–120 | [hardware-parity.md](docs/hardware-parity.md) |
| Localization | 12% | 60–110 + review | [localization-parity.md](docs/localization-parity.md) |
| Performance | 35% | 50–100 | [gaps.md](docs/gaps.md#g5-performance-at-real-world-scale) |
| Stability | 45% | 40–80 | [gaps.md](docs/gaps.md#g3-stability-and-a-green-main) |
| Platforms | 65% | 30–60 | [gaps.md](docs/gaps.md#g2-real-user-reliability-on-every-platform) |
| Ecosystem and plug-ins | 20% | 40–80 | [plugins.md](docs/plugins.md), [scripting-parity.md](docs/scripting-parity.md) |
| AI features | 35% | 40–80 | [gaps.md](docs/gaps.md) |
| Agent control (beyond After Effects) | ahead | — | [agents.md](docs/agents.md) |
## Features
| Area | Breadth | Ready | Hours |
|---|---:|---:|---:|
| Project, footage and import | 92% | 55% | 25–45 |
| Compositions, layers and compositing | 98% | 60% | 30–55 |
| Animation: keyframes, graph editor, motion paths, puppet | 97% | 55% | 35–60 |
| Expressions and scripting ([detail](docs/scripting-parity.md)) | 93% | 65% | 20–40 |
| Shapes, masks and roto | 93% | 45% | 30–50 |
| Text and typography | 94% | 50% | 20–35 |
| Effects and animation presets ([detail](docs/effects-parity.md)) | 85% | 40% | 70–130 |
| 3D (Classic and Advanced) | 82% | 35% | 35–65 |
| Tracking and AI-assisted tools | 80% | 35% | 35–70 |
| Preview and cache | 92% | 40% | 25–50 |
| Render queue and export | 85% | 55% | 25–45 |
| Audio | 85% | 45% | 6–12 |
Weights and evidence per row: [target-app-parity.md](docs/target-app-parity.md#by-feature-area).
## Languages
UI strings translated out of ≈ 3,900 (detail: [localization-parity.md](docs/localization-parity.md)).
| Language | Status | % |
|---|---|---:|
| English | full | 100% |
| Simplified Chinese | partial (menus, part of the panels) | 28% |
| Spanish | menus only | 16% |
| Hindi | none | 0% |
| Arabic | none | 0% |
| French | none | 0% |
| Portuguese (Brazil) | menus only | 16% |
| Indonesian | none | 0% |
| Japanese | partial (menus, part of the panels) | 26% |
| German | none | 0% |
| Korean | none | 0% |
| Vietnamese | none | 0% |
Also shipped: Traditional Chinese (partial, 28%) and Ukrainian (partial, 29%). No language has had
native-speaker review.
## Upcoming
Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gaps.md).
| | Next | Hours |
|---|---|---:|
| 1 | Fix the open crash reports; run `cargo xtask ci` on every pull request (G3) | 15–30 |
| 2 | Fidelity harness against After Effects: rendered frames, a corpus case per P0/P1 feature (G1) | 60–100 |
| 3 | Real-user reliability on Linux and Windows; the open mask, effect, text and 3D bugs (G2) | 100–190 |
| 4 | `.aep` / `.aepx` import, after the owner's clean-room decision (G4) | 80–160 |
| 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 |
- **≈ 99% of After Effects' features, weighted by importance**, counting partial features as half
done (≈ 99.8% with per-feature fractions): 89 of 92 features done, 3 partial, none missing; every
essential (P0) feature done; all 306 effects implemented in full, 280 of them on the GPU.
- **≈ 8.5 agent-hours of work remain**: about **2–3 hours of wall-clock time** with five Claude
Opus 5.5 agents in parallel, ≈ 1 hour without learned models for Roto Brush and face tracking.
- What is left: GPU kernels for the remaining visual effects, and learned-model quality for Roto
Brush and face tracking; in previews, content-keyed RAM preview frames ([parity.md](docs/parity.md#highest-value-gaps-in-order)).
Come tell us what matters most to you on [Discord](https://discord.gg/artcraft).
## 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
`docs/roadmap.md`, `localization-parity.md`, `file-format-parity.md`, `hardware-parity.md`,
`ui-parity.md`, `effects-parity.md` and `scripting-parity.md`.
- 2026-10-10: Spanish (#460) and Brazilian Portuguese (#217) menu translations.
- 2026-10-10: Japanese now translates the interface below the menu bar too: `crates/ui-egui/src/i18n/ui.rs` has a Japanese table with the same 396 rows as the Chinese ones (panels, dialogs, buttons and tooltips; the shape test keeps the three catalogs listing the same source strings). Menus were already Japanese.
- 2026-10-09: Ukrainian now covers all 396 current panel-catalog strings, including dialogs, buttons and tooltips, alongside its menu translation. The catalog keeps the same source keys and placeholder counts as the Chinese catalogs; missing or engine-generated text still falls back to English.
- UI.6: Viewer breadcrumbs use theme colors for readable active text. The bottom control row scrolls horizontally at compact widths; keyboard focus reveals the selected control while dropdowns, exposure adjustments and snapshot holds retain their behavior.
- UI.3: Shared docking transactions and pointer targets now handle panel moves, splits, tab ordering and floating groups, preserving accordion metadata and the saved workspace schema. Dock dividers use bounded shared geometry and keyboard-accessible resize handles. Tiny layouts no longer reach an invalid floating-point clamp; workspace trees and saved layouts are retained.
- 2026-10-09: Settings ▸ General ▸ Language now translates the interface below the menu bar as well: panels, dialogs, buttons and tooltips read their text through `crates/ui-egui/src/i18n/ui.rs`, keyed by the English source string. Simplified and Traditional Chinese are complete (384 strings); Japanese rows can follow in the same file. A test fails when a converted panel gains a hard-coded string, and another keeps the catalog free of unused rows.
- 2026-10-08: `effectcraft-cli render --out` resolves a relative path against the working directory, like `--project` (it used the project's folder, which doubled a path that already named it). Render Queue outputs set in a project are unchanged.
- 2026-10-08: Long-press a grouped toolbar button to choose its tools, including Horizontal/Vertical Type; releasing the hold keeps the menu open. Existing point/paragraph text creation is unchanged.
- 2026-10-06: Settings ▸ General ▸ Language persists English/Japanese menu labels (`general.language`); native UI reuses installed Japanese font fallback, with no bundled CJK font. Dialog and panel contents remain English.
- 2026-10-05: Honest assessment ([docs/gaps.md](docs/gaps.md)): breadth ≈ 99% by our own checklist, real use ≈ 30–50%; workstreams G1–G9.
- 2026-10-04: Checklist audit at `d39c0e8`: 89 done / 3 partial / 0 missing of 92 features.
- 2026-10-01: First commit.
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-10-10 | minor | Hours to ≈ 95% for each audience |
| 2026-10-10 | minor | Headline adds mainstream practitioner (≈ 47%) and essentials user (≈ 52%) |
| 2026-10-10 | minor | Stage checked against the new core-workflow gate (all six pass; still alpha) |
| 2026-10-10 | major | Rewritten to the shared progress-docs standard: stage, two numbers, dimensions, features, languages, upcoming, progress log; milestones moved to docs/roadmap.md |
| 2026-10-05 | major | Two numbers (breadth vs real use) and workstreams G1–G9 |

View File

@ -9,22 +9,19 @@
//! effectcraft-cli props <comp> <layer> [--flat] [--time S] a layer's property tree (with paths)
//! effectcraft-cli get <comp> <layer> <path> [--time S] read a property
//! effectcraft-cli set <comp> <layer> <path> <value> [--time S] [--expression E]
//! effectcraft-cli render-frame [--comp C] [--time S|--frame N] [--max-side PX|--scale K] [--out F.png] [--transparent]
//! effectcraft-cli render-frame [--comp C] [--time S|--frame N] [--max-side PX|--scale K] [--out F.png]
//! effectcraft-cli render [--comp C] --out FILE [--format h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff] [--start S] [--end S]
//! [--work-area] [--fps N] [--resolution full|half|third|quarter|K] [--quality best|draft] [--channels rgb|rgba]
//! [--jpeg-quality N] [--bitrate KBPS] [--prores proxy|lt|standard|hq|4444|4444xq] [--audio auto|on|off]
//! [--profile main|main10] [--level auto|4.1] [--rate-control bitrate|quality] [--video-quality 1-100]
//! [--keyint FRAMES] [--webm-codec vp9|av1] [--audio-bitrate KBPS] [--opus-app audio|voice]
//! (a relative --out is relative to the working directory; --queue outputs follow the project's own settings)
//! effectcraft-cli render --template T.ectemplate [--values V.json] --out FILE
//! effectcraft-cli render-frame --template T.ectemplate [--values V.json] [--time S] [--out F.png]
//! effectcraft-cli render F.ecproj --queue render the project's Render Queue
//! effectcraft-cli bench [--comp C] [--time S] [--scale K] [--n N] [--play N] [--gpu [--adv3d]] render timings
//! (--gpu: CPU vs GPU ms/frame for every comp at Full and Half)
//! effectcraft-cli bench --ops [--small] [--layers N] [--comps N] [--footage N] everyday-operation timings
//! on a large generated project (open, save, auto-save, undo/redo, timeline, Project panel)
//! effectcraft-cli script FILE.jsx [F.ecproj] | --eval CODE run an After Effects-style script
//! effectcraft-cli mcp [--autosave | --bridge PORT] MCP server on stdio
//! effectcraft-cli mcp [--bridge PORT] MCP server on stdio
//!
//! Project: --project F.ecproj (or a positional *.ecproj) | --demo | --empty (default: demo; mcp: empty)
//! Saving: --save (back to --project) | --save-as F.ecproj
@ -39,56 +36,10 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use std::io::Write;
mod template;
use effectcraft_automation::tools::{self, Reply};
use effectcraft_automation::{Backend, McpServer};
use serde_json::{Value, json};
/// `println!` that never panics: see [`write_line`].
macro_rules! say {
($($t:tt)*) => {
write_line(format_args!($($t)*))
};
}
/// `eprintln!` that never panics: a diagnostic that can't be written is dropped.
macro_rules! note {
($($t:tt)*) => {{
let _ = writeln!(std::io::stderr(), $($t)*);
}};
}
/// Why stdout stopped taking output, once it has.
static STDOUT_FAILED: std::sync::OnceLock<std::io::ErrorKind> = std::sync::OnceLock::new();
/// Write a line to stdout. Once that fails, later output is dropped but the command still
/// finishes its work (a `run … --save` sequence isn't cut short). A reader that closes stdout
/// early (`info --json | head`) has read what it wanted; any other failure is reported and makes
/// the exit status 1 ([`stdout_broken`]).
fn write_line(text: std::fmt::Arguments) {
if STDOUT_FAILED.get().is_some() {
return;
}
let r = {
let mut out = std::io::stdout().lock();
out.write_fmt(text).and_then(|()| out.write_all(b"\n"))
};
if let Err(e) = r {
let _ = STDOUT_FAILED.set(e.kind());
if e.kind() != std::io::ErrorKind::BrokenPipe {
note!("effectcraft-cli: cannot write to stdout: {e}");
}
}
}
/// Whether stdout failed for a reason other than its reader closing it early.
fn stdout_broken() -> bool {
STDOUT_FAILED.get().is_some_and(|k| *k != std::io::ErrorKind::BrokenPipe)
}
const USAGE: &str = "usage: effectcraft-cli <info|commands|exec|run|props|get|set|render-frame|render|script|mcp> [args] [--json]
info project + engine summary
commands [--filter TEXT] [--enabled] list engine commands
@ -97,7 +48,7 @@ const USAGE: &str = "usage: effectcraft-cli <info|commands|exec|run|props|get|se
props <comp> <layer> [--flat] [--time S] a layer's property tree with paths
get <comp> <layer> <path> [--time S] read a property
set <comp> <layer> <path> <value> [--time S] [--expression E]
render-frame [--comp C] [--time S | --frame N] [--max-side PX | --scale K] [--out F.png] [--transparent]
render-frame [--comp C] [--time S | --frame N] [--max-side PX | --scale K] [--out F.png]
render [--comp C] --out FILE [--format F] [--start S] [--end S] [--work-area] [--fps N]
[--resolution full|half|third|quarter|K] [--quality best|draft] [--channels rgb|rgba]
[--jpeg-quality N] [--bitrate KBPS] [--prores PROFILE] [--audio auto|on|off]
@ -105,8 +56,6 @@ const USAGE: &str = "usage: effectcraft-cli <info|commands|exec|run|props|get|se
[--keyint FRAMES] [--webm-codec vp9|av1] [--audio-bitrate KBPS] [--opus-app audio|voice] | --queue
(formats h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff;
--profile..--keyint: HEVC / AV1, --audio-bitrate/--opus-app: WebM Opus)
a relative --out is relative to the working directory
render/render-frame --template T.ectemplate [--values V.json] render one template instance
bench [--comp C] [--time S] [--scale K] [--n N] [--play N] [--gpu [--adv3d]] per-layer/effect render timings;
--play N renders N consecutive frames with/without the layer cache;
--gpu compares CPU and GPU ms/frame for every comp at Full and Half
@ -116,10 +65,7 @@ const USAGE: &str = "usage: effectcraft-cli <info|commands|exec|run|props|get|se
script FILE.jsx [F.ecproj] | --eval CODE run JavaScript with the After Effects-style object model
(app.project, comps, layers, properties…); prints writeLn
output and the result; errors exit 1 with file:line:col
mcp [--autosave | --bridge PORT] MCP server (JSON-RPC over stdio); --autosave preserves unsaved headless work
[--automation-read-root DIR] [--automation-write-root DIR]
confine the agent's file access: reads under a read or write root,
writes under a write root (each repeatable); scripts are refused
mcp [--bridge PORT] MCP server (JSON-RPC over stdio)
options: --project F.ecproj | --demo | --empty --save | --save-as F --bridge PORT --json --gpu
<comp>: id or name, '-' = active comp; <layer>: id, '#n' or name; <value>: JSON or bare string";
@ -145,8 +91,6 @@ const VALUED: &[&str] = &[
"--depth",
"--eval",
// render
"--template",
"--values",
"--format",
"--start",
"--end",
@ -166,29 +110,6 @@ const VALUED: &[&str] = &[
"--webm-codec",
"--audio-bitrate",
"--opus-app",
// mcp
"--automation-read-root",
"--automation-write-root",
];
/// Options without a value. Any other `--option` is a usage error.
const FLAGS: &[&str] = &[
"--json",
"--save",
"--demo",
"--empty",
"--gpu",
"--list",
"--schemas",
"--enabled",
"--flat",
"--transparent",
"--work-area",
"--queue",
"--ops",
"--small",
"--adv3d",
"--autosave",
];
struct Args {
@ -200,7 +121,7 @@ struct Args {
impl Args {
fn parse(raw: Vec<String>) -> Result<Args, String> {
let (mut pos, mut opts, mut unknown) = (vec![], vec![], vec![]);
let (mut pos, mut opts) = (vec![], vec![]);
let mut it = raw.into_iter();
while let Some(a) = it.next() {
if a.starts_with("--") && a.len() > 2 {
@ -211,10 +132,6 @@ impl Args {
if VALUED.contains(&k.as_str()) {
let v = inline.or_else(|| it.next()).ok_or_else(|| format!("{k} needs a value"))?;
opts.push((k, Some(v)));
} else if !FLAGS.contains(&k.as_str()) {
unknown.push(format!("`{k}`"));
} else if inline.is_some() {
return Err(format!("{k} takes no value"));
} else {
opts.push((k, None));
}
@ -222,12 +139,6 @@ impl Args {
pos.push(a);
}
}
// Before anything runs: a misspelt option must not run the command with defaults, or
// leave its value behind as a positional (`--saveas x.ecproj` opening x.ecproj).
if !unknown.is_empty() {
let s = if unknown.len() == 1 { "" } else { "s" };
return Err(format!("unknown option{s} {}", unknown.join(", ")));
}
let mut a = Args { pos, opts, project: None };
a.project = match a.opt("--project") {
Some(p) => Some(p.to_string()),
@ -238,10 +149,6 @@ impl Args {
fn flag(&self, k: &str) -> bool {
self.opts.iter().any(|(o, _)| o == k)
}
/// Every value of a repeatable option, in order.
fn opts(&self, k: &str) -> Vec<&str> {
self.opts.iter().filter(|(o, _)| o == k).filter_map(|(_, v)| v.as_deref()).collect()
}
fn opt(&self, k: &str) -> Option<&str> {
self.opts.iter().rev().find(|(o, _)| o == k).and_then(|(_, v)| v.as_deref())
}
@ -266,11 +173,11 @@ fn value_arg(s: &str) -> Value {
fn main() {
let raw: Vec<String> = std::env::args().skip(1).collect();
if matches!(raw.first().map(String::as_str), Some("--version" | "-V" | "version")) {
say!("effectcraft-cli {}", env!("CARGO_PKG_VERSION"));
println!("effectcraft-cli {}", env!("CARGO_PKG_VERSION"));
return;
}
if raw.is_empty() || raw.iter().any(|a| a == "-h" || a == "--help") {
say!("{USAGE}");
println!("{USAGE}");
std::process::exit(if raw.is_empty() { 2 } else { 0 });
}
let mut args = match Args::parse(raw) {
@ -283,21 +190,20 @@ fn main() {
}
let cmd = args.pos.remove(0);
match run(&cmd, &args, json_out) {
Ok(()) if stdout_broken() => std::process::exit(1),
Ok(()) => {}
Err(Failure::Usage(e)) => fail_usage(&e),
Err(Failure::Error(e)) => {
if json_out {
say!("{}", json!({"error": e}));
println!("{}", json!({"error": e}));
}
note!("effectcraft-cli {cmd}: {e}");
eprintln!("effectcraft-cli {cmd}: {e}");
std::process::exit(1);
}
}
}
fn fail_usage(e: &str) -> ! {
note!("effectcraft-cli: {e}\n{USAGE}");
eprintln!("effectcraft-cli: {e}\n{USAGE}");
std::process::exit(2)
}
@ -341,22 +247,9 @@ fn backend(args: &Args, default_demo: bool) -> Result<Backend, Failure> {
/// renderer setting, Mercury GPU Acceleration by default).
fn session(args: &Args) -> Result<effectcraft_engine::Session, Failure> {
let mut s = effectcraft_host::session();
if args.flag("--autosave") {
let dir = effectcraft_host::config_dir().ok_or_else(|| Failure::Error("--autosave: no config directory; set EFFECTCRAFT_CONFIG_DIR".into()))?;
// Read only auto-save settings. Opting in must not change headless rendering or load
// the desktop's models, shortcuts, disk cache or recovery sentinel.
use effectcraft_engine::config::ConfigStore;
let config = effectcraft_engine::config::DirConfig::new(dir);
if let Some(text) = config.read(effectcraft_engine::prefs::PREFS_FILE) {
s.prefs.auto_save = effectcraft_engine::prefs::Prefs::from_json(&text).auto_save;
}
s.prefs.normalize();
}
if args.flag("--gpu") {
let g = effectcraft_gpu::Gpu::try_headless().map_err(|e| Failure::Error(format!("--gpu: no usable GPU adapter ({e})")))?;
let g = effectcraft_gpu::Gpu::headless().ok_or_else(|| Failure::Error("--gpu: no usable GPU adapter".into()))?;
s.accel = Some(std::sync::Arc::new(g));
} else {
s.accel_note = Some("headless: renders on the CPU unless started with --gpu".into());
}
Ok(s)
}
@ -399,13 +292,13 @@ fn script_cmd(args: &Args, json_out: bool) -> Result<(), Failure> {
emit(&o, true);
} else {
if let Some(out) = r["output"].as_str().filter(|o| !o.is_empty()) {
say!("{out}");
println!("{out}");
}
if !r["result"].is_null() && r["error"].is_null() {
say!("{}", r["result"]);
println!("{}", r["result"]);
}
if let Some(p) = saved {
note!("saved {p}");
eprintln!("saved {p}");
}
}
if let Some(e) = r.get("error").filter(|e| !e.is_null()) {
@ -426,17 +319,13 @@ fn script_cmd(args: &Args, json_out: bool) -> Result<(), Failure> {
/// Print a result: compact JSON with `--json`, else pretty JSON.
fn emit(v: &Value, json_out: bool) {
if json_out {
say!("{v}");
println!("{v}");
} else {
say!("{}", serde_json::to_string_pretty(v).unwrap_or_default());
println!("{}", serde_json::to_string_pretty(v).unwrap_or_default());
}
}
fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
if args.flag("--autosave") && cmd != "mcp" {
return usage_err("--autosave is only supported by mcp");
}
template::validate(cmd, args)?;
match cmd {
"render" => render(args, json_out)?,
"bench" => bench_cmd(args)?,
@ -455,19 +344,19 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
if json_out {
emit(&info, true);
} else {
say!(
println!(
"EffectCraft {} ({}), {} commands, {} effects",
info["version"].as_str().unwrap_or(""),
info["mode"].as_str().unwrap_or(""),
info["commands"],
info["effects"]
);
say!("project: {}", p["path"].as_str().unwrap_or("(unsaved)"));
println!("project: {}", p["path"].as_str().unwrap_or("(unsaved)"));
for i in p["items"].as_array().into_iter().flatten() {
say!(" item {:>3} {:<12} {}", i["id"], i["type"].as_str().unwrap_or(""), i["name"].as_str().unwrap_or(""));
println!(" item {:>3} {:<12} {}", i["id"], i["type"].as_str().unwrap_or(""), i["name"].as_str().unwrap_or(""));
}
if let Some(c) = comp {
say!(
println!(
"active comp {} \"{}\" {}x{} @ {} fps, {} s",
c["id"],
c["name"].as_str().unwrap_or(""),
@ -477,7 +366,7 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
c["duration"]
);
for l in c["layers"].as_array().into_iter().flatten() {
say!(" #{:<3} id {:<4} {:<10} {}", l["index"], l["id"], l["type"].as_str().unwrap_or(""), l["name"].as_str().unwrap_or(""));
println!(" #{:<3} id {:<4} {:<10} {}", l["index"], l["id"], l["type"].as_str().unwrap_or(""), l["name"].as_str().unwrap_or(""));
}
}
}
@ -533,7 +422,7 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
if json_out {
emit(&v, true);
} else {
say!("layer {} \"{}\" ({}) at {} s", v["id"], v["name"].as_str().unwrap_or(""), v["type"].as_str().unwrap_or(""), v["time"]);
println!("layer {} \"{}\" ({}) at {} s", v["id"], v["name"].as_str().unwrap_or(""), v["type"].as_str().unwrap_or(""), v["time"]);
for p in v["properties"].as_array().into_iter().flatten() {
let mut extra = String::new();
if let Some(k) = p.get("keys") {
@ -542,7 +431,7 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
if let Some(e) = p.get("expression").and_then(Value::as_str) {
extra += &format!(" expr: {e}");
}
say!(" {:<44} {:<8} {}{extra}", p["path"].as_str().unwrap_or(""), p["type"].as_str().unwrap_or(""), p["value"]);
println!(" {:<44} {:<8} {}{extra}", p["path"].as_str().unwrap_or(""), p["type"].as_str().unwrap_or(""), p["value"]);
}
}
}
@ -568,13 +457,7 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
emit(&with_saved(v, saved), json_out);
}
"render-frame" | "frame" => {
let mut b = if args.opt("--template").is_some() {
let mut s = session(args)?;
template::prepare(&mut s, args)?;
Backend::headless(s)
} else {
backend(args, true)?
};
let mut b = backend(args, true)?;
let comp = args.opt("--comp").and_then(reference);
let mut time = args.num("--time")?;
let mut max_side = args.num("--max-side")?.map(|m| m as u32).unwrap_or(0);
@ -591,39 +474,19 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
}
let out = args.opt("--out").unwrap_or("frame.png").to_string();
let t0 = std::time::Instant::now();
let f = b.render_with(comp.as_ref(), time, max_side, args.flag("--transparent"))?;
let f = b.render(comp.as_ref(), time, max_side)?;
let ms = t0.elapsed().as_secs_f64() * 1000.0;
std::fs::write(&out, &f.png).map_err(|e| format!("cannot write {out}: {e}"))?;
let info = json!({"path": out, "comp": f.comp, "time": f.time, "width": f.width, "height": f.height, "ms": (ms * 10.0).round() / 10.0});
if json_out {
emit(&info, true);
} else {
note!("rendered {}x{} at {:.3}s in {ms:.1} ms -> {out}", f.width, f.height, f.time);
eprintln!("rendered {}x{} at {:.3}s in {ms:.1} ms -> {out}", f.width, f.height, f.time);
}
}
"mcp" => {
if args.flag("--autosave") && args.opt("--bridge").is_some() {
return usage_err("--autosave is for headless MCP; the bridged app owns its auto-saves");
}
let mut b = backend(args, false)?;
let (read, write) = (args.opts("--automation-read-root"), args.opts("--automation-write-root"));
if !read.is_empty() || !write.is_empty() {
let roots = |r: Vec<&str>| r.into_iter().map(std::path::PathBuf::from).collect::<Vec<_>>();
b.confine(effectcraft_automation::confine::Confinement::new(&roots(read), &roots(write))?);
}
let server = McpServer::new(b);
let mut server = if args.flag("--autosave") {
let root =
effectcraft_host::config_dir().ok_or_else(|| Failure::Error("--autosave: no config directory; set EFFECTCRAFT_CONFIG_DIR".into()))?;
server.with_autosave(&root)?
} else {
server
};
// A client that closes its end of stdout has ended the session.
match server.serve_stdio() {
Err(e) if e.kind() != std::io::ErrorKind::BrokenPipe => return Err(e.to_string().into()),
_ => {}
}
let b = backend(args, false)?;
McpServer::new(b).serve_stdio().map_err(|e| e.to_string())?;
}
other => return usage_err(&format!("unknown subcommand `{other}`")),
}
@ -644,32 +507,21 @@ fn with_saved(v: Value, saved: Option<String>) -> Value {
}
}
/// A relative `--out` is relative to the working directory, like `--project` and every other CLI
/// path. The Render Queue alone would resolve it against the project's folder.
fn from_cwd(path: &str) -> Result<String, Failure> {
std::path::absolute(path).map(|p| p.to_string_lossy().into_owned()).map_err(|e| Failure::Error(format!("render: cannot resolve --out {path}: {e}")))
}
/// `render`: queue `--comp` (or the active comp) with the given settings unless `--queue`, then
/// render the queue with a progress line on stderr. Fails if any item fails.
fn render(args: &Args, json_out: bool) -> Result<(), Failure> {
use effectcraft_engine::project::render_queue::{OutputFormat, RenderStatus};
use effectcraft_engine::project::render_queue::RenderStatus;
if args.opt("--bridge").is_some() {
return usage_err("render runs headless; use `exec renderQueue.add` / `renderQueue.render` with --bridge");
}
let mut s = session(args)?;
if args.opt("--template").is_some() {
template::prepare(&mut s, args)?;
} else {
match &args.project {
Some(p) => s.execute("file.open", json!({"path": p})).map_err(|e| Failure::Error(e.to_string()))?,
None => s.execute("file.openDemoProject", json!({})).map_err(|e| Failure::Error(e.to_string()))?,
};
}
match &args.project {
Some(p) => s.execute("file.open", json!({"path": p})).map_err(|e| Failure::Error(e.to_string()))?,
None => s.execute("file.openDemoProject", json!({})).map_err(|e| Failure::Error(e.to_string()))?,
};
let err = |e: effectcraft_engine::EngineError| Failure::Error(e.to_string());
if !args.flag("--queue") {
let Some(out) = args.opt("--out") else { return usage_err("render: --out FILE is required (or --queue)") };
let out = &from_cwd(out)?;
let mut p = json!({"output": out});
if let Some(c) = args.opt("--comp") {
p["comp"] = json!(c);
@ -703,11 +555,6 @@ fn render(args: &Args, json_out: bool) -> Result<(), Failure> {
}
if let Some(v) = args.num("--bitrate")? {
p["bitrate"] = json!(v);
// VP9 WebM encodes by quality unless told to target the bitrate (#440).
// The format parser decides what is WebM, so its aliases (`vp9`, `webmvp9opus`) count.
if p["format"].as_str().and_then(OutputFormat::from_name) == Some(OutputFormat::WebM) {
p["webmBitrate"] = json!(true);
}
}
for (flag, key) in [
("--profile", "profile"),
@ -730,7 +577,7 @@ fn render(args: &Args, json_out: bool) -> Result<(), Failure> {
let _ = s.execute("renderQueue.setRender", json!({"index": k + 1, "render": false}));
}
let r = s.execute("renderQueue.add", p).map_err(err)?;
note!(
eprintln!(
"{} → {} ({}×{}, {} frames, {})",
r["compName"].as_str().unwrap_or("?"),
r["outputPath"].as_str().unwrap_or("?"),
@ -746,8 +593,7 @@ fn render(args: &Args, json_out: bool) -> Result<(), Failure> {
std::thread::sleep(std::time::Duration::from_millis(100));
if tty && let Some(p) = s.render_progress() {
let left = p.remaining.map(|r| format!(", ~{r:.1}s left")).unwrap_or_default();
let _ = write!(
std::io::stderr(),
eprint!(
"
[{}/{}] frame {}/{} {:.1}s{left}\x1b[K",
(p.items_done + 1).min(p.items_total),
@ -761,7 +607,7 @@ fn render(args: &Args, json_out: bool) -> Result<(), Failure> {
}
s.poll_render();
if tty {
note!();
eprintln!();
}
let mut results = vec![];
let mut failed = vec![];
@ -769,7 +615,7 @@ fn render(args: &Args, json_out: bool) -> Result<(), Failure> {
let name = s.project.item(it.comp).map(|i| i.name.clone()).unwrap_or_else(|| "?".into());
match &it.status {
RenderStatus::Done => {
note!("done: {name} → {} in {:.2}s", it.last_output.as_deref().unwrap_or("?"), it.render_time.unwrap_or(0.0));
eprintln!("done: {name} → {} in {:.2}s", it.last_output.as_deref().unwrap_or("?"), it.render_time.unwrap_or(0.0));
results.push(json!({"comp": name, "output": it.last_output, "seconds": it.render_time}));
}
RenderStatus::Failed(e) => failed.push(format!("{name}: {e}")),
@ -794,7 +640,7 @@ fn list_commands_cmd(args: &Args, json_out: bool) -> Result<(), Failure> {
emit(&v, true);
} else {
for c in v.as_array().into_iter().flatten() {
say!(
println!(
"{:<36} {:<36} {:<16} {}",
c["id"].as_str().unwrap_or(""),
c["label"].as_str().unwrap_or(""),
@ -862,7 +708,7 @@ fn bench_ops(args: &Args) -> Result<(), Failure> {
m.extend(effectcraft_ui_egui::bench::ui_ops(|| {
let mut s = effectcraft_host::session();
if let Err(e) = s.execute("file.open", json!({"path": path})) {
note!("bench --ops: {e}");
eprintln!("bench --ops: {e}");
}
s
}));
@ -870,9 +716,9 @@ fn bench_ops(args: &Args) -> Result<(), Failure> {
if args.flag("--json") {
emit(&json!({"spec": format!("{spec:?}"), "measures": m.iter().map(perf::Measure::json).collect::<Vec<_>>()}), true);
} else {
say!("bench --ops: {} comps, {} layers in Main, {} footage items", spec.comps, spec.main_layers, spec.footage);
println!("bench --ops: {} comps, {} layers in Main, {} footage items", spec.comps, spec.main_layers, spec.footage);
for x in &m {
say!(" {:<22} {:>10.2} ms {}", x.name, x.ms, x.note);
println!(" {:<22} {:>10.2} ms {}", x.name, x.ms, x.note);
}
}
Ok(())
@ -942,7 +788,7 @@ fn bench(s: &Session, cid: ItemId, t: Tick, opts: RenderOpts, n: usize) {
}
}
}
note!(
eprintln!(
"bench (no cache): {n} runs at scale {}: wall min {:.2} ms, median {:.2} ms; CPU (all threads) min {:.2} ms",
opts.scale,
min(&totals),
@ -950,9 +796,9 @@ fn bench(s: &Session, cid: ItemId, t: Tick, opts: RenderOpts, n: usize) {
min(&cpus)
);
for (d, name, p, c, fx) in per_layer {
note!(" {:indent$}{name:<28} process {:8.2} ms composite {:8.2} ms (min)", "", min(&p), min(&c), indent = d * 2);
eprintln!(" {:indent$}{name:<28} process {:8.2} ms composite {:8.2} ms (min)", "", min(&p), min(&c), indent = d * 2);
for (id, v) in fx {
note!(" {:indent$} fx {id:<30} {:8.2} ms", "", min(&v), indent = d * 2);
eprintln!(" {:indent$} fx {id:<30} {:8.2} ms", "", min(&v), indent = d * 2);
}
}
}
@ -973,7 +819,7 @@ fn bench_play(s: &Session, cid: ItemId, t: Tick, opts: RenderOpts, n: usize) {
let mut v: Vec<f64> = runs.iter().map(|r| r.0).collect();
let mean = v.iter().sum::<f64>() / n as f64;
let cpu = runs.iter().map(|r| r.1).sum::<f64>() / n as f64;
note!(
eprintln!(
"bench-play ({label}): {n} frames at scale {}: wall mean {mean:.2} ms, median {:.2} ms, min {:.2} ms; CPU mean {cpu:.2} ms",
opts.scale,
median(&mut v),
@ -981,7 +827,7 @@ fn bench_play(s: &Session, cid: ItemId, t: Tick, opts: RenderOpts, n: usize) {
);
}
let st = cache.stats();
note!(" layer cache: {} hits, {} misses, {} entries, {:.1} MB", st.hits, st.misses, st.entries, st.bytes as f64 / 1e6);
eprintln!(" layer cache: {} hits, {} misses, {} entries, {:.1} MB", st.hits, st.misses, st.entries, st.bytes as f64 / 1e6);
}
/// Median wall time (ms) of `n` runs of `f` after one warm-up run.
@ -1144,7 +990,7 @@ fn adv3d_bench_comp(p: &mut effectcraft_engine::project::Project) -> Option<Item
/// warm; "up/dn MB" are the GPU's uploads / readbacks per warm GPU frame.
fn bench_gpu(s: &Session, args: &Args) -> Result<(), Failure> {
use effectcraft_render::Backend;
let gpu = effectcraft_gpu::Gpu::try_headless().map_err(|e| Failure::Error(format!("--gpu: no usable GPU adapter ({e})")))?;
let gpu = effectcraft_gpu::Gpu::headless().ok_or_else(|| Failure::Error("--gpu: no usable GPU adapter".into()))?;
let n = args.num("--n")?.unwrap_or(10.0).max(1.0) as usize;
// Every comp, plus an adjustment-layer comp built for the benchmark (the main comp under a
// full-frame adjustment layer with a GPU effect stack) and an Advanced 3D comp (`--adv3d`:
@ -1162,21 +1008,10 @@ fn bench_gpu(s: &Session, args: &Args) -> Result<(), Failure> {
}
};
let project = &project;
note!("GPU: {} — median of {n} runs (ms/frame)", effectcraft_render::Accelerator::name(&gpu));
note!(
eprintln!("GPU: {} — median of {n} runs (ms/frame)", effectcraft_render::Accelerator::name(&gpu));
eprintln!(
"{:<28} {:>5} {:>10} {:>9} {:>9} {:>9} {:>9} {:>9} {:>8} {:>9} {:>9} {:>11}",
"comp",
"res",
"size",
"cpu cold",
"gpu cold",
"cpu warm",
"gpu warm",
"gpu view",
"speedup",
"auto",
"gpu≠cpu",
"up/dn MB"
"comp", "res", "size", "cpu cold", "gpu cold", "cpu warm", "gpu warm", "gpu view", "speedup", "auto", "gpu≠cpu", "up/dn MB"
);
for cid in comps {
let Some(comp) = project.comp(cid) else { continue };
@ -1242,46 +1077,10 @@ fn bench_gpu(s: &Session, args: &Args) -> Result<(), Failure> {
.count();
let pct = 100.0 * off as f64 / a.data.len().max(1) as f64;
let speedup = cpu_warm / gpu_warm.max(1e-9);
note!(
eprintln!(
"{name:<28} {label:>5} {size:>10} {cpu_cold:>9.2} {gpu_cold:>9.2} {cpu_warm:>9.2} {gpu_warm:>9.2} {view:>9.2} {speedup:>7.2}x {auto:>9.2} {pct:>8.3}% {traffic:>11}"
);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(args: &[&str]) -> Result<Args, String> {
Args::parse(args.iter().map(|a| a.to_string()).collect())
}
/// Unknown options are usage errors naming them, not ignored (#168).
#[test]
fn unknown_options_are_refused() {
let err = |args: &[&str]| parse(args).err().unwrap();
assert_eq!(err(&["exec", "comp.new", "--bogus", "--empty", "--saveas", "z.ecproj"]), "unknown options `--bogus`, `--saveas`");
assert_eq!(err(&["render", "--bogus=1"]), "unknown option `--bogus`");
assert_eq!(err(&["info", "--json=yes"]), "--json takes no value");
let a = parse(&["render", "t.ecproj", "--comp=Main", "--work-area", "--out", "x.mp4", "--json"]).unwrap();
assert_eq!((a.project.as_deref(), a.opt("--comp"), a.opt("--out")), (Some("t.ecproj"), Some("Main"), Some("x.mp4")));
assert!(a.flag("--work-area") && a.flag("--json"));
}
/// Every `--option` the CLI reads is declared in VALUED or FLAGS, so none is refused.
#[test]
fn every_option_read_is_declared() {
let code = include_str!("main.rs").split("#[cfg(test)]").next().unwrap();
for rest in code.split("\"--").skip(1) {
let name: String = rest.chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '-').collect();
let opt = format!("--{name}");
// `--help` and `--version` are handled before parsing.
if name.is_empty() || !rest[name.len()..].starts_with('"') || ["--help", "--version"].contains(&opt.as_str()) {
continue;
}
assert!(VALUED.contains(&opt.as_str()) || FLAGS.contains(&opt.as_str()), "{opt} is read but not declared");
}
}
}

View File

@ -1,138 +0,0 @@
//! CLI-only setup of an Essential Graphics template instance using engine commands.
use std::io::Read;
use effectcraft_engine::{
EngineError, Session,
project::{ItemId, LayerId},
};
use serde_json::{Map, Value, json};
use crate::{Args, Failure, usage_err};
pub(super) fn validate(cmd: &str, args: &Args) -> Result<(), Failure> {
if args.opt("--values").is_some() && args.opt("--template").is_none() {
return usage_err("--values requires --template");
}
if args.opt("--template").is_none() {
return Ok(());
}
if !matches!(cmd, "render" | "render-frame" | "frame") {
return usage_err("--template is available for render and render-frame");
}
if args.project.is_some() || args.flag("--demo") || args.opt("--bridge").is_some() || args.flag("--queue") || args.opt("--comp").is_some() {
return usage_err("--template renders a new instance; it cannot be combined with --project, --demo, --bridge, --queue or --comp");
}
if !args.pos.is_empty() {
return usage_err("template rendering takes --template FILE.ectemplate, without positional arguments");
}
if cmd == "render" && args.opt("--out").is_none() {
return usage_err("render: --out FILE is required (or --queue)");
}
// Resolve existing outputs before importing or rendering can overwrite an input.
let out = args.opt("--out").or_else(|| matches!(cmd, "render-frame" | "frame").then_some("frame.png"));
if let Some(out) = out
&& let Ok(output) = std::fs::canonicalize(out)
{
for input in ["--template", "--values"] {
if let Some(path) = args.opt(input)
&& std::fs::canonicalize(path).is_ok_and(|path| path == output)
{
return Err(Failure::Error(format!("--out {out} would overwrite {input} {path}")));
}
}
}
Ok(())
}
fn values(args: &Args) -> Result<Map<String, Value>, Failure> {
let Some(path) = args.opt("--values") else { return Ok(Map::new()) };
// A bounded read also handles a file that grows after opening.
const MAX_BYTES: u64 = 1024 * 1024;
let file = std::fs::File::open(path).map_err(|e| Failure::Error(format!("cannot read values {path}: {e}")))?;
let mut bytes = Vec::new();
file.take(MAX_BYTES + 1).read_to_end(&mut bytes).map_err(|e| Failure::Error(format!("cannot read values {path}: {e}")))?;
if bytes.len() as u64 > MAX_BYTES {
return Err(Failure::Error(format!("values {path}: JSON exceeds 1 MiB")));
}
let value: Value = serde_json::from_slice(&bytes).map_err(|e| Failure::Error(format!("values {path}: invalid JSON: {e}")))?;
match value {
Value::Object(values) => Ok(values),
_ => Err(Failure::Error(format!("values {path}: expected a JSON object mapping control names to values"))),
}
}
fn execute(s: &mut Session, command: &str, params: Value) -> Result<Value, Failure> {
s.execute_checked(command, params).map_err(|e| Failure::Error(e.to_string()))
}
// Use the same instance commands to reject invalid values before import extracts media.
fn preflight(s: &Session, path: &str, values: &Map<String, Value>) -> Result<(), Failure> {
let bytes = s.services.read_file(path).map_err(|e| Failure::Error(format!("cannot read {path}: {e}")))?;
effectcraft_engine::guard::guarded("template value validation", || {
let bad = |msg: String| EngineError::BadParams { cmd: "essential.importTemplate".into(), msg };
let (manifest, mut project, _) = effectcraft_engine::commands::essential::read_template(&bytes).map_err(bad)?;
let source = manifest.get("comp").and_then(Value::as_u64).ok_or_else(|| bad("manifest has no `comp`".into()))?;
let base = s.project.next_id;
let source = source.checked_add(base).ok_or_else(|| bad("template composition ID overflows the import offset".into()))?;
effectcraft_engine::project::essential::offset_ids(&mut project, base);
project.fix_next_id();
let mut check = Session::new();
check.project = project.into();
Ok(instantiate(&mut check, source, values))
})
.map_err(|e| Failure::Error(e.to_string()))?
}
pub(super) fn prepare(s: &mut Session, args: &Args) -> Result<(), Failure> {
let Some(path) = args.opt("--template") else { return Ok(()) };
let values = values(args)?;
if !values.is_empty() {
preflight(s, path, &values)?;
}
let imported = execute(s, "essential.importTemplate", json!({"path": path, "addToComp": false}))?;
let source = imported.get("comp").and_then(Value::as_u64).ok_or_else(|| Failure::Error("template import did not return a composition".into()))?;
instantiate(s, source, &values)
}
fn instantiate(s: &mut Session, source: u64, values: &Map<String, Value>) -> Result<(), Failure> {
// Duplicate and empty the source with commands: this preserves exact rational frame rate,
// Tick duration, background, work area and renderer settings without round-tripping floats.
let duplicate = execute(s, "project.duplicate", json!({"items": [source]}))?;
let parent = duplicate
.get("items")
.and_then(Value::as_array)
.and_then(|items| items.first())
.and_then(Value::as_u64)
.ok_or_else(|| Failure::Error("cannot create template render composition".into()))?;
execute(s, "comp.open", json!({"comp": parent}))?;
let layers: Vec<u64> = s
.project
.comp(ItemId(parent))
.ok_or_else(|| Failure::Error("template render composition is missing".into()))?
.layers
.iter()
.map(|layer| layer.id.0)
.collect();
if !layers.is_empty() {
execute(s, "layer.setSwitch", json!({"comp": parent, "layers": layers, "switch": "lock", "value": false}))?;
execute(s, "edit.clear", json!({"comp": parent, "layers": layers}))?;
}
let added = execute(s, "layer.addItem", json!({"comp": parent, "item": source, "time": 0}))?;
let instance = added.get("layer").and_then(Value::as_u64).ok_or_else(|| Failure::Error("cannot create template instance".into()))?;
// Keep a partial final frame: layer.addItem snaps its default end to the frame grid.
s.edit("Template Duration", None, |project, _| {
let comp = project.comp_mut(ItemId(parent)).ok_or(EngineError::NoComp)?;
let duration = comp.duration;
let layer = comp.layer_mut(LayerId(instance)).ok_or_else(|| EngineError::Other("template instance is missing".into()))?;
layer.out_point = duration;
Ok(())
})
.map_err(|e| Failure::Error(e.to_string()))?;
// Let the wrapper carry the source composition's authored motion blur.
execute(s, "layer.setSwitch", json!({"comp": parent, "layers": [instance], "switch": "motionBlur", "value": true}))?;
for (control, value) in values {
execute(s, "essential.set", json!({"comp": parent, "layer": instance, "control": control, "value": value}))?;
}
Ok(())
}

View File

@ -1,6 +1,6 @@
//! End-to-end tests of the agent CLI: JSON output, project round-trips, rendering and MCP over stdio.
use std::io::{BufRead, BufReader, Write};
use std::io::Write;
use std::path::PathBuf;
use std::process::{Command, Stdio};
@ -28,9 +28,7 @@ fn ok_json(args: &[&str]) -> Value {
fn tmp(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("ec-cli-test-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
// macOS's `/var` is a symlink to `/private/var`: a child process resolves its working
// directory through `current_dir()`, which is canonical, so compare canonical paths.
std::fs::canonicalize(&d).unwrap_or(d).join(name)
d.join(name)
}
#[test]
@ -132,60 +130,6 @@ fn errors_are_json() {
assert_eq!(out.status.code(), Some(2));
}
/// An unknown option is a usage error (exit 2) naming it, before anything runs: the command
/// doesn't run with defaults, and a misspelt option's value isn't opened as the project (#168).
#[test]
fn unknown_options_are_usage_errors() {
let png = tmp("bogus.png");
let o = png.to_str().unwrap();
for (args, named) in [
(&["exec", "comp.new", "--bogus", "--empty", "--json"][..], "`--bogus`"),
(&["render-frame", "--bogusflag", "--out", o, "--json"], "`--bogusflag`"),
(&["exec", "comp.new", "--empty", "--saveas", "z.ecproj", "--json"], "`--saveas`"),
] {
let out = bin().args(args).output().unwrap();
let err = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(2), "{args:?}: {err}");
assert!(err.contains(&format!("unknown option {named}")), "{args:?}: {err}");
assert!(out.stdout.is_empty(), "{args:?}: nothing ran");
}
assert!(!png.exists(), "render-frame didn't render");
// `--transparent` (silently ignored before) keeps the frame's alpha.
let proj = tmp("transparent.ecproj");
let p = proj.to_str().unwrap();
ok_json(&["run", "comp.new", r#"{"name":"T","width":32,"height":32}"#, "layer.newSolid", r#"{"width":8,"height":8}"#, "--empty", "--save-as", p]);
for (flag, alpha) in [(None, 255), (Some("--transparent"), 0)] {
ok_json(&[&["render-frame", p, "--out", o][..], flag.as_slice()].concat());
assert_eq!(image::open(&png).unwrap().to_rgba8().get_pixel(0, 0)[3], alpha, "{flag:?}");
}
}
/// A reader that closes stdout before the CLI writes (`| head`) is not a crash: the command
/// still does its work and exits 0, without a panic (#167).
#[test]
fn a_closed_stdout_is_not_a_crash() {
let proj = tmp("closed-stdout.ecproj");
let p = proj.to_str().unwrap();
for args in [&["info", "--json"][..], &["exec", "--list"], &["run", "comp.new", r#"{"name":"Piped"}"#, "project.summary", "--empty", "--save-as", p]] {
let mut child = bin().args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
drop(child.stdout.take());
let out = child.wait_with_output().unwrap();
let err = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(0), "{args:?}: {err}");
assert!(!err.contains("panicked"), "{args:?}: {err}");
}
assert!(std::fs::read_to_string(&proj).unwrap().contains("Piped"), "the sequence ran to the end and saved");
// The MCP server ends quietly when its client closes stdout.
let mut child = bin().arg("mcp").stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
drop(child.stdout.take());
{
let mut stdin = child.stdin.take().unwrap();
let _ = writeln!(stdin, "{}", json!({"jsonrpc": "2.0", "id": 1, "method": "ping"}));
}
let out = child.wait_with_output().unwrap();
assert_eq!(out.status.code(), Some(0), "{}", String::from_utf8_lossy(&out.stderr));
}
#[test]
fn mcp_over_stdio() {
let mut child = bin().args(["mcp", "--demo"]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap();
@ -241,253 +185,4 @@ fn script_file_and_eval() {
let (code, v) = run_json(&["script", "--eval", "\nnope()"]);
assert_eq!(code, 1);
assert_eq!(v["error"]["line"], json!(2));
// A compiled .jsxbin script is reported as unsupported, not as a SyntaxError (#176).
let bin_script = tmp("compiled.jsxbin");
std::fs::write(&bin_script, "@JSXBIN@ES@2.0@MyBbyBn0ABJAnAEjzFjBjMjFjSjUBfRBFeFjIjFjMjMjPff0DzACByB\n").unwrap();
let out = bin().args(["script", bin_script.to_str().unwrap()]).output().unwrap();
assert_eq!(out.status.code(), Some(1));
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains(".jsxbin scripts are not supported: run the .jsx source") && !err.contains("SyntaxError"), "{err}");
}
/// `render --out` (#300): a relative path is relative to the working directory like `--project`,
/// not to the project's folder (which doubled a path that already named it).
#[test]
fn render_out_is_relative_to_the_working_directory() {
let root = tmp("render-out");
let _ = std::fs::remove_dir_all(&root);
let (proj, elsewhere) = (root.join("proj"), root.join("elsewhere"));
std::fs::create_dir_all(&proj).unwrap();
std::fs::create_dir_all(&elsewhere).unwrap();
// current_dir resolves aliases such as macOS /var -> /private/var.
let root = root.canonicalize().unwrap();
let (proj, elsewhere) = (root.join("proj"), root.join("elsewhere"));
let project = proj.join("x.ecproj");
let p = project.to_str().unwrap();
ok_json(&[
"run",
"comp.new",
r#"{"name":"T","width":64,"height":36,"duration":1}"#,
"layer.newSolid",
r#"{"width":64,"height":36}"#,
"--empty",
"--save-as",
p,
]);
assert!(project.exists());
let render = |cwd: &std::path::Path, project: &str, out: &str| {
let o = bin()
.current_dir(cwd)
.args(["render", "--project", project, "--format", "gif", "--start", "0", "--end", "0.1", "--resolution", "quarter", "--out", out, "--json"])
.output()
.unwrap();
assert!(o.status.success(), "{out}: {}", String::from_utf8_lossy(&o.stderr));
let v: Value = serde_json::from_slice(&o.stdout).unwrap();
PathBuf::from(v["rendered"][0]["output"].as_str().unwrap())
};
// A path that already names the project folder is not doubled.
let done = render(&root, "proj/x.ecproj", "proj/out/d.gif");
assert_eq!(done, root.join("proj/out/d.gif"));
assert!(done.exists() && !proj.join("proj").exists());
// Another working directory: the file lands there, not beside the project.
let done = render(&elsewhere, project.to_str().unwrap(), "out/card.gif");
assert_eq!(done, elsewhere.join("out/card.gif"));
assert!(done.exists() && !proj.join("out/card.gif").exists());
// `./f.gif` too.
assert_eq!(render(&elsewhere, project.to_str().unwrap(), "./f.gif"), elsewhere.join("./f.gif"));
// An absolute path is unchanged.
let abs = root.join("abs.gif");
assert_eq!(render(&elsewhere, project.to_str().unwrap(), abs.to_str().unwrap()), abs);
assert!(abs.exists());
let _ = std::fs::remove_dir_all(&root);
}
/// A real stdio client: each edit's reply is read before disconnecting or killing the server.
struct McpChild {
child: std::process::Child,
output: BufReader<std::process::ChildStdout>,
}
impl McpChild {
fn start(config: &std::path::Path, autosave: bool) -> Self {
let mut command = bin();
command.arg("mcp").env("EFFECTCRAFT_CONFIG_DIR", config).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped());
if autosave {
command.arg("--autosave");
}
let mut child = command.spawn().unwrap();
let output = BufReader::new(child.stdout.take().unwrap());
Self { child, output }
}
fn rpc(&mut self, method: &str, params: Value) -> Value {
writeln!(self.child.stdin.as_mut().unwrap(), "{}", json!({"jsonrpc":"2.0", "id":1, "method":method, "params":params})).unwrap();
let mut line = String::new();
assert!(self.output.read_line(&mut line).unwrap() > 0, "server exited without replying");
let reply: Value = serde_json::from_str(&line).unwrap();
assert!(reply.get("error").is_none(), "{reply}");
reply["result"].clone()
}
fn tool(&mut self, name: &str, arguments: Value) -> Value {
let result = self.rpc("tools/call", json!({"name":name,"arguments":arguments}));
assert_eq!(result["isError"], false, "{result}");
serde_json::from_str(result["content"][0]["text"].as_str().unwrap()).unwrap()
}
fn kill(&mut self) {
self.child.kill().unwrap();
self.child.wait().unwrap();
}
fn eof(&mut self) {
drop(self.child.stdin.take());
assert!(self.child.wait().unwrap().success());
}
}
impl Drop for McpChild {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
fn mcp_config(name: &str) -> PathBuf {
let dir = tmp(name);
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn mcp_autosave_survives_kill_and_restart_without_touching_desktop_settings() {
let config = mcp_config("mcp-kill");
let prefs = r#"{"autoSave":{"enabled":false,"maxVersions":2,"intervalMinutes":20}}"#;
std::fs::write(config.join("prefs.json"), prefs).unwrap();
std::fs::write(config.join("shortcuts.json"), "{}").unwrap();
std::fs::write(config.join("session.lock"), "desktop sentinel").unwrap();
let mut first = McpChild::start(&config, true);
first.rpc("initialize", json!({}));
first.tool("execute_command", json!({"command":"comp.new","params":{"name":"Survives restart","width":320,"height":180}}));
first.tool("execute_command", json!({"command":"layer.newText","params":{"text":"Unsaved title"}}));
assert_eq!(first.tool("execute_command", json!({"command":"prefs.get","params":{"key":"autoSave.enabled"}})), false);
let status = first.tool("get_project", json!({}))["autosave"].clone();
let path = status["latestPath"].as_str().unwrap();
first.kill(); // SIGKILL: there is no opportunity to save at EOF.
let folder = std::path::Path::new(status["folder"].as_str().unwrap());
assert_eq!(effectcraft_engine::autosave::existing(folder, "Untitled Project").len(), 2, "loaded maxVersions is respected");
let manifest: Value = serde_json::from_str(&std::fs::read_to_string(folder.join("session.json")).unwrap()).unwrap();
assert_eq!(manifest["dirty"], true);
assert_eq!(manifest["ended"], false);
let mut restarted = McpChild::start(&config, true);
let init = restarted.rpc("initialize", json!({}));
// The instructions carry the auto-save state as JSON, where a Windows path's backslashes
// are escaped.
assert!(init["instructions"].as_str().unwrap().contains(&Value::from(path).to_string()));
assert_eq!(init["_meta"]["effectcraftAutoSave"]["previousSessions"][0]["autosave"], path);
restarted.tool("open_project", json!({"path":path}));
let comp = restarted.tool("get_comp", json!({"comp":"Survives restart"}));
assert_eq!(comp["layers"].as_array().unwrap().len(), 1);
let layer = restarted.tool("get_layer", json!({"layer":"#1"}));
assert!(layer.to_string().contains("Unsaved title"), "{layer}");
restarted.tool("save_project", json!({"path":config.join("Recovered.ecproj")}));
restarted.eof();
assert_eq!(std::fs::read_to_string(config.join("prefs.json")).unwrap(), prefs);
assert_eq!(std::fs::read_to_string(config.join("shortcuts.json")).unwrap(), "{}");
assert_eq!(std::fs::read_to_string(config.join("session.lock")).unwrap(), "desktop sentinel");
std::fs::remove_dir_all(config).unwrap();
}
#[test]
fn mcp_autosave_eof_preserves_titled_edits_without_overwriting_project() {
let config = mcp_config("mcp-eof");
let original = config.join("Original.ecproj");
let mut first = McpChild::start(&config, true);
first.tool("execute_command", json!({"command":"comp.new","params":{"name":"Original"}}));
first.tool("save_project", json!({"path":original}));
let original_bytes = std::fs::read(&original).unwrap();
first.tool("execute_command", json!({"command":"layer.newText","params":{"text":"Not manually saved"}}));
let status = first.tool("get_project", json!({}))["autosave"].clone();
first.eof();
assert_eq!(std::fs::read(original).unwrap(), original_bytes);
assert!(std::fs::read_to_string(status["latestPath"].as_str().unwrap()).unwrap().contains("Not manually saved"));
let folder = std::path::Path::new(status["folder"].as_str().unwrap());
let manifest: Value = serde_json::from_str(&std::fs::read_to_string(folder.join("session.json")).unwrap()).unwrap();
assert_eq!(manifest["ended"], true);
assert_eq!(manifest["dirty"], true);
std::fs::remove_dir_all(config).unwrap();
}
#[test]
fn mcp_autosave_preserves_work_when_stdout_is_closed() {
let config = mcp_config("mcp-broken-pipe");
let mut child = bin()
.args(["mcp", "--autosave"])
.env("EFFECTCRAFT_CONFIG_DIR", &config)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
drop(child.stdout.take());
{
let mut input = child.stdin.take().unwrap();
writeln!(input, "{}", json!({"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute_command","arguments":{"command":"comp.new","params":{"name":"Closed stdout"}}}})).unwrap();
}
let out = child.wait_with_output().unwrap();
assert_eq!(out.status.code(), Some(0), "{}", String::from_utf8_lossy(&out.stderr));
let base = config.join(effectcraft_engine::autosave::AUTOSAVE_FOLDER).join("MCP");
let folder = std::fs::read_dir(base).unwrap().next().unwrap().unwrap().path();
let manifest: Value = serde_json::from_str(&std::fs::read_to_string(folder.join("session.json")).unwrap()).unwrap();
assert!(std::fs::read_to_string(manifest["autosave"].as_str().unwrap()).unwrap().contains("Closed stdout"));
assert_eq!(manifest["ended"], true);
std::fs::remove_dir_all(config).unwrap();
}
#[test]
fn mcp_autosave_is_opt_in_and_rejects_invalid_combinations() {
let config = mcp_config("mcp-opt-in");
let mut client = McpChild::start(&config, false);
client.tool("execute_command", json!({"command":"comp.new","params":{"name":"Ephemeral"}}));
client.eof();
assert!(std::fs::read_dir(&config).unwrap().next().is_none());
for args in [["mcp", "--autosave", "--bridge", "1"].as_slice(), ["info", "--autosave"].as_slice()] {
let out = bin().args(args).env("EFFECTCRAFT_CONFIG_DIR", &config).output().unwrap();
assert_eq!(out.status.code(), Some(2));
assert!(String::from_utf8_lossy(&out.stderr).contains("--autosave"));
}
let blocked = config.join("file-not-directory");
std::fs::write(&blocked, "file").unwrap();
let out = bin().args(["mcp", "--autosave"]).env("EFFECTCRAFT_CONFIG_DIR", blocked).output().unwrap();
assert_eq!(out.status.code(), Some(1));
assert!(String::from_utf8_lossy(&out.stderr).contains("MCP auto-save"));
assert!(!String::from_utf8_lossy(&out.stderr).contains("panicked"));
std::fs::remove_dir_all(config).unwrap();
}
/// `render --bitrate` (#440) targets the bitrate in VP9 WebM.
#[test]
fn render_bitrate_applies_to_vp9_webm() {
let root = tmp("webm-bitrate");
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
let project = root.join("x.ecproj");
let p = project.to_str().unwrap();
ok_json(&[
"run",
"comp.new",
r##"{"name":"T","width":320,"height":180,"duration":1}"##,
"layer.newSolid",
r##"{"width":320,"height":180}"##,
"effect.apply",
r##"{"layer":"#1","effect":"ec.noise.fractal"}"##,
"--empty",
"--save-as",
p,
]);
let size = |kbps: &str| {
let out = root.join(format!("b{kbps}.webm"));
ok_json(&["render", "--project", p, "--start", "0", "--end", "0.5", "--bitrate", kbps, "--out", out.to_str().unwrap()]);
std::fs::metadata(&out).unwrap().len()
};
let (low, high) = (size("100"), size("8000"));
assert!(high > low, "100 kbps: {low} bytes, 8000 kbps: {high} bytes");
let _ = std::fs::remove_dir_all(&root);
}

View File

@ -1,7 +1,6 @@
//! M13.15 scenario 5 — Essential Graphics through the CLI: a MOGRT-style template built by an
//! After Effects-style script (`effectcraft-cli script`), its controls changed with commands on
//! an instance, rendered, exported as a template and imported into a fresh project. Direct
//! template rendering uses the same original, procedural fixture under the project licence.
//! an instance, rendered, exported as a template and imported into a fresh project.
use std::path::PathBuf;
use std::process::Command;
@ -112,543 +111,5 @@ fn essential_graphics_template_via_script_and_commands() {
assert!(imp["saved"].is_string(), "{imp}");
let list = ok(&dir, &["exec", "essential.list", r##"{"comp":"Lower Third"}"##, "fresh.ecproj"]);
assert_eq!(list["controls"].as_array().unwrap().len(), 3, "{list}");
// One-shot rendering applies the JSON values to an instance, preserving the template defaults.
let original_template = std::fs::read(dir.join("lt.ectemplate")).unwrap();
std::fs::write(dir.join("values.json"), r##"{"Title":"John Smith","Bar Color":"#00c080","bar opacity":50}"##).unwrap();
ok(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", "values.json", "--time", "1", "--out", "direct.png"]);
assert_eq!(image::open(dir.join("direct.png")).unwrap().to_rgba8(), edit);
ok(&dir, &["frame", "--template", "lt.ectemplate", "--frame", "12", "--out", "defaults.png"]);
assert_eq!(image::open(dir.join("defaults.png")).unwrap().to_rgba8(), plain);
let output = dir.join("sequence/direct_[#####].png");
let rendered = ok(
&dir,
&[
"render",
"--template",
"lt.ectemplate",
"--values",
"values.json",
"--out",
output.to_str().unwrap(),
"--format",
"png",
"--channels",
"rgb",
"--start",
"1",
"--end",
"1.0833333333333333",
],
);
assert_eq!(rendered["rendered"].as_array().unwrap().len(), 1, "{rendered}");
let frames: Vec<_> = std::fs::read_dir(dir.join("sequence"))
.unwrap()
.map(|entry| entry.unwrap().path())
.filter(|path| path.extension().is_some_and(|extension| extension == "png"))
.collect();
assert_eq!(frames.len(), 1, "{frames:?}");
assert_eq!(image::open(&frames[0]).unwrap().to_rgba8(), edit);
assert_eq!(std::fs::read(dir.join("lt.ectemplate")).unwrap(), original_template);
// Invalid values fail before the queue creates an output directory, or frame rendering
// overwrites an existing output. A JSON object and existing control names are required.
for (name, values, expected) in [
("malformed", "{", "invalid JSON"),
("array", "[]", "JSON object"),
("unknown", r#"{"Subtitle":"x"}"#, "controls: Title, Bar Color, Bar Opacity"),
("invalid-value", r#"{"Bar Opacity":"bad"}"#, "can't use"),
] {
let values_file = format!("{name}.json");
std::fs::write(dir.join(&values_file), values).unwrap();
let output_dir = dir.join(format!("failed-{name}"));
let out = output_dir.join("frame_[#####].png");
let (code, error) = cli(&dir, &["render", "--template", "lt.ectemplate", "--values", &values_file, "--out", out.to_str().unwrap()]);
assert_eq!(code, 1, "{name}: {error}");
assert!(error["error"].as_str().unwrap().contains(expected), "{name}: {error}");
assert!(!output_dir.exists(), "{name}: invalid input created render output");
std::fs::write(dir.join("untouched.png"), b"existing output").unwrap();
let (code, error) = cli(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", &values_file, "--out", "untouched.png"]);
assert_eq!(code, 1, "{name}: {error}");
assert_eq!(std::fs::read(dir.join("untouched.png")).unwrap(), b"existing output");
}
let (code, error) = cli(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", "missing-values.json", "--out", "missing.png"]);
assert_eq!(code, 1, "{error}");
assert!(error["error"].as_str().unwrap().contains("cannot read values"), "{error}");
assert!(!dir.join("missing.png").exists());
std::fs::write(dir.join("large-values.json"), vec![b' '; 1024 * 1024 + 1]).unwrap();
let (code, error) = cli(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", "large-values.json", "--out", "large.png"]);
assert_eq!(code, 1, "{error}");
assert!(error["error"].as_str().unwrap().contains("exceeds 1 MiB"), "{error}");
assert!(!dir.join("large.png").exists());
for args in [
vec!["render-frame", "--values", "values.json"],
vec!["info", "--template", "lt.ectemplate"],
vec!["render-frame", "--template", "lt.ectemplate", "--comp", "Lower Third"],
vec!["render-frame", "t.ecproj", "--template", "lt.ectemplate"],
vec!["render", "--template", "lt.ectemplate", "--queue"],
vec!["render-frame", "--template", "lt.ectemplate", "--bridge", "1"],
] {
let output = Command::new(env!("CARGO_BIN_EXE_effectcraft-cli")).current_dir(&dir).args(&args).output().unwrap();
assert_eq!(output.status.code(), Some(2), "{args:?}: {}", String::from_utf8_lossy(&output.stderr));
}
// File-loaded templates can use exact rational rates that comp.new's float parser rounds.
// Preserve that rate, Tick duration, work area, background and locked source layers.
let mut project = effectcraft_engine::project::Project::from_json(&std::fs::read_to_string(dir.join("t.ecproj")).unwrap()).unwrap();
let source = project.find_by_name("Lower Third").unwrap().id;
let rate = effectcraft_time::FrameRate::new(24001, 1001);
let comp = project.comp_mut(source).unwrap();
comp.frame_rate = rate;
comp.duration = rate.tick_of(24);
comp.work_area = (rate.tick_of(3), rate.tick_of(7));
comp.background = [0.08, 0.16, 0.24];
comp.layers.first_mut().unwrap().switches.locked = true;
std::fs::write(dir.join("rational.ecproj"), project.to_json()).unwrap();
ok(&dir, &["exec", "essential.exportTemplate", r#"{"comp":"Lower Third","path":"rational.ectemplate"}"#, "rational.ecproj"]);
let rational_template = std::fs::read(dir.join("rational.ectemplate")).unwrap();
let rational = ok(&dir, &["render-frame", "--template", "rational.ectemplate", "--frame", "5", "--out", "rational.png"]);
assert!((rational["time"].as_f64().unwrap() - 5.0 / rate.as_f64()).abs() < 1e-9, "{rational}");
assert_eq!((rational["width"].as_u64().unwrap(), rational["height"].as_u64().unwrap()), (320, 90));
let output = dir.join("rational-work-area/frame_[#####].png");
ok(&dir, &["render", "--template", "rational.ectemplate", "--work-area", "--out", output.to_str().unwrap(), "--format", "png", "--resolution", "0.05"]);
let count = std::fs::read_dir(dir.join("rational-work-area"))
.unwrap()
.filter_map(Result::ok)
.filter(|entry| entry.path().extension().is_some_and(|extension| extension == "png"))
.count();
assert_eq!(count, 4, "the template's exact work area contains frames 3 through 6");
assert_eq!(std::fs::read(dir.join("rational.ectemplate")).unwrap(), rational_template);
assert_eq!(std::fs::read(dir.join("lt.ectemplate")).unwrap(), original_template);
let _ = std::fs::remove_dir_all(&dir);
}
fn template_output_alias_fixture(case: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("ec-qa-template-output-{case}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("build.jsx"), BUILD).unwrap();
let built = ok(&dir, &["script", "build.jsx", "--save-as", "t.ecproj"]);
assert_eq!(built["output"], "controls: 3", "{built}");
let exported = ok(&dir, &["exec", "essential.exportTemplate", r#"{"comp":"Lower Third","path":"lt.ectemplate"}"#, "t.ecproj"]);
assert_eq!(exported["controls"], 3, "{exported}");
std::fs::write(dir.join("values.json"), r##"{"Title":"John Smith","Bar Color":"#00c080","bar opacity":50}"##).unwrap();
let template_before = std::fs::read(dir.join("lt.ectemplate")).unwrap();
let values_before = std::fs::read(dir.join("values.json")).unwrap();
// The same valid inputs must still render to a distinct output before testing aliases.
ok(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", "values.json", "--time", "1", "--out", "ordinary.png"]);
let image = image::open(dir.join("ordinary.png")).unwrap().to_rgba8();
let bar = image.get_pixel(10, 75).0;
assert!(bar[0] < 15 && (bar[1] as i32 - 96).abs() < 8 && (bar[2] as i32 - 64).abs() < 8, "50 % of #00c080 over black: {bar:?}");
assert_eq!(std::fs::read(dir.join("lt.ectemplate")).unwrap(), template_before);
assert_eq!(std::fs::read(dir.join("values.json")).unwrap(), values_before);
dir
}
fn assert_template_output_preserves_inputs(dir: &PathBuf, out: &str) {
let template_before = std::fs::read(dir.join("lt.ectemplate")).unwrap();
let values_before = std::fs::read(dir.join("values.json")).unwrap();
// Read the inputs before interpreting the subprocess status or error text.
let result = Command::new(env!("CARGO_BIN_EXE_effectcraft-cli"))
.current_dir(dir)
.args(["render-frame", "--template", "lt.ectemplate", "--values", "values.json", "--time", "1", "--out", out, "--json"])
.output()
.unwrap();
let template_after = std::fs::read(dir.join("lt.ectemplate")).unwrap();
let values_after = std::fs::read(dir.join("values.json")).unwrap();
let template_preserved = template_after == template_before;
let values_preserved = values_after == values_before;
eprintln!(
"output={out:?} status={:?} template_preserved={template_preserved} values_preserved={values_preserved} template_bytes={}->{} values_bytes={}->{} stdout={} stderr={}",
result.status.code(),
template_before.len(),
template_after.len(),
values_before.len(),
values_after.len(),
String::from_utf8_lossy(&result.stdout),
String::from_utf8_lossy(&result.stderr),
);
assert!(template_preserved, "template rendering must preserve its template input when --out aliases that file");
assert!(values_preserved, "template rendering must preserve its values input when --out aliases that file");
assert!(!result.status.success(), "an output alias must fail before rendering overwrites an input");
}
#[test]
fn template_frame_output_cannot_replace_its_template_input() {
let dir = template_output_alias_fixture("template");
assert_template_output_preserves_inputs(&dir, "lt.ectemplate");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn template_frame_output_cannot_replace_its_values_input() {
let dir = template_output_alias_fixture("values");
assert_template_output_preserves_inputs(&dir, "values.json");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn template_frame_output_cannot_alias_its_template_through_parent_components() {
let dir = template_output_alias_fixture("normalized-template");
std::fs::create_dir(dir.join("alias")).unwrap();
let out = "alias/../lt.ectemplate";
assert_eq!(dir.join(out).canonicalize().unwrap(), dir.join("lt.ectemplate").canonicalize().unwrap());
assert_template_output_preserves_inputs(&dir, out);
let _ = std::fs::remove_dir_all(&dir);
}
fn timeline_tail_render_pair(dir: &PathBuf, name: &str, duration: effectcraft_time::Tick) {
let mut project = effectcraft_engine::project::Project::from_json(&std::fs::read_to_string(dir.join("base.ecproj")).unwrap()).unwrap();
let source = project.find_by_name("Lower Third").unwrap().id;
let comp = project.comp_mut(source).unwrap();
assert_eq!(comp.frame_rate, effectcraft_time::FrameRate::new(12, 1));
comp.duration = duration;
comp.work_area = (effectcraft_time::Tick::ZERO, duration);
for layer in &mut comp.layers {
layer.out_point = duration;
}
assert!(comp.layers.iter().all(|layer| layer.is_active_at(effectcraft_time::Tick::from_seconds_f64(1.0))));
let project_name = format!("{name}.ecproj");
let template_name = format!("{name}.ectemplate");
let source_frame = format!("{name}-source.png");
let template_frame = format!("{name}-template.png");
std::fs::write(dir.join(&project_name), project.to_json()).unwrap();
let params = serde_json::json!({"comp":"Lower Third", "path":template_name}).to_string();
let exported = ok(dir, &["exec", "essential.exportTemplate", &params, &project_name]);
assert_eq!(exported["controls"], 3, "{exported}");
let project_before = std::fs::read(dir.join(&project_name)).unwrap();
let template_before = std::fs::read(dir.join(&template_name)).unwrap();
let direct = ok(dir, &["render-frame", &project_name, "--comp", "Lower Third", "--time", "1", "--out", &source_frame]);
let instance = ok(dir, &["render-frame", "--template", &template_name, "--time", "1", "--out", &template_frame]);
for result in [&direct, &instance] {
assert_eq!(result["time"].as_f64(), Some(1.0), "{name}: {result}");
assert_eq!((result["width"].as_u64(), result["height"].as_u64()), (Some(320), Some(90)), "{name}: {result}");
}
assert_eq!(std::fs::read(dir.join(&project_name)).unwrap(), project_before, "{name}: rendering preserves the source project bytes");
assert_eq!(std::fs::read(dir.join(&template_name)).unwrap(), template_before, "{name}: rendering preserves the exported template bytes");
let direct = image::open(dir.join(&source_frame)).unwrap().to_rgba8();
let instance = image::open(dir.join(&template_frame)).unwrap().to_rgba8();
assert_eq!(direct.dimensions(), (320, 90));
assert_eq!(instance.dimensions(), direct.dimensions());
let direct_bar = direct.get_pixel(10, 75).0;
let instance_bar = instance.get_pixel(10, 75).0;
assert!(
direct_bar[0] > 240 && direct_bar[1] < 15 && direct_bar[2] < 15,
"{name}: direct source has the authored red bar at the requested valid time: {direct_bar:?}"
);
let different = direct.pixels().zip(instance.pixels()).filter(|(a, b)| a != b).count();
eprintln!("{name}: duration={duration:?}, direct_bar={direct_bar:?}, instance_bar={instance_bar:?}, different_pixels={different}");
assert_eq!(different, 0, "{name}: the template instance must preserve every source pixel at the same valid time");
}
#[test]
fn template_instance_preserves_the_partial_final_source_frame() {
let dir = std::env::temp_dir().join(format!("ec-qa-template-partial-tail-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("build.jsx"), BUILD).unwrap();
let built = ok(&dir, &["script", "build.jsx", "--save-as", "base.ecproj"]);
assert_eq!(built["output"], "controls: 3", "{built}");
// Establish ordinary instance rendering before testing the final partial source frame.
timeline_tail_render_pair(&dir, "aligned", effectcraft_time::Tick::from_seconds_f64(2.0));
let partial = effectcraft_time::Tick(effectcraft_time::TICKS_PER_SECOND * 101 / 100);
assert_eq!(partial.seconds(), 1.01);
timeline_tail_render_pair(&dir, "partial", partial);
let _ = std::fs::remove_dir_all(&dir);
}
// Original procedural motion: the renderer supplies the expected pixels for both states.
const GEOMETRY_BLUR_BUILD: &str = r#"
app.beginUndoGroup("Build Blur Template");
var comp = app.project.items.addComp("Blur Template", 160, 64, 1, 2, 24);
var mover = comp.layers.addSolid([1, 0.3, 0.1], "Mover", 12, 16, 1, 2);
var position = mover.property("ADBE Transform Group").property("ADBE Position");
position.setValueAtTime(0, [16, 32]);
position.setValueAtTime(1, [144, 32]);
position.setInterpolationTypeAtKey(1, KeyframeInterpolationType.LINEAR, KeyframeInterpolationType.LINEAR);
position.setInterpolationTypeAtKey(2, KeyframeInterpolationType.LINEAR, KeyframeInterpolationType.LINEAR);
mover.motionBlur = true;
comp.motionBlur = true;
comp.shutterAngle = 180;
comp.shutterPhase = -90;
mover.property("ADBE Transform Group").property("ADBE Opacity").addToMotionGraphicsTemplateAs(comp, "Opacity");
app.endUndoGroup();
"#;
fn geometry_blur_difference(a: &image::RgbaImage, b: &image::RgbaImage) -> usize {
assert_eq!(a.dimensions(), b.dimensions());
a.pixels().zip(b.pixels()).filter(|(a, b)| a != b).count()
}
fn geometry_blur_assert_inputs(dir: &std::path::Path, inputs: &[(&str, Vec<u8>)]) {
for (name, before) in inputs {
assert_eq!(&std::fs::read(dir.join(name)).unwrap(), before, "template rendering preserves input {name}");
}
}
#[test]
fn template_wrapper_preserves_authored_motion_blur() {
let dir = std::env::temp_dir().join(format!("ec-qa-template-authored-blur-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("motion.jsx"), GEOMETRY_BLUR_BUILD).unwrap();
let built = ok(&dir, &["script", "motion.jsx", "--save-as", "moving.ecproj"]);
assert_eq!(built["ok"], true, "{built}");
// Check the authored switch/key setup, then use an unchanged top-level composition as
// the production pixel oracle. No expected raster or threshold is synthesized here.
let project = effectcraft_engine::project::Project::from_json(&std::fs::read_to_string(dir.join("moving.ecproj")).unwrap()).unwrap();
let source = project.find_by_name("Blur Template").unwrap().id;
let comp = project.comp(source).unwrap();
assert!(comp.enable_motion_blur);
assert_eq!(comp.frame_rate, effectcraft_time::FrameRate::new(24, 1));
assert_eq!(comp.layers.len(), 1);
assert!(comp.layers[0].switches.motion_blur);
assert_eq!(comp.layers[0].props.prop("transform/position").unwrap().keys.len(), 2);
let exported = ok(&dir, &["exec", "essential.exportTemplate", r#"{"comp":"Blur Template","path":"moving.ectemplate"}"#, "moving.ecproj"]);
assert_eq!(exported["controls"], 1, "{exported}");
// The positive control disables only the source layer's authored Motion Blur switch.
// It retains the same geometry, timing, source comp settings and template-control values.
ok(
&dir,
&[
"run",
"moving.ecproj",
"layer.setSwitch",
r#"{"comp":"Blur Template","layers":["Mover"],"switch":"motionBlur","value":false}"#,
"--save-as",
"unblurred.ecproj",
],
);
let exported = ok(&dir, &["exec", "essential.exportTemplate", r#"{"comp":"Blur Template","path":"unblurred.ectemplate"}"#, "unblurred.ecproj"]);
assert_eq!(exported["controls"], 1, "{exported}");
let inputs: Vec<_> = ["moving.ecproj", "moving.ectemplate", "unblurred.ecproj", "unblurred.ectemplate"]
.into_iter()
.map(|name| (name, std::fs::read(dir.join(name)).unwrap()))
.collect();
ok(&dir, &["render-frame", "moving.ecproj", "--comp", "Blur Template", "--time", "0.5", "--transparent", "--out", "direct-blur.png"]);
geometry_blur_assert_inputs(&dir, &inputs);
ok(&dir, &["render-frame", "unblurred.ecproj", "--comp", "Blur Template", "--time", "0.5", "--transparent", "--out", "direct-unblurred.png"]);
geometry_blur_assert_inputs(&dir, &inputs);
ok(&dir, &["render-frame", "--template", "unblurred.ectemplate", "--time", "0.5", "--transparent", "--out", "template-unblurred.png"]);
geometry_blur_assert_inputs(&dir, &inputs);
let blurred = image::open(dir.join("direct-blur.png")).unwrap().to_rgba8();
let unblurred = image::open(dir.join("direct-unblurred.png")).unwrap().to_rgba8();
let template_unblurred = image::open(dir.join("template-unblurred.png")).unwrap().to_rgba8();
assert_eq!(blurred.dimensions(), (160, 64));
assert_eq!(unblurred.dimensions(), (160, 64));
assert!(blurred.pixels().any(|p| p.0[3] > 0), "the direct blurred source is visible");
assert!(unblurred.pixels().any(|p| p.0[3] > 0), "the direct unblurred source is visible");
let authored_blur_difference = geometry_blur_difference(&blurred, &unblurred);
assert!(authored_blur_difference > 0, "the real source renderer must show the authored motion blur");
assert_eq!(template_unblurred, unblurred, "an unblurred template preserves its source pixels");
eprintln!("CLI_TEMPLATE_BLUR_CONTROLS_PASSED source_blur_vs_source_unblurred={authored_blur_difference}");
ok(&dir, &["render-frame", "--template", "moving.ectemplate", "--time", "0.5", "--transparent", "--out", "template-blur.png"]);
geometry_blur_assert_inputs(&dir, &inputs);
let template_blurred = image::open(dir.join("template-blur.png")).unwrap().to_rgba8();
let differing_pixels = geometry_blur_difference(&template_blurred, &blurred);
let unblurred_difference = geometry_blur_difference(&template_blurred, &unblurred);
eprintln!(
"template authored-blur result: source_vs_template_differing_pixels={differing_pixels}, template_vs_unblurred_differing_pixels={unblurred_difference}"
);
assert_eq!(differing_pixels, 0, "template wrapper must preserve authored source motion blur");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn template_unknown_control_does_not_replace_extracted_media() {
let temp_root = std::env::temp_dir().canonicalize().unwrap();
let dir = temp_root.join(format!("ec-qa-template-embedded-media-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("build.jsx"), BUILD).unwrap();
let built = ok(&dir, &["script", "build.jsx", "--save-as", "t.ecproj"]);
assert_eq!(built["output"], "controls: 3", "{built}");
// Both PNGs are generated here; the template uses an ordinary file-backed footage layer.
image::RgbaImage::from_pixel(2, 2, image::Rgba([30, 60, 90, 255])).save(dir.join("source.png")).unwrap();
let imported_reply = ok(&dir, &["exec", "file.import", r#"{"paths":["source.png"]}"#, "t.ecproj", "--save"]);
let imported = &imported_reply["result"];
assert!(imported["errors"].as_array().unwrap().is_empty(), "{imported}");
let items = imported["items"].as_array().unwrap();
assert_eq!(items.len(), 1, "{imported}");
let item = items[0].as_u64().unwrap();
let params = serde_json::json!({"comp": "Lower Third", "item": item, "position": [8, 8], "time": 0}).to_string();
ok(&dir, &["exec", "layer.addItem", &params, "t.ecproj", "--save"]);
let exported = ok(&dir, &["exec", "essential.exportTemplate", r#"{"comp":"Lower Third","path":"lt.ectemplate"}"#, "t.ecproj"]);
assert_eq!(exported["controls"], 3, "{exported}");
let info = ok(&dir, &["exec", "essential.templateInfo", r#"{"path":"lt.ectemplate"}"#, "--empty"]);
let media = info["media"].as_array().unwrap();
assert_eq!(media.len(), 1, "{info}");
let files = media[0]["files"].as_array().unwrap();
assert_eq!(files.len(), 1, "{info}");
let entry = files[0].as_str().unwrap();
let relative = std::path::Path::new(entry.strip_prefix("media/").unwrap());
assert_eq!(relative.components().count(), 2, "{entry}");
assert!(relative.components().all(|part| matches!(part, std::path::Component::Normal(_))), "{entry}");
assert_eq!(relative.file_name().unwrap(), "source.png");
let extracted = dir.join("lt Media").join(relative);
assert!(extracted.starts_with(&dir));
std::fs::write(dir.join("values.json"), r##"{"Title":"John Smith","Bar Color":"#00c080","bar opacity":50}"##).unwrap();
let template_before = std::fs::read(dir.join("lt.ectemplate")).unwrap();
let valid_values_before = std::fs::read(dir.join("values.json")).unwrap();
let source_before = std::fs::read(dir.join("source.png")).unwrap();
ok(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", "values.json", "--time", "1", "--out", "ordinary.png"]);
let ordinary = image::open(dir.join("ordinary.png")).unwrap().to_rgba8();
let bar = ordinary.get_pixel(10, 75).0;
assert!(bar[0] < 15 && (bar[1] as i32 - 96).abs() < 8 && (bar[2] as i32 - 64).abs() < 8, "known control values rendered: {bar:?}");
let footage = ordinary.get_pixel(8, 8).0;
assert!(
(footage[0] as i32 - 30).abs() <= 2 && (footage[1] as i32 - 60).abs() <= 2 && (footage[2] as i32 - 90).abs() <= 2 && footage[3] == 255,
"embedded footage rendered: {footage:?}"
);
assert_eq!(std::fs::read(&extracted).unwrap(), source_before, "ordinary render extracted the declared embedded PNG");
assert_eq!(std::fs::read(dir.join("lt.ectemplate")).unwrap(), template_before);
assert_eq!(std::fs::read(dir.join("values.json")).unwrap(), valid_values_before);
assert_eq!(std::fs::read(dir.join("source.png")).unwrap(), source_before);
let ordinary_before = std::fs::read(dir.join("ordinary.png")).unwrap();
// Keep a different valid PNG at the exact extraction path and an existing render output.
image::RgbaImage::from_pixel(2, 2, image::Rgba([220, 10, 20, 255])).save(&extracted).unwrap();
let sentinel_before = std::fs::read(&extracted).unwrap();
assert_ne!(sentinel_before, source_before);
std::fs::copy(dir.join("ordinary.png"), dir.join("failed.png")).unwrap();
let output_before = std::fs::read(dir.join("failed.png")).unwrap();
std::fs::write(dir.join("bad-values.json"), r#"{"Subtitle":"x"}"#).unwrap();
let invalid_values_before = std::fs::read(dir.join("bad-values.json")).unwrap();
// Capture every file before interpreting status/JSON, so a failed command cannot hide a write.
let result = Command::new(env!("CARGO_BIN_EXE_effectcraft-cli"))
.current_dir(&dir)
.args(["render-frame", "--template", "lt.ectemplate", "--values", "bad-values.json", "--time", "1", "--out", "failed.png", "--json"])
.output()
.unwrap();
let template_after = std::fs::read(dir.join("lt.ectemplate")).unwrap();
let valid_values_after = std::fs::read(dir.join("values.json")).unwrap();
let invalid_values_after = std::fs::read(dir.join("bad-values.json")).unwrap();
let source_after = std::fs::read(dir.join("source.png")).unwrap();
let ordinary_after = std::fs::read(dir.join("ordinary.png")).unwrap();
let output_after = std::fs::read(dir.join("failed.png")).unwrap();
let sentinel_after = std::fs::read(&extracted).unwrap();
let media_preserved = sentinel_after == sentinel_before;
eprintln!(
"EMBEDDED_MEDIA_OBSERVATION status={:?} media_preserved={media_preserved} source_preserved={} output_preserved={} stdout={} stderr={}",
result.status.code(),
source_after == source_before,
output_after == output_before,
String::from_utf8_lossy(&result.stdout),
String::from_utf8_lossy(&result.stderr)
);
assert_eq!(result.status.code(), Some(1));
let reply: Value = serde_json::from_slice(&result.stdout).unwrap();
let error = reply["error"].as_str().unwrap();
assert!(error.contains("no control named `Subtitle`") && error.contains("controls: Title, Bar Color, Bar Opacity"), "{reply}");
assert_eq!(template_after, template_before);
assert_eq!(valid_values_after, valid_values_before);
assert_eq!(invalid_values_after, invalid_values_before);
assert_eq!(source_after, source_before);
assert_eq!(ordinary_after, ordinary_before);
assert_eq!(output_after, output_before);
eprintln!("EMBEDDED_MEDIA_UNKNOWN_CONTROL: valid ordinary render and actual unknown-control failure verified");
assert!(media_preserved, "invalid template values must fail before overwriting previously extracted media");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn template_missing_output_does_not_replace_extracted_media() {
let temp_root = std::env::temp_dir().canonicalize().unwrap();
let dir = temp_root.join(format!("ec-qa-template-missing-output-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("build.jsx"), BUILD).unwrap();
let built = ok(&dir, &["script", "build.jsx", "--save-as", "t.ecproj"]);
assert_eq!(built["output"], "controls: 3", "{built}");
// Both PNGs are generated here; the template uses an ordinary file-backed footage layer.
image::RgbaImage::from_pixel(2, 2, image::Rgba([30, 60, 90, 255])).save(dir.join("source.png")).unwrap();
let imported_reply = ok(&dir, &["exec", "file.import", r#"{"paths":["source.png"]}"#, "t.ecproj", "--save"]);
let imported = &imported_reply["result"];
assert!(imported["errors"].as_array().unwrap().is_empty(), "{imported}");
let items = imported["items"].as_array().unwrap();
assert_eq!(items.len(), 1, "{imported}");
let item = items[0].as_u64().unwrap();
let params = serde_json::json!({"comp": "Lower Third", "item": item, "position": [8, 8], "time": 0}).to_string();
ok(&dir, &["exec", "layer.addItem", &params, "t.ecproj", "--save"]);
let exported = ok(&dir, &["exec", "essential.exportTemplate", r#"{"comp":"Lower Third","path":"lt.ectemplate"}"#, "t.ecproj"]);
assert_eq!(exported["controls"], 3, "{exported}");
let info = ok(&dir, &["exec", "essential.templateInfo", r#"{"path":"lt.ectemplate"}"#, "--empty"]);
let media = info["media"].as_array().unwrap();
assert_eq!(media.len(), 1, "{info}");
let files = media[0]["files"].as_array().unwrap();
assert_eq!(files.len(), 1, "{info}");
let entry = files[0].as_str().unwrap();
let relative = std::path::Path::new(entry.strip_prefix("media/").unwrap());
assert_eq!(relative.components().count(), 2, "{entry}");
assert!(relative.components().all(|part| matches!(part, std::path::Component::Normal(_))), "{entry}");
assert_eq!(relative.file_name().unwrap(), "source.png");
let extracted = dir.join("lt Media").join(relative);
assert!(extracted.starts_with(&dir));
std::fs::write(dir.join("values.json"), r##"{"Title":"John Smith","Bar Color":"#00c080","bar opacity":50}"##).unwrap();
let template_before = std::fs::read(dir.join("lt.ectemplate")).unwrap();
let valid_values_before = std::fs::read(dir.join("values.json")).unwrap();
let source_before = std::fs::read(dir.join("source.png")).unwrap();
ok(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", "values.json", "--time", "1", "--out", "ordinary.png"]);
let ordinary = image::open(dir.join("ordinary.png")).unwrap().to_rgba8();
let bar = ordinary.get_pixel(10, 75).0;
assert!(bar[0] < 15 && (bar[1] as i32 - 96).abs() < 8 && (bar[2] as i32 - 64).abs() < 8, "known control values rendered: {bar:?}");
let footage = ordinary.get_pixel(8, 8).0;
assert!(
(footage[0] as i32 - 30).abs() <= 2 && (footage[1] as i32 - 60).abs() <= 2 && (footage[2] as i32 - 90).abs() <= 2 && footage[3] == 255,
"embedded footage rendered: {footage:?}"
);
assert_eq!(std::fs::read(&extracted).unwrap(), source_before, "ordinary render extracted the declared embedded PNG");
assert_eq!(std::fs::read(dir.join("lt.ectemplate")).unwrap(), template_before);
assert_eq!(std::fs::read(dir.join("values.json")).unwrap(), valid_values_before);
assert_eq!(std::fs::read(dir.join("source.png")).unwrap(), source_before);
let ordinary_before = std::fs::read(dir.join("ordinary.png")).unwrap();
// Keep a different valid PNG at the normal extraction path before a usage error.
image::RgbaImage::from_pixel(2, 2, image::Rgba([220, 10, 20, 255])).save(&extracted).unwrap();
let sentinel_before = std::fs::read(&extracted).unwrap();
assert_ne!(sentinel_before, source_before);
// Observe writes before interpreting the actual CLI usage exit.
let result = Command::new(env!("CARGO_BIN_EXE_effectcraft-cli"))
.current_dir(&dir)
.args(["render", "--template", "lt.ectemplate", "--values", "values.json", "--json"])
.output()
.unwrap();
let template_after = std::fs::read(dir.join("lt.ectemplate")).unwrap();
let values_after = std::fs::read(dir.join("values.json")).unwrap();
let source_after = std::fs::read(dir.join("source.png")).unwrap();
let output_after = std::fs::read(dir.join("ordinary.png")).unwrap();
let sentinel_after = std::fs::read(&extracted).unwrap();
let media_preserved = sentinel_after == sentinel_before;
let stderr = String::from_utf8_lossy(&result.stderr);
eprintln!(
"MISSING_OUTPUT_OBSERVATION status={:?} media_preserved={media_preserved} source_preserved={} output_preserved={} stdout={} stderr={stderr}",
result.status.code(),
source_after == source_before,
output_after == ordinary_before,
String::from_utf8_lossy(&result.stdout),
);
assert_eq!(result.status.code(), Some(2), "{stderr}");
assert!(stderr.contains("render: --out FILE is required (or --queue)"), "{stderr}");
assert_eq!(template_after, template_before);
assert_eq!(values_after, valid_values_before);
assert_eq!(source_after, source_before);
assert_eq!(output_after, ordinary_before);
assert!(!dir.join("frame.png").exists(), "a missing render output must not create a default frame");
eprintln!("MISSING_OUTPUT_REQUIRED_OUT: valid ordinary render/extraction and actual required-output usage failure verified");
assert!(media_preserved, "missing --out must fail before overwriting previously extracted media");
let _ = std::fs::remove_dir_all(&dir);
}

View File

@ -278,8 +278,8 @@ export function audioStop() {
// ------------------------------------------------------------------ workers
// Idle workers are reused; each keeps the files it was sent (path → version) so a file goes to a
// worker once, and again when it was replaced (even by one of the same size, #218).
// Idle workers are reused; each keeps the files it was sent (path → size) so a file goes to a
// worker once.
const idle = [];
const running = new Map(); // job id → {worker, done}
// Job workers open a WebGPU device of their own (unless `?nogpuworkers`): their jobs' frames
@ -310,8 +310,7 @@ function spawn(base) {
return w;
}
/// Run a job in a worker. `files`: [[path, Uint8Array, version]] the job reads (`version`
/// changes with every write of the file); `onMessage(type, json,
/// Run a job in a worker. `files`: [[path, Uint8Array]] the job reads; `onMessage(type, json,
/// path, bytes)` receives "reply" (json), "file" (path, bytes: a rendered file to download),
/// "store" (path, bytes: a file to keep in browser storage) and "error" (json = message).
export function workerRun(id, json, files, base, onMessage) {
@ -340,17 +339,14 @@ export function workerRun(id, json, files, base, onMessage) {
onMessage("error", String(e.message || "worker error"), null, null);
w.worker.terminate();
};
for (const [path, bytes, version] of files) {
const tag = version ?? bytes.length;
if (w.files.get(path) === tag) continue;
w.files.set(path, tag);
for (const [path, bytes] of files) {
if (w.files.get(path) === bytes.length) continue;
w.files.set(path, bytes.length);
w.worker.postMessage({ type: "file", path, bytes });
}
w.worker.postMessage({ type: "job", json });
}, (e) => {
// The worker didn't start (#216): the job fails and the worker goes.
running.delete(id);
w.worker.terminate();
onMessage("error", String(e.message || e), null, null);
});
}
@ -615,72 +611,22 @@ export async function browseRead(path) {
}
/// The fallback where folders can't be opened: pick files (or a whole folder, `directory`) with a
/// plain file input; resolves with [{path, bytes}] (path relative to the picked folder). A file
/// that can't be read is listed as {path, error} instead (#226); cancelling resolves with [].
/// plain file input; resolves with [{path, bytes}] (path relative to the picked folder).
export function browsePickFiles(directory) {
return new Promise((resolve) => {
return new Promise((resolve, reject) => {
const input = document.createElement("input");
input.type = "file";
input.multiple = true;
if (directory) input.webkitdirectory = true;
input.oncancel = () => resolve([]);
input.onchange = async () => {
const out = [];
for (const f of input.files) {
const path = (directory && f.webkitRelativePath) || f.name;
try {
out.push({ path, bytes: new Uint8Array(await f.arrayBuffer()) });
} catch (e) {
out.push({ path, error: String((e && (e.message || e.name)) || e) });
try {
const out = [];
for (const f of input.files) {
out.push({ path: (directory && f.webkitRelativePath) || f.name, bytes: new Uint8Array(await f.arrayBuffer()) });
}
}
resolve(out);
resolve(out);
} catch (e) { reject(e); }
};
input.click();
});
}
/// The page's WebGPU device was lost (#225): the canvas can't show anything new while the project
/// stays open and editable. Say so over the stale picture, and offer to save the project (a
/// download) and to reload; the reload first writes pending changes to browser storage, from
/// which the session comes back when it is `kept` there.
export function showDeviceLost(message, kept) {
if (document.getElementById("device-lost")) return;
const box = document.createElement("div");
box.id = "device-lost";
box.setAttribute("role", "alertdialog");
const title = document.createElement("strong");
title.textContent = "The graphics device was lost";
const text = document.createElement("p");
text.textContent =
"EffectCraft can't update the display any more. Your project is still open. " +
(kept ? "Reload to continue: the project and its unsaved changes come back." : "This session isn't kept in browser storage: save the project before you reload.");
const detail = document.createElement("small");
detail.textContent = message;
const save = document.createElement("button");
save.textContent = "Save Project";
save.onclick = () =>
self.effectcraft
.execute("file.save", {})
.catch(() => self.effectcraft.execute("file.saveAs", { path: "/Untitled Project.ecproj" }))
.catch((e) => (text.textContent = `Saving failed: ${(e && e.message) || e}`));
const reload = document.createElement("button");
reload.textContent = "Reload";
reload.onclick = async () => {
reload.disabled = true;
try {
await self.effectcraft.flush();
} catch (e) {
text.textContent = `Writing to browser storage failed (${(e && e.message) || e}). Save the project, or reload anyway and lose the changes.`;
reload.textContent = "Reload Anyway";
reload.disabled = false;
reload.onclick = () => location.reload();
return;
}
location.reload();
};
const buttons = document.createElement("div");
buttons.append(save, reload);
box.append(title, text, detail, buttons);
document.body.append(box);
}

View File

@ -148,8 +148,7 @@ pub fn remove_stored(path: String) -> bool {
crate::files::STORE.remove_file(&path)
}
/// Resolves once every change is written to browser storage; rejects when a write failed (it is
/// tried again).
/// Resolves once every change is written to browser storage.
#[wasm_bindgen]
pub fn flush() -> js_sys::Promise {
crate::persist::flushed()

View File

@ -11,12 +11,6 @@ use effectcraft_ui_egui::EffectcraftApp;
use serde_json::json;
use wasm_bindgen::prelude::*;
#[wasm_bindgen(module = "/js/host.js")]
extern "C" {
#[wasm_bindgen(js_name = showDeviceLost)]
fn show_device_lost(message: &str, kept: bool);
}
/// Work for the UI thread with access to the app (posted by async tasks: picked files…).
type Action = Box<dyn FnOnce(&mut EffectcraftApp)>;
@ -210,27 +204,6 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
eframe::WebOptions::default(),
Box::new(move |cc| {
CTX.with(|c| *c.borrow_mut() = Some(cc.egui_ctx.clone()));
// The page owns this shared presentation device; worker devices retain their
// separate owned-device handlers. No browser error-scope future is blocked on.
let gpu_failures = effectcraft_ui_egui::gpu_failure::GpuFailureBridge::new(&cc.egui_ctx);
if let Some(rs) = &cc.wgpu_render_state {
let errors = gpu_failures.clone();
rs.device.on_uncaptured_error(Arc::new(move |error| {
let message = format!("uncaptured GPU error: {error}");
if errors.report(&message, false) {
log::error!("{message}");
}
}));
// The canvas can't present any more (#225): the page says so over it and
// offers a reload, which brings the session back when browser storage keeps it.
let lost = gpu_failures.clone();
let kept = !empty && persist::backend() != "memory";
rs.device.set_device_lost_callback(move |reason, message| {
let message = format!("GPU device lost ({reason:?}): {message}");
lost.report(&message, true);
show_device_lost(&message, kept);
});
}
let mut session = session();
session.load_settings();
let recovery = session.begin_recovery();
@ -241,10 +214,7 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
let _ = session.execute("file.openDemoProject", json!({}));
}
api::set_info("restored", json!(restored));
// The ScriptUI panels open on the last visit open again.
let _ = session.execute("window.restoreScriptPanels", json!({}));
let mut app = EffectcraftApp::new(session);
app.set_gpu_failure_bridge(gpu_failures);
app.ui.start_screen = home;
if let Some(r) = recovery.filter(|r| !restored && r.autosave.is_some()) {
app.offer_recovery(r);

View File

@ -105,7 +105,7 @@ fn spawn(p: js_sys::Promise, what: &'static str, then: impl FnOnce(&mut effectcr
crate::post(move |app| then(app, v));
}
Err(e) => {
let msg = crate::persist::js_err(e);
let msg = e.as_string().or_else(|| js_sys::Reflect::get(&e, &"message".into()).ok().and_then(|m| m.as_string())).unwrap_or_default();
// Cancelling a picker is not an error.
if !msg.contains("abort") && !msg.contains("AbortError") {
crate::post(move |app| app.ui.status = format!("{what}: {msg}"));
@ -234,42 +234,20 @@ impl Browser for WebBrowser {
Ok(json!({"pending": true}))
}
"addFiles" | "uploadFolder" => {
let what = if id == "uploadFolder" { "Upload Folder…" } else { "Add Files…" };
let p = pick_files(id == "uploadFolder");
wasm_bindgen_futures::spawn_local(async move {
let list = match wasm_bindgen_futures::JsFuture::from(p).await {
Ok(list) => js_sys::Array::from(&list),
Err(e) => {
let msg = crate::persist::js_err(e);
crate::post(move |app| app.ui.status = format!("{what}: {msg}"));
return;
let Ok(list) = wasm_bindgen_futures::JsFuture::from(p).await else { return };
let mut n = 0;
for f in js_sys::Array::from(&list).iter() {
let path = js_sys::Reflect::get(&f, &"path".into()).ok().and_then(|v| v.as_string());
let bytes = js_sys::Reflect::get(&f, &"bytes".into()).ok().and_then(|b| b.dyn_into::<js_sys::Uint8Array>().ok());
if let (Some(path), Some(bytes)) = (path, bytes) {
crate::files::put(&format!("/{}", path.trim_start_matches('/')), bytes.to_vec().into());
n += 1;
}
};
// Files that can't be read are reported, not dropped silently (#226).
let (mut n, mut unread) = (0, vec![]);
for f in list.iter() {
let get = |key: &str| js_sys::Reflect::get(&f, &key.into()).ok();
let Some(path) = get("path").and_then(|v| v.as_string()) else { continue };
match get("bytes").and_then(|b| b.dyn_into::<js_sys::Uint8Array>().ok()) {
Some(bytes) => {
crate::files::put(&format!("/{}", path.trim_start_matches('/')), bytes.to_vec().into());
n += 1;
}
None => unread.push(match get("error").and_then(|e| e.as_string()).filter(|e| !e.is_empty()) {
Some(why) => format!("{path} ({why})"),
None => path,
}),
}
}
// Cancelled: nothing to report.
if n == 0 && unread.is_empty() {
return;
}
crate::post(move |app| {
app.ui.status = format!("{n} file(s) added to Browser Storage");
if !unread.is_empty() {
app.ui.status += &format!(". Could not read {}: try {what} again", unread.join(", "));
}
go(app, STORAGE);
});
});

View File

@ -35,12 +35,6 @@ pub fn get(path: &str) -> Option<Arc<[u8]>> {
STORE.file(path)
}
/// A file's bytes and version (new with every write: workers resend a file whose version they
/// haven't seen).
pub fn get_versioned(path: &str) -> Option<(Arc<[u8]>, u64)> {
STORE.file_versioned(path)
}
/// `(path, size)` of every file in the table.
pub fn list() -> Vec<(String, usize)> {
STORE.files().into_iter().map(|(p, n, _, _)| (p, n)).collect()
@ -138,15 +132,6 @@ impl Services for WebServices {
}
Ok(())
}
/// The table's files in `dir` (picked and dropped files sit in `/`): picking or dropping a
/// numbered file finds the other frames of its image sequence loaded before.
fn list_dir(&self, dir: &str) -> Vec<String> {
let dir = dir.trim_end_matches('/');
list().into_iter().map(|(p, _)| p).filter(|p| p.rsplit_once('/').is_some_and(|(d, _)| d == dir)).collect()
}
fn is_dir(&self, _path: &str) -> bool {
false
}
}
/// Media import from the table (the bytes are also handed to the media pool, which decodes them).
@ -163,13 +148,6 @@ impl effectcraft_engine::Importer for WebImporter {
fn register(&self, path: &str, data: &[u8]) {
self.pool.add_bytes(path, get(path).unwrap_or_else(|| data.into()));
}
fn add_frames(&self, paths: &[String]) {
for p in paths {
if let Some(bytes) = get(p) {
self.pool.add_bytes(p, bytes);
}
}
}
}
/// Render Queue output: files land in the table and are downloaded after the render

View File

@ -19,7 +19,7 @@
//! frame times. Without WebGPU, frames render on the worker's CPU.
use std::cell::RefCell;
use std::collections::HashMap;
use std::collections::HashSet;
use std::sync::Arc;
use effectcraft_engine::offload::footage_files;
@ -45,8 +45,7 @@ extern "C" {
struct Fw {
index: u32,
mirror: Mirror,
/// The footage files it was sent, with their versions (a replaced file goes again).
files: HashMap<String, u64>,
files: HashSet<String>,
busy: Option<(u64, RemoteDone)>,
alive: bool,
/// Its GPU adapter (after it reported), or why it has none.
@ -100,7 +99,7 @@ impl WebFrames {
let mut s = s.borrow_mut();
for _ in 0..n {
let index = frame_worker_start(&crate::page_url(""), on.as_ref().unchecked_ref(), gpu);
s.workers.push(Fw { index, mirror: Mirror::default(), files: HashMap::new(), busy: None, alive: true, gpu: None });
s.workers.push(Fw { index, mirror: Mirror::default(), files: HashSet::new(), busy: None, alive: true, gpu: None });
}
});
HANDLER.with(|h| *h.borrow_mut() = Some(on));
@ -117,13 +116,14 @@ impl WebFrames {
// Footage the worker hasn't got yet.
let w = &mut s.workers[k];
for p in footage_files(&job.project) {
let Some((d, version)) = crate::files::get_versioned(&p) else { continue };
if w.files.get(&p) == Some(&version) {
if w.files.contains(&p) {
continue;
}
let bytes = js_sys::Uint8Array::from(&d[..]);
frame_worker_post(w.index, &obj(&[("type", "file".into()), ("path", p.as_str().into()), ("bytes", bytes.into())]), &js_sys::Array::new());
w.files.insert(p, version);
if let Some(d) = crate::files::get(&p) {
let bytes = js_sys::Uint8Array::from(&d[..]);
frame_worker_post(w.index, &obj(&[("type", "file".into()), ("path", p.as_str().into()), ("bytes", bytes.into())]), &js_sys::Array::new());
w.files.insert(p);
}
}
let sync = w.mirror.sync(job.revision, &job.project);
let index = w.index;

View File

@ -3,8 +3,7 @@
//!
//! - [`load`] (before the app starts) opens the storage and reads every entry into the mirror.
//! - Changes flush in the background ([`schedule_flush`], wired as the store's change hook):
//! one write per changed key, in order, with nothing written twice concurrently. A write that
//! fails (quota exceeded…) is kept and tried again, after 2 s, then longer, up to a minute.
//! one write per changed key, in order, with nothing written twice concurrently.
//! - The session snapshot ([`snapshot`]) records the open project and editor state every second
//! or so while it changes, so a reload (or a crash) comes back to where the user was
//! ([`restore`]).
@ -38,18 +37,11 @@ thread_local! {
static FLUSHING: Cell<bool> = const { Cell::new(false) };
static BACKEND: std::cell::RefCell<String> = const { std::cell::RefCell::new(String::new()) };
static LAST_ERROR: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
/// [`flushed`] promises waiting for the running flush: (resolve, reject).
static FLUSH_WAITERS: std::cell::RefCell<Vec<(js_sys::Function, js_sys::Function)>> = const { std::cell::RefCell::new(Vec::new()) };
/// Delay before retrying failed writes (ms; doubles while they keep failing).
static RETRY_MS: Cell<i32> = const { Cell::new(RETRY_FIRST_MS) };
static RETRY_PENDING: Cell<bool> = const { Cell::new(false) };
static FLUSH_WAITERS: std::cell::RefCell<Vec<js_sys::Function>> = const { std::cell::RefCell::new(Vec::new()) };
static SNAP: Cell<(u64, f64)> = const { Cell::new((u64::MAX, 0.0)) };
static LAST_META: std::cell::RefCell<String> = const { std::cell::RefCell::new(String::new()) };
}
const RETRY_FIRST_MS: i32 = 2_000;
const RETRY_MAX_MS: i32 = 60_000;
/// The storage in use: `opfs`, `indexeddb` or `memory`.
pub fn backend() -> String {
BACKEND.with(|b| b.borrow().clone())
@ -60,8 +52,7 @@ pub fn last_error() -> Option<String> {
LAST_ERROR.with(|e| e.borrow().clone())
}
/// A JavaScript error (a rejected promise's reason) as text.
pub(crate) fn js_err(e: JsValue) -> String {
fn js_err(e: JsValue) -> String {
e.as_string().or_else(|| js_sys::Reflect::get(&e, &"message".into()).ok().and_then(|m| m.as_string())).unwrap_or_else(|| format!("{e:?}"))
}
@ -95,11 +86,8 @@ pub fn schedule_flush() {
return; // the running flush picks the new changes up
}
wasm_bindgen_futures::spawn_local(async {
// Keys whose write failed in this flush, and the last failure.
let mut failed: Vec<String> = vec![];
let mut error = None;
loop {
let pending: Vec<_> = STORE.lock().take_pending().into_iter().filter(|p| !failed.contains(&p.key)).collect();
let pending = STORE.lock().take_pending();
if pending.is_empty() {
break;
}
@ -111,68 +99,24 @@ pub fn schedule_flush() {
if let Err(e) = r {
let msg = format!("saving {} to browser storage failed: {}", p.key, js_err(e));
log::warn!("{msg}");
LAST_ERROR.with(|l| *l.borrow_mut() = Some(msg.clone()));
error = Some(msg);
failed.push(p.key);
LAST_ERROR.with(|l| *l.borrow_mut() = Some(msg));
}
}
}
// Failed writes stay pending (#215): flushed later, and the waiters hear of the failure
// instead of being told the changes are stored.
{
let mut m = STORE.lock();
for k in &failed {
m.retry(k);
}
}
FLUSHING.with(|f| f.set(false));
let waiters = FLUSH_WAITERS.with(|w| std::mem::take(&mut *w.borrow_mut()));
match &error {
None => {
RETRY_MS.with(|r| r.set(RETRY_FIRST_MS));
for (resolve, _) in waiters {
let _ = resolve.call0(&JsValue::NULL);
}
}
Some(msg) => {
let e: JsValue = js_sys::Error::new(msg).into();
for (_, reject) in waiters {
let _ = reject.call1(&JsValue::NULL, &e);
}
retry_later();
}
for w in FLUSH_WAITERS.with(|w| std::mem::take(&mut *w.borrow_mut())) {
let _ = w.call0(&JsValue::NULL);
}
});
}
/// Flush again after the retry delay (once; the delay doubles up to a minute while writes keep
/// failing).
fn retry_later() {
if RETRY_PENDING.with(|r| r.replace(true)) {
return;
}
let ms = RETRY_MS.with(|r| {
let ms = r.get();
r.set(ms.saturating_mul(2).min(RETRY_MAX_MS));
ms
});
let again = Closure::once_into_js(|| {
RETRY_PENDING.with(|r| r.set(false));
schedule_flush();
});
if let Some(w) = web_sys::window() {
let _ = w.set_timeout_with_callback_and_timeout_and_arguments_0(again.unchecked_ref(), ms);
}
}
/// A promise that resolves once every change so far is in the browser storage, and rejects
/// when a write failed (the change stays pending and is tried again).
/// A promise that resolves once every change so far is in the browser storage.
pub fn flushed() -> js_sys::Promise {
js_sys::Promise::new(&mut |resolve, reject| {
js_sys::Promise::new(&mut |resolve, _| {
if !FLUSHING.with(|f| f.get()) && !STORE.lock().has_pending() {
let _ = resolve.call0(&JsValue::NULL);
} else {
FLUSH_WAITERS.with(|w| w.borrow_mut().push((resolve, reject)));
FLUSH_WAITERS.with(|w| w.borrow_mut().push(resolve));
schedule_flush();
}
})

View File

@ -42,9 +42,6 @@ pub struct Entry {
pub modified: f64,
/// Written to the browser's storage (render outputs are not).
pub persist: bool,
/// Which write this is: new with every write of the key, so a file replaced by another of
/// the same size still reads as changed (workers are sent it again, #218).
pub version: u64,
}
/// A queued write for the browser storage: `data` = `None` deletes the key.
@ -61,21 +58,17 @@ pub struct Mirror {
entries: BTreeMap<String, Entry>,
/// Keys changed since the last [`Mirror::take_pending`], in first-change order.
dirty: Vec<String>,
/// The last [`Entry::version`] given.
version: u64,
}
impl Mirror {
/// An entry read from the browser's storage at startup (nothing to flush).
pub fn load(&mut self, key: &str, data: Arc<[u8]>, modified: f64) {
let version = self.next_version();
self.entries.insert(key.to_string(), Entry { data, modified, persist: true, version });
self.entries.insert(key.to_string(), Entry { data, modified, persist: true });
}
pub fn put(&mut self, key: &str, data: Arc<[u8]>, modified: f64, persist: bool) {
let was_persisted = self.entries.get(key).is_some_and(|e| e.persist);
let version = self.next_version();
self.entries.insert(key.to_string(), Entry { data, modified, persist, version });
self.entries.insert(key.to_string(), Entry { data, modified, persist });
if persist || was_persisted {
self.touch(key);
}
@ -93,16 +86,6 @@ impl Mirror {
}
}
fn next_version(&mut self) -> u64 {
self.version = self.version.wrapping_add(1);
self.version
}
/// A write of `key` that failed: the next flush writes the key's latest state again (#215).
pub fn retry(&mut self, key: &str) {
self.touch(key);
}
fn touch(&mut self, key: &str) {
if !self.dirty.iter().any(|k| k == key) {
self.dirty.push(key.to_string());
@ -189,11 +172,6 @@ impl Store {
self.lock().get(&file_key(path)).map(|e| e.data.clone())
}
/// The file under `path` with its [`Entry::version`].
pub fn file_versioned(&self, path: &str) -> Option<(Arc<[u8]>, u64)> {
self.lock().get(&file_key(path)).map(|e| (e.data.clone(), e.version))
}
pub fn remove_file(&self, path: &str) -> bool {
let r = self.lock().remove(&file_key(path));
self.changed();
@ -434,38 +412,6 @@ mod tests {
assert_eq!(m.list("files").len(), 1);
}
/// A file replaced by different bytes of the same size gets a new version, so workers are
/// sent it again (#218).
#[test]
fn versions_change_with_every_write() {
let s = store();
s.put_file("/collision.png", Arc::from(&b"red!"[..]), true);
let (_, a) = s.file_versioned("/collision.png").unwrap();
s.put_file("/collision.png", Arc::from(&b"blue"[..]), true);
let (data, b) = s.file_versioned("/collision.png").unwrap();
assert_ne!(a, b);
assert_eq!(&data[..], b"blue");
assert_eq!(s.file_versioned("/collision.png").map(|f| f.1), Some(b), "reading doesn't change it");
}
/// A write that failed is flushed again with the key's latest state (#215).
#[test]
fn failed_writes_retry() {
let s = store();
s.put_file("/collision.png", Arc::from(&b"blue"[..]), true);
let mut m = s.lock();
let p = m.take_pending();
assert_eq!(p.len(), 1);
assert!(!m.has_pending());
m.retry(&p[0].key);
assert_eq!(m.take_pending(), p, "the same write again");
// A newer change wins over the failed one.
m.retry("files/collision.png");
m.put("files/collision.png", Arc::from(&b"gold"[..]), 2.0, true);
let again = m.take_pending();
assert_eq!((again.len(), again[0].data.as_deref()), (1, Some(&b"gold"[..])));
}
#[test]
fn keys_and_paths_round_trip() {
assert_eq!(file_key("/a b/c.mov"), "files/a b/c.mov");
@ -531,8 +477,8 @@ mod tests {
a.execute("comp.new", json!({"name": "C"})).unwrap();
// Untitled: the auto-save lands in the store's file table.
let p = a.autosave_now().unwrap();
assert_eq!(Path::new(&p), Path::new("/EffectCraft Auto-Save/Untitled Project auto-save 1.ecproj"));
assert!(s.file(&path_str(Path::new(&p))).is_some());
assert_eq!(p, "/EffectCraft Auto-Save/Untitled Project auto-save 1.ecproj");
assert!(s.file(&p).is_some());
// The tab closes without a clean exit: the next visit offers the auto-save.
let mut b = Session { config: Some(Arc::new(WebConfig::new(s.clone()))), ..Default::default() };
b.load_settings();

View File

@ -60,8 +60,8 @@ impl Offload for WorkerOffload {
let id = req.id;
let files = js_sys::Array::new();
for p in &req.files {
if let Some((d, version)) = crate::files::get_versioned(p) {
files.push(&js_sys::Array::of3(&JsValue::from_str(p), &js_sys::Uint8Array::from(&d[..]), &JsValue::from_f64(version as f64)));
if let Some(d) = crate::files::get(p) {
files.push(&js_sys::Array::of2(&JsValue::from_str(p), &js_sys::Uint8Array::from(&d[..])));
}
}
let json = serde_json::to_string(&req).map_err(|e| e.to_string())?;

View File

@ -1,129 +0,0 @@
// Page-side helpers of the web app (js/host.js) under Node with a fake DOM, no browser or Wasm
// build needed:
//
// node apps/effectcraft-web/tests/page.mjs
//
// - Add Files… / Upload Folder…: a picked file that can't be read is listed with its error
// instead of failing the whole pick silently, and cancelling picks nothing (#226).
// - A lost WebGPU device: the page says so over the canvas and offers Save Project and Reload;
// the reload writes pending changes to browser storage first, and a failed write is reported
// instead of reloading (#225).
// Exits non-zero on failure.
import assert from "node:assert/strict";
import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const tick = () => new Promise((r) => setTimeout(r, 0));
globalThis.self = globalThis;
class FakeElement {
constructor(tag) {
this.tag = tag;
this.children = [];
this.attributes = {};
this.textContent = "";
}
setAttribute(k, v) {
this.attributes[k] = v;
}
append(...c) {
this.children.push(...c);
}
click() {
this.onclick?.();
}
*all() {
yield this;
for (const c of this.children) yield* c.all();
}
}
let picker;
globalThis.document = {
body: new FakeElement("body"),
createElement(tag) {
const el = new FakeElement(tag);
if (tag === "input") {
picker = el;
el.click = () => {};
}
return el;
},
getElementById(id) {
return [...this.body.all()].find((e) => e.id === id) ?? null;
},
};
let reloads = 0;
globalThis.location = { reload: () => reloads++ };
const host = await import(pathToFileURL(join(here, "../js/host.js")));
// #226: one readable and one unreadable file.
const file = (name, read) => ({ name, webkitRelativePath: "", arrayBuffer: read });
const picked = host.browsePickFiles(false);
picker.files = [
file("ok.png", async () => new Uint8Array([1, 2, 3]).buffer),
file("red-corner.png", async () => {
throw new DOMException("Generated unreadable selected file", "NotReadableError");
}),
];
await picker.onchange();
const out = await picked;
assert.equal(out.length, 2);
assert.deepEqual([out[0].path, [...out[0].bytes]], ["ok.png", [1, 2, 3]]);
assert.deepEqual(out[1], { path: "red-corner.png", error: "Generated unreadable selected file" });
const cancelled = host.browsePickFiles(false);
picker.oncancel();
assert.deepEqual(await cancelled, [], "cancelling picks nothing");
// #225: the device-lost notice.
const calls = [];
let flushFails = false;
self.effectcraft = {
flush: async () => {
calls.push("flush");
if (flushFails) throw new Error("quota exceeded");
},
execute: async (command, params) => {
calls.push([command, params]);
if (command === "file.save") throw new Error("no path: use file.saveAs {path}");
},
};
const notice = () => document.getElementById("device-lost");
const buttons = () => [...notice().all()].filter((e) => e.tag === "button");
const text = () => [...notice().all()].find((e) => e.tag === "p").textContent;
host.showDeviceLost("GPU device lost (Destroyed): destroyed", true);
host.showDeviceLost("again", true);
assert.equal(document.body.children.length, 1, "one notice");
assert.equal([...notice().all()].find((e) => e.tag === "small").textContent, "GPU device lost (Destroyed): destroyed");
assert.match(text(), /Reload to continue/);
assert.deepEqual(
buttons().map((b) => b.textContent),
["Save Project", "Reload"],
);
// Save Project: Save, or Save As for an untitled project (a download).
await buttons()[0].onclick();
assert.deepEqual(calls.splice(0), [
["file.save", {}],
["file.saveAs", { path: "/Untitled Project.ecproj" }],
]);
// Reload writes pending changes first.
await buttons()[1].onclick();
assert.deepEqual(calls.splice(0), ["flush"]);
assert.equal(reloads, 1);
// A failed write is reported instead of reloading; Reload Anyway reloads.
document.body.children = [];
flushFails = true;
host.showDeviceLost("GPU device lost (Unknown): gone", false);
assert.match(text(), /isn't kept in browser storage/);
await buttons()[1].onclick();
await tick();
assert.equal(reloads, 1, "no reload after a failed write");
assert.match(text(), /quota exceeded/);
assert.equal(buttons()[1].textContent, "Reload Anyway");
buttons()[1].onclick();
assert.equal(reloads, 2);
console.log("page: ok");

View File

@ -1,101 +0,0 @@
// Job-worker plumbing of the web app (js/host.js, web/worker.js) under Node with a fake Worker,
// no browser or Wasm build needed:
//
// node apps/effectcraft-web/tests/workers.mjs
//
// - A reused worker is sent a file again when it was replaced, even by one of the same size
// (#218), and not when it is unchanged.
// - A worker whose start fails fails its job and is terminated, instead of the job waiting for
// "ready" forever (#216): host.js on the worker's error, worker.js re-raising the failed start.
// Exits non-zero on failure.
import assert from "node:assert/strict";
import { copyFileSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const tick = () => new Promise((r) => setTimeout(r, 0));
globalThis.self = globalThis;
// A Worker that starts (or fails to) on "init" and finishes each job at once.
let failStart = false;
const workers = [];
class FakeWorker {
constructor() {
this.posted = [];
this.terminated = false;
workers.push(this);
}
postMessage(m) {
this.posted.push(m);
setTimeout(() => {
if (m.type === "init") {
if (failStart) this.onerror?.({ message: "worker failed to start" });
else this.onmessage?.({ data: { type: "ready" } });
} else if (m.type === "job") {
this.onmessage?.({ data: { type: "reply", json: "{}", done: true } });
}
});
}
terminate() {
this.terminated = true;
}
}
globalThis.Worker = FakeWorker;
const host = await import(pathToFileURL(join(here, "../js/host.js")));
const run = (id, files) =>
new Promise((resolve) => host.workerRun(id, "{}", files, "http://localhost/", (kind, json) => kind !== "file" && resolve({ kind, json })));
const sent = (w) => w.posted.filter((m) => m.type === "file").map((m) => m.bytes[0]);
// #218: equal-length replacement.
const red = new Uint8Array(512).fill(1);
const blue = new Uint8Array(512).fill(2);
assert.equal((await run(1, [["/collision.png", red, 1]])).kind, "reply");
assert.equal((await run(2, [["/collision.png", blue, 2]])).kind, "reply");
assert.equal((await run(3, [["/collision.png", blue, 2]])).kind, "reply");
assert.equal(workers.length, 1, "the worker is reused");
assert.deepEqual(sent(workers[0]), [1, 2], "the replacement is sent once, the unchanged file not again");
// #216: a worker that fails to start.
failStart = true;
const slow = run(4, []); // the idle worker takes this one
const failed = await run(5, []);
await slow;
assert.equal(failed.kind, "error");
assert.ok(workers[1].terminated, "the failed worker is terminated");
assert.equal(host.workerStats().running, 0);
// #216, worker side: a rejected start is re-raised where the page's `onerror` sees it.
const dir = mkdtempSync(join(tmpdir(), "ec-worker-"));
copyFileSync(join(here, "../web/worker.js"), join(dir, "worker.js"));
writeFileSync(
join(dir, "effectcraft_web.js"),
`export default async function init() {}
export function workerInit() {}
export async function workerJobInit() { throw new Error("no device"); }
export async function workerFrameInit() {}`,
);
const posted = [];
globalThis.postMessage = (m) => posted.push(m);
// A browser's Worker `error` event only sees thrown errors, not unhandled rejections (Node would
// turn one into an uncaught exception unless it is listened to).
const unhandled = [];
process.on("unhandledRejection", (e) => unhandled.push(e));
const raised = new Promise((resolve) => {
process.once("uncaughtException", resolve);
setTimeout(() => resolve(new Error("nothing was raised")), 1000);
});
await import(pathToFileURL(join(dir, "worker.js")));
self.onmessage({ data: { type: "init", base: "http://localhost/" } });
const err = await raised;
await tick();
// (So a failed assertion below fails the run.)
process.removeAllListeners("unhandledRejection");
process.removeAllListeners("uncaughtException");
assert.deepEqual(unhandled, [], "the failed start is handled, not left as an unhandled rejection");
assert.equal(err.message, "no device");
assert.ok(!posted.some((m) => m.type === "ready"), "no ready after a failed start");
console.log("workers: ok");

View File

@ -20,23 +20,6 @@
}
#loading img { width: 56px; height: 56px; }
#loading .err { color: #ff8a7a; white-space: pre-wrap; max-width: 70ch; pointer-events: auto; }
#device-lost {
position: absolute; inset: 0; display: flex; flex-direction: column; gap: 10px;
align-items: center; justify-content: center; padding: 16px; text-align: center;
background: rgb(18 18 18 / 0.85); color: #c8c8c8; font: 14px/1.4 system-ui, sans-serif;
}
#device-lost strong { color: #e3e3e3; font-size: 16px; }
#device-lost p { margin: 0; max-width: 60ch; }
#device-lost small { color: #8c8c8c; font-size: 12px; }
#device-lost div { display: flex; gap: 8px; }
#device-lost button {
padding: 6px 14px; border: 1px solid #3a3a3a; border-radius: 4px;
background: #232323; color: #e3e3e3; font: inherit; cursor: pointer;
}
#device-lost button:hover { background: #303030; }
#device-lost button:last-child { background: #2d8ceb; border-color: #2d8ceb; }
#device-lost button:last-child:hover { background: #4aa0f5; }
#device-lost button:disabled { opacity: 0.6; cursor: default; }
</style>
</head>
<body>

View File

@ -30,9 +30,6 @@ self.onmessage = (e) => {
else await ec.workerJobInit(!!m.gpu);
self.postMessage({ type: "ready" });
})();
// A failed start is re-raised outside the promise so the page's `onerror` sees it: its job
// fails instead of waiting for "ready" forever (#216).
ready.catch((err) => setTimeout(() => { throw err; }));
return;
}
// A failure (a panic) is re-raised outside the queue so the page's `onerror` sees it, as

View File

@ -14,28 +14,19 @@ 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.
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "links", "wayland", "x11"] }
serde = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
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 }
log = { workspace = true }
[target.'cfg(target_os = "linux")'.dependencies]
# Settings ▸ Appearance ▸ Sync with System: the XDG desktop portal's colour scheme. Already in
# Cargo.lock (accesskit, through eframe), so it adds no new crates.
zbus = "5.19"
[build-dependencies]
# Icon + VERSIONINFO for effectcraft.exe; build.rs only uses it when targeting Windows.
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.
@ -43,8 +34,3 @@ muda = { version = "0.21", default-features = false }
objc2 = "0.6"
# NSApplication hide / hide others / show all, and the pasteboard for Edit ▸ Paste in text fields.
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSPasteboard"] }
# Finder double-click, Open With, Dock drops and `open -a EffectCraft x.ecproj` arrive as a
# kAEOpenDocuments Apple event, which winit 0.30 doesn't handle. This audited crate (also used by
# PhotoCraft and PdfCraft) registers the handler (Objective-C interop needs unsafe, which our
# workspace forbids) behind a safe API. Pinned exactly: review the diff before bumping.
fmv-macos-events = "=0.1.0"

View File

@ -1,154 +0,0 @@
//! The desktop's light or dark appearance on Linux, for Settings ▸ Appearance ▸ Appearance Mode ▸
//! Sync with System. Wayland compositors often report no theme to winit, so this reads the XDG
//! desktop portal instead. Other platforms use what egui reports.
#[cfg(target_os = "linux")]
const PORTAL_NAMESPACE: &str = "org.freedesktop.appearance";
#[cfg(target_os = "linux")]
const PORTAL_KEY: &str = "color-scheme";
#[cfg(target_os = "linux")]
fn portal_proxy() -> Option<zbus::blocking::Proxy<'static>> {
let conn = zbus::blocking::connection::Builder::session().ok()?.method_timeout(std::time::Duration::from_secs(2)).build().ok()?;
zbus::blocking::Proxy::new(&conn, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Settings").ok()
}
#[cfg(target_os = "linux")]
fn portal_theme(proxy: &zbus::blocking::Proxy<'_>) -> Option<egui::Theme> {
let value: zbus::zvariant::OwnedValue = proxy.call("Read", &(PORTAL_NAMESPACE, PORTAL_KEY)).ok()?;
decode(portal_code(value)?)
}
/// The colour-scheme number inside a portal reply.
#[cfg(target_os = "linux")]
fn portal_code(value: zbus::zvariant::OwnedValue) -> Option<u32> {
let mut value: zbus::zvariant::Value<'_> = value.into();
// `Settings.Read` answers with a variant, and some portals nest another variant inside it.
for _ in 0..4 {
match value {
zbus::zvariant::Value::Value(inner) => value = *inner,
other => return u32::try_from(other).ok(),
}
}
None
}
/// The portal's colour scheme: 1 prefers dark, 2 prefers light, 0 (or anything else) says nothing.
#[cfg(target_os = "linux")]
fn decode(code: u32) -> Option<egui::Theme> {
match code {
1 => Some(egui::Theme::Dark),
2 => Some(egui::Theme::Light),
_ => None,
}
}
/// Fallback for desktops without the settings portal. It runs once at start-up, by absolute path,
/// and is never repeated.
#[cfg(target_os = "linux")]
fn gtk_theme() -> Option<egui::Theme> {
let exe = ["/usr/bin/gsettings", "/bin/gsettings", "/usr/local/bin/gsettings"].into_iter().find(|p| std::path::Path::new(p).is_file())?;
let output = std::process::Command::new(exe).args(["get", "org.gnome.desktop.interface", "color-scheme"]).output().ok()?;
if !output.status.success() {
return None;
}
parse_gtk_scheme(std::str::from_utf8(&output.stdout).ok()?)
}
#[cfg(target_os = "linux")]
fn parse_gtk_scheme(s: &str) -> Option<egui::Theme> {
match s.trim().trim_matches('\'') {
"prefer-dark" => Some(egui::Theme::Dark),
"prefer-light" => Some(egui::Theme::Light),
_ => None,
}
}
#[cfg(target_os = "linux")]
fn code_of(theme: Option<egui::Theme>) -> u8 {
match theme {
Some(egui::Theme::Dark) => 1,
Some(egui::Theme::Light) => 2,
None => 0,
}
}
/// Reads the colour scheme once, then follows the portal's `SettingChanged` signal. There is no
/// polling: the worker sleeps in the signal iterator and wakes the UI only when the value changes.
pub fn service() -> Option<effectcraft_ui_egui::SystemThemeFn> {
#[cfg(target_os = "linux")]
{
use std::sync::{
Arc, OnceLock,
atomic::{AtomicU8, Ordering},
};
let value = Arc::new(AtomicU8::new(0));
let wake: Arc<OnceLock<egui::Context>> = Arc::new(OnceLock::new());
let (worker_value, worker_wake) = (Arc::clone(&value), Arc::clone(&wake));
let (ready, wait) = std::sync::mpsc::channel();
if std::thread::Builder::new()
.name("appearance-portal".into())
.spawn(move || {
let proxy = portal_proxy();
// Subscribe before reading so a change during startup stays queued.
let signals = proxy.as_ref().and_then(|p| p.receive_signal("SettingChanged").ok());
let initial = proxy.as_ref().and_then(portal_theme).or_else(gtk_theme);
// Startup can finish after the UI's bounded wait: wake its first frame too.
if worker_value.swap(code_of(initial), Ordering::Relaxed) != code_of(initial)
&& let Some(ctx) = worker_wake.get()
{
ctx.request_repaint();
}
let _ = ready.send(());
let Some(signals) = signals else { return };
for message in signals {
let Ok((namespace, key, changed)) = message.body().deserialize::<(String, String, zbus::zvariant::OwnedValue)>() else {
continue;
};
if namespace != PORTAL_NAMESPACE || key != PORTAL_KEY {
continue;
}
let code = code_of(portal_code(changed).and_then(decode));
if worker_value.swap(code, Ordering::Relaxed) != code
&& let Some(ctx) = worker_wake.get()
{
ctx.request_repaint();
}
}
})
.is_err()
{
return None;
}
// The first frame already has the answer when the portal replies quickly.
let _ = wait.recv_timeout(std::time::Duration::from_millis(250));
Some(Box::new(move |ctx| {
let _ = wake.set(ctx.clone());
decode(u32::from(value.load(Ordering::Relaxed)))
}))
}
#[cfg(not(target_os = "linux"))]
{
None
}
}
#[cfg(test)]
mod tests {
#[cfg(target_os = "linux")]
#[test]
fn portal_and_gsettings_values_decode() {
assert_eq!(super::decode(0), None);
assert_eq!(super::decode(1), Some(egui::Theme::Dark));
assert_eq!(super::decode(2), Some(egui::Theme::Light));
assert_eq!(super::decode(9), None);
let nested = zbus::zvariant::Value::Value(Box::new(zbus::zvariant::Value::Value(Box::new(zbus::zvariant::Value::U32(2)))));
assert_eq!(super::portal_code(zbus::zvariant::OwnedValue::try_from(nested).unwrap()), Some(2));
assert_eq!(super::portal_code(zbus::zvariant::OwnedValue::from(1u32)), Some(1));
assert_eq!(super::portal_code(zbus::zvariant::OwnedValue::try_from(zbus::zvariant::Value::from("dark")).unwrap()), None);
assert_eq!(super::parse_gtk_scheme("'prefer-light'\n"), Some(egui::Theme::Light));
assert_eq!(super::parse_gtk_scheme("'prefer-dark'\n"), Some(egui::Theme::Dark));
assert_eq!(super::parse_gtk_scheme("'default'\n"), None);
assert_eq!(super::code_of(super::decode(2)), 2);
}
}

View File

@ -1,132 +0,0 @@
//! Desktop integration for the AppImage. Wayland compositors (GNOME, KDE…) show a window's icon
//! from the desktop entry named by its app ID (`ai.storyteller.effectcraft.desktop`). The .deb,
//! .rpm and Flatpak packages install one; an AppImage installs nothing, so its window got the
//! generic icon. Started from an AppImage (`$APPIMAGE`), the app adds a hidden entry
//! (`NoDisplay=true`: no menu item) pointing at it and its icon under the user's data folder
//! (`$XDG_DATA_HOME`, else `~/.local/share`). Nothing is written when another entry already
//! provides the ID (an installed package, or the user's own), and an entry it wrote is kept up to
//! date when the AppImage moves. Failures are only logged: the icon is cosmetic.
use std::path::{Path, PathBuf};
pub const APP_ID: &str = "ai.storyteller.effectcraft";
/// Marks entries this module wrote (it never touches others).
const MARKER: &str = "X-EffectCraft-AppImage=true";
/// A path as one `Exec` argument (desktop entry spec: quoted, with `"`, `` ` ``, `$` and `\`
/// escaped; `%` doubled so it isn't a field code).
pub fn exec_arg(path: &str) -> String {
let mut s = String::with_capacity(path.len() + 2);
s.push('"');
for c in path.chars() {
// The quoting escape is a backslash, which the key file's string syntax escapes again:
// `"` is written `\\"`, a backslash `\\\\`.
match c {
'"' | '`' | '$' => {
s.push_str("\\\\");
s.push(c);
}
'\\' => s.push_str("\\\\\\\\"),
'%' => s.push_str("%%"),
'\n' | '\r' => {}
c => s.push(c),
}
}
s.push('"');
s
}
/// The hidden desktop entry for the AppImage at `appimage`.
pub fn entry(appimage: &str) -> String {
format!(
"[Desktop Entry]\nType=Application\nName=EffectCraft\nComment=Motion graphics and visual effects compositor\nExec={} %F\nIcon={APP_ID}\nTerminal=false\nStartupWMClass={APP_ID}\nNoDisplay=true\n{MARKER}\n",
exec_arg(appimage)
)
}
/// Write the entry and `icon` (a 256 px PNG) under `data_home`, unless one of `data_dirs` (or
/// `data_home` itself, with an entry we didn't write) already has the ID's entry. Returns whether
/// anything was written.
pub fn integrate(appimage: &str, data_home: &Path, data_dirs: &[PathBuf], icon: &[u8]) -> std::io::Result<bool> {
let name = format!("{APP_ID}.desktop");
if data_dirs.iter().any(|d| d.join("applications").join(&name).exists()) {
return Ok(false);
}
let path = data_home.join("applications").join(&name);
let text = entry(appimage);
let icon_path = data_home.join("icons/hicolor/256x256/apps").join(format!("{APP_ID}.png"));
match std::fs::read_to_string(&path) {
Ok(old) if !old.contains(MARKER) => return Ok(false),
Ok(old) if old == text && icon_path.exists() => return Ok(false),
_ => {}
}
write_atomic(&icon_path, icon)?;
write_atomic(&path, text.as_bytes())?;
Ok(true)
}
fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let tmp = path.with_extension("part");
std::fs::write(&tmp, bytes)?;
std::fs::rename(&tmp, path)
}
/// [`integrate`] for this process: when started from an AppImage, with the XDG data folders of
/// the environment.
pub fn integrate_from_env(icon: &[u8]) {
let Some(appimage) = std::env::var_os("APPIMAGE").map(|a| a.to_string_lossy().into_owned()) else { return };
let env_path = |k: &str| std::env::var_os(k).filter(|v| !v.is_empty()).map(PathBuf::from);
let Some(data_home) = env_path("XDG_DATA_HOME").or_else(|| env_path("HOME").map(|h| h.join(".local/share"))) else { return };
let dirs = std::env::var("XDG_DATA_DIRS").ok().filter(|v| !v.is_empty()).unwrap_or_else(|| "/usr/local/share:/usr/share".into());
let data_dirs: Vec<PathBuf> = dirs.split(':').filter(|d| !d.is_empty()).map(PathBuf::from).collect();
match integrate(&appimage, &data_home, &data_dirs, icon) {
Ok(true) => log::info!("AppImage: desktop entry and icon written to {}", data_home.display()),
Ok(false) => {}
Err(e) => log::warn!("AppImage: could not write the desktop entry: {e}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exec_arguments_are_quoted_and_escaped() {
assert_eq!(exec_arg("/home/a/EffectCraft.AppImage"), "\"/home/a/EffectCraft.AppImage\"");
assert_eq!(exec_arg("/a b/$x\"y`z\\w%"), "\"/a b/\\\\$x\\\\\"y\\\\`z\\\\\\\\w%%\"");
let e = entry("/opt/EffectCraft.AppImage");
assert!(e.contains("Exec=\"/opt/EffectCraft.AppImage\" %F\n") && e.contains("NoDisplay=true\n") && e.contains("Icon=ai.storyteller.effectcraft\n"));
}
#[test]
fn writes_once_follows_moves_and_leaves_other_entries_alone() {
let root = std::env::temp_dir().join(format!("ec-appimage-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let (home, system) = (root.join("home"), root.join("system"));
let entry_path = home.join("applications/ai.storyteller.effectcraft.desktop");
let icon_path = home.join("icons/hicolor/256x256/apps/ai.storyteller.effectcraft.png");
// First start: entry and icon written; again: nothing to do.
assert!(integrate("/a/EffectCraft.AppImage", &home, std::slice::from_ref(&system), b"png").unwrap());
assert_eq!(std::fs::read(&icon_path).unwrap(), b"png");
assert!(std::fs::read_to_string(&entry_path).unwrap().contains("Exec=\"/a/EffectCraft.AppImage\""));
assert!(!integrate("/a/EffectCraft.AppImage", &home, std::slice::from_ref(&system), b"png").unwrap());
// The AppImage moved: the entry follows it.
assert!(integrate("/b/EffectCraft.AppImage", &home, std::slice::from_ref(&system), b"png").unwrap());
assert!(std::fs::read_to_string(&entry_path).unwrap().contains("Exec=\"/b/EffectCraft.AppImage\""));
// The user's own entry is never replaced.
std::fs::write(&entry_path, "[Desktop Entry]\nName=Mine\n").unwrap();
assert!(!integrate("/c/EffectCraft.AppImage", &home, std::slice::from_ref(&system), b"png").unwrap());
assert_eq!(std::fs::read_to_string(&entry_path).unwrap(), "[Desktop Entry]\nName=Mine\n");
// An installed package provides the entry: nothing is written.
let _ = std::fs::remove_dir_all(&home);
std::fs::create_dir_all(system.join("applications")).unwrap();
std::fs::write(system.join("applications/ai.storyteller.effectcraft.desktop"), "x").unwrap();
assert!(!integrate("/a/EffectCraft.AppImage", &home, std::slice::from_ref(&system), b"png").unwrap());
assert!(!entry_path.exists());
let _ = std::fs::remove_dir_all(&root);
}
}

View File

@ -1,80 +0,0 @@
//! macOS open-documents and quit Apple events.
//!
//! Finder double-clicks, Open With, drops on the Dock icon and `open -a EffectCraft x.ecproj` don't
//! pass paths on the command line: LaunchServices sends the running (or just-launched) app a
//! `kAEOpenDocuments` ('odoc') Apple event. winit 0.30 doesn't handle it and owns the
//! `NSApplicationDelegate`, so the project never opened. Handling it ourselves needs Objective-C
//! class declarations, i.e. `unsafe`, which this workspace forbids. The audited `fmv-macos-events`
//! crate (also used by PhotoCraft and PdfCraft) wraps exactly that, an `NSAppleEventManager`
//! handler registered before Finder's launch event that leaves winit's delegate alone, behind a
//! safe main-thread API.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use fmv_macos_events::{Event, Inbox, Registration};
/// Keeps the Apple-event handlers registered; hold it until the event loop returns.
pub struct AppleEvents {
_registration: Registration,
inbox: Inbox,
}
impl AppleEvents {
/// Register the handlers. Call on the main thread before the event loop starts, so the event
/// that launched the app (a Finder double-click) is caught too.
pub fn install() -> Self {
let (registration, inbox) = Registration::install();
Self { _registration: registration, inbox }
}
/// The queue the app drains every frame ([`Opened::feed`]); events arriving later wake `ctx`.
pub fn connect(&self, ctx: &egui::Context) -> Opened {
let ctx = ctx.clone();
self.inbox.set_wake(move || ctx.request_repaint());
Opened { inbox: self.inbox.clone(), pending: Vec::new() }
}
}
/// Documents macOS asked us to open, waiting for the UI to take them.
pub struct Opened {
inbox: Inbox,
/// Paths that arrived before the UI was ready (the Finder event that launched the app comes
/// before the first frame is drawn).
pending: Vec<PathBuf>,
}
impl Opened {
/// Hand the documents to the dropped-file handling once the UI is `ready` (a project opens,
/// asking about unsaved changes first; media is imported), and act on quit requests.
pub fn feed(&mut self, ready: bool, ctx: &egui::Context, raw: &mut egui::RawInput) {
for e in self.inbox.drain() {
match e {
Event::Open(paths) => self.pending.extend(paths),
Event::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
}
}
if self.pending.is_empty() {
return;
}
if !ready {
ctx.request_repaint();
return;
}
raw.dropped_files.extend(self.pending.drain(..).map(|p| Arc::new(OpenedFile(p)) as egui::DroppedFileHandle));
}
}
/// A document macOS asked us to open, presented to egui as a dropped file.
#[derive(Debug)]
struct OpenedFile(PathBuf);
impl egui::DroppedFile for OpenedFile {
fn path(&self) -> &Path {
&self.0
}
fn bytes(&self) -> Result<Vec<u8>, String> {
std::fs::read(&self.0).map_err(|e| e.to_string())
}
}

View File

@ -10,32 +10,11 @@ use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::mpsc;
use std::thread::JoinHandle;
use std::time::Duration;
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use cpal::{FromSample, SizedSample};
use effectcraft_ui_egui::audio::{AudioDevice, AudioFeed, AudioOutput};
/// Device callback period: about 10 ms, clamped so Pulse/PipeWire/ALSA does not open a
/// multi-hundred-millisecond default buffer that plays as silence at the start of preview (#554).
fn preferred_buffer_frames(rate: u32) -> u32 {
(rate / 100).clamp(256, 2048)
}
/// Wait until the feeder has mixed a block, or until preview is stopped. Starting the stream
/// before that fills the device with silence, so the first real samples are heard late (#554).
fn wait_to_play(feed: &AudioFeed, stop: &mpsc::Receiver<()>) -> bool {
loop {
if feed.queued_frames() > 0 {
return true;
}
match stop.recv_timeout(Duration::from_millis(2)) {
Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => return false,
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
}
}
pub struct CpalOut {
out: AudioOutput,
rate: u32,
@ -97,7 +76,7 @@ fn run<T: SizedSample + FromSample<f32>>(
let frames = out.len() / ch.max(1);
stereo.resize(frames * 2, 0.0);
feed.pull(&mut stereo);
for (f, s) in out.chunks_mut(ch.max(1)).zip(stereo.as_chunks::<2>().0.iter()) {
for (f, s) in out.chunks_mut(ch.max(1)).zip(stereo.chunks_exact(2)) {
for (c, o) in f.iter_mut().enumerate() {
// Mono devices, or left and right mapped to the same channel, get the mix.
let v = if ch == 1 || (c == left && c == right) {
@ -117,7 +96,7 @@ fn run<T: SizedSample + FromSample<f32>>(
let dev_lat = ts.playback.duration_since(&ts.callback).map(|d| d.as_secs_f64()).unwrap_or(0.0);
latency.store((dev_lat * rate) as u64 + frames as u64, Ordering::Relaxed);
},
|e| log::warn!("audio output: {e}"),
|e| eprintln!("effectcraft: audio output: {e}"),
None,
)
.map_err(|e| e.to_string())
@ -152,44 +131,21 @@ impl AudioDevice for CpalOut {
};
let mut cfg: cpal::StreamConfig = sup.config();
cfg.sample_rate = cpal::SampleRate(rate);
// A short period only where the default one is long (ALSA/PulseAudio on Linux, #554),
// and only within what the device reports it takes.
if cfg!(target_os = "linux") {
let want = preferred_buffer_frames(rate);
cfg.buffer_size = match sup.buffer_size() {
cpal::SupportedBufferSize::Range { min, max } if *min <= *max => cpal::BufferSize::Fixed(want.clamp(*min, *max)),
_ => cpal::BufferSize::Default,
};
}
let build = |cfg: &cpal::StreamConfig| match sup.sample_format() {
cpal::SampleFormat::F32 => run::<f32>(&dev, cfg, feed.clone(), latency.clone(), map),
cpal::SampleFormat::I16 => run::<i16>(&dev, cfg, feed.clone(), latency.clone(), map),
cpal::SampleFormat::U16 => run::<u16>(&dev, cfg, feed.clone(), latency.clone(), map),
cpal::SampleFormat::I32 => run::<i32>(&dev, cfg, feed.clone(), latency.clone(), map),
let stream = match sup.sample_format() {
cpal::SampleFormat::F32 => run::<f32>(&dev, &cfg, feed, latency, map),
cpal::SampleFormat::I16 => run::<i16>(&dev, &cfg, feed, latency, map),
cpal::SampleFormat::U16 => run::<u16>(&dev, &cfg, feed, latency, map),
cpal::SampleFormat::I32 => run::<i32>(&dev, &cfg, feed, latency, map),
f => Err(format!("unsupported sample format {f:?}")),
};
let stream = match build(&cfg) {
let stream = match stream.and_then(|s| s.play().map(|_| s).map_err(|e| e.to_string())) {
Ok(s) => s,
Err(_) => {
// Some hosts reject a fixed period; a large default still plays, just later.
cfg.buffer_size = cpal::BufferSize::Default;
match build(&cfg) {
Ok(s) => s,
Err(e) => {
let _ = ready_tx.send(Err(e));
return;
}
}
Err(e) => {
let _ = ready_tx.send(Err(e));
return;
}
};
let _ = ready_tx.send(Ok(()));
if !wait_to_play(&feed, &stop_rx) {
return;
}
if let Err(e) = stream.play() {
log::warn!("audio output: {e}");
return;
}
let _ = stop_rx.recv();
drop(stream);
})
@ -218,37 +174,3 @@ impl Drop for CpalOut {
self.stop();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn preferred_buffer_is_about_ten_milliseconds() {
assert_eq!(preferred_buffer_frames(48_000), 480);
assert_eq!(preferred_buffer_frames(8_000), 256);
assert_eq!(preferred_buffer_frames(192_000), 1920);
}
#[test]
fn wait_to_play_returns_once_the_feeder_has_samples() {
let feed = Arc::new(AudioFeed::default());
let (_tx, rx) = mpsc::channel();
let f = feed.clone();
let h = std::thread::spawn(move || wait_to_play(&f, &rx));
assert_eq!(feed.queued_frames(), 0);
std::thread::sleep(Duration::from_millis(20));
feed.push(&[0.1, 0.2]);
assert!(h.join().unwrap());
assert_eq!(feed.queued_frames(), 1);
}
#[test]
fn wait_to_play_stops_without_samples() {
let feed = AudioFeed::default();
let (tx, rx) = mpsc::channel();
let h = std::thread::spawn(move || wait_to_play(&feed, &rx));
tx.send(()).unwrap();
assert!(!h.join().unwrap());
}
}

View File

@ -1,274 +1,61 @@
//! Localhost JSON-lines control server (one request per line, one reply per line). Loopback only.
//!
//! The server only ever reads requests: a line that is not a JSON object with a string `method`
//! (an HTTP request line from a browser's cross-origin `fetch`, a stray `nc`, binary junk) gets
//! one error reply and the connection is closed, so nothing after it is executed. Lines are
//! bounded ([`MAX_LINE`]) and so is the number of open connections ([`MAX_CONNECTIONS`]); junk
//! never reaches the UI thread. See `docs/control-protocol.md`.
use std::io::{BufRead, BufReader, Read, Write};
use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::mpsc::{Receiver, Sender, channel};
use std::time::Duration;
use effectcraft_ui_egui::ControlRequest;
use serde_json::{Value, json};
/// Longest accepted request line (bytes, without the newline). Requests are small JSON objects;
/// anything longer is refused and the connection closed.
pub const MAX_LINE: usize = 4 * 1024 * 1024;
/// Connections served at once; further ones get an error line and are closed.
pub const MAX_CONNECTIONS: usize = 16;
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
let (tx, rx) = channel::<ControlRequest>();
let listener = match TcpListener::bind(("127.0.0.1", port)) {
Ok(l) => l,
Err(e) => {
log::error!("control server failed to bind 127.0.0.1:{port}: {e}");
eprintln!("effectcraft: control server failed to bind 127.0.0.1:{port}: {e}");
return rx;
}
};
log::info!("control server listening on 127.0.0.1:{port}");
std::thread::spawn(move || accept_loop(listener, tx, ctx));
eprintln!("effectcraft: control server listening on 127.0.0.1:{port}");
std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
let tx = tx.clone();
let ctx = ctx.clone();
std::thread::spawn(move || serve(stream, tx, ctx));
}
});
rx
}
fn accept_loop(listener: TcpListener, tx: Sender<ControlRequest>, ctx: egui::Context) {
let open = Arc::new(AtomicUsize::new(0));
for mut stream in listener.incoming().flatten() {
if open.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS {
open.fetch_sub(1, Ordering::SeqCst);
let _ = writeln!(stream, "{}", json!({"ok": false, "error": "too many control connections"}));
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
let Ok(read) = stream.try_clone() else { return };
let mut out = stream;
for line in BufReader::new(read).lines() {
let Ok(line) = line else { break };
if line.trim().is_empty() {
continue;
}
let tx = tx.clone();
let ctx = ctx.clone();
let open = Arc::clone(&open);
std::thread::spawn(move || {
serve(stream, &tx, &ctx);
open.fetch_sub(1, Ordering::SeqCst);
});
}
}
fn serve(stream: TcpStream, tx: &Sender<ControlRequest>, ctx: &egui::Context) {
let Ok(read) = stream.try_clone() else { return };
serve_lines(read, stream, |method, params| {
let (req, rrx) = ControlRequest::new(method, params);
tx.send(req).ok()?;
ctx.request_repaint();
Some(rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"})))
});
}
/// One parsed line.
enum Line {
Request {
id: Value,
method: String,
params: Value,
},
Blank,
/// Not a request: reply with this error and close the connection.
Reject(String),
}
fn parse_line(bytes: &[u8]) -> Line {
let Ok(text) = std::str::from_utf8(bytes) else {
return Line::Reject("not a control request (invalid UTF-8); closing the connection".into());
};
let text = text.trim();
if text.is_empty() {
return Line::Blank;
}
let msg = match serde_json::from_str::<Value>(text) {
Ok(v) => v,
Err(e) => return Line::Reject(format!("bad JSON ({e}); expected one JSON request object per line; closing the connection")),
};
let Some(obj) = msg.as_object() else {
return Line::Reject("not a control request (expected a JSON object); closing the connection".into());
};
let Some(method) = obj.get("method").and_then(Value::as_str) else {
return Line::Reject("not a control request (missing string `method`); closing the connection".into());
};
Line::Request { id: obj.get("id").cloned().unwrap_or(Value::Null), method: method.to_string(), params: obj.get("params").cloned().unwrap_or(json!({})) }
}
/// Read one `\n`-terminated line of at most `max` bytes into `buf` (newline stripped).
/// `Ok(false)` at end of stream; `Err` when the line is too long or the read fails.
fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -> Result<bool, String> {
buf.clear();
// `take` bounds the bytes buffered for one line: a peer can't make us allocate more than `max`.
let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1);
let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| e.to_string())?;
if n == 0 {
return Ok(false);
}
if buf.last() == Some(&b'\n') {
buf.pop();
if buf.last() == Some(&b'\r') {
buf.pop();
}
return Ok(true);
}
if buf.len() > max {
return Err(format!("request line longer than {max} bytes; closing the connection"));
}
// Last line without a newline, then end of stream.
Ok(true)
}
/// Serve requests from `read`, answering on `out`, until the peer closes, sends something that is
/// not a request, or `handle` returns `None` (app gone). `handle` gets `(method, params)` and
/// returns the reply object (the request's `id` is added here).
fn serve_lines<R: Read, W: Write>(read: R, mut out: W, mut handle: impl FnMut(String, Value) -> Option<Value>) {
let mut reader = BufReader::new(read);
let mut buf = Vec::new();
loop {
let reject = match read_bounded_line(&mut reader, &mut buf, MAX_LINE) {
Ok(false) => return,
Err(e) => e,
Ok(true) => match parse_line(&buf) {
Line::Blank => continue,
Line::Reject(e) => e,
Line::Request { id, method, params } => {
let Some(mut r) = handle(method, params) else { return };
if let Some(o) = r.as_object_mut() {
o.insert("id".into(), id);
}
if writeln!(out, "{r}").and_then(|()| out.flush()).is_err() {
return;
}
continue;
let reply = match serde_json::from_str::<Value>(&line) {
Ok(msg) => {
let id = msg.get("id").cloned().unwrap_or(Value::Null);
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
let params = msg.get("params").cloned().unwrap_or(json!({}));
let (req, rrx) = ControlRequest::new(method, params);
if tx.send(req).is_err() {
break;
}
},
ctx.request_repaint();
let mut r = rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
if let Some(o) = r.as_object_mut() {
o.insert("id".into(), id);
}
r
}
Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}),
};
let _ = writeln!(out, "{}", json!({"ok": false, "error": reject})).and_then(|()| out.flush());
return;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn run(input: &[u8]) -> (Vec<Value>, Vec<String>) {
let mut out = Vec::new();
let mut calls = Vec::new();
serve_lines(input, &mut out, |m, _p| {
calls.push(m);
Some(json!({"ok": true, "result": null}))
});
let replies = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str::<Value>(l).unwrap()).collect();
(replies, calls)
}
#[test]
fn requests_are_answered_in_order_with_ids() {
let (r, calls) = run(b"{\"id\":1,\"method\":\"a\"}\n\n{\"id\":2,\"method\":\"b\",\"params\":{}}\r\n{\"method\":\"c\"}");
assert_eq!(calls, ["a", "b", "c"]);
assert_eq!(r.len(), 3);
assert_eq!(r[0]["id"], 1);
assert_eq!(r[1]["id"], 2);
assert_eq!(r[2]["id"], Value::Null);
}
#[test]
fn http_request_closes_before_the_body_runs() {
// A browser's cross-origin `fetch` POST: the request line is rejected and the
// connection closed, so the JSON body is never executed.
let http = b"POST / HTTP/1.1\r\nHost: 127.0.0.1:9877\r\nOrigin: https://attacker.example\r\nContent-Type: text/plain\r\n\r\n\
{\"id\":7,\"method\":\"engine.execute\",\"params\":{\"command\":\"file.new\",\"params\":{}}}\n";
let (r, calls) = run(http);
assert!(calls.is_empty(), "nothing may run: {calls:?}");
assert_eq!(r.len(), 1);
assert_eq!(r[0]["ok"], false);
assert!(r[0]["error"].as_str().unwrap().contains("closing the connection"));
}
#[test]
fn non_request_json_closes_the_connection() {
for junk in [
&b"[1,2]\n{\"method\":\"a\"}\n"[..],
b"{\"id\":1}\n{\"method\":\"a\"}\n",
b"{\"method\":5}\n{\"method\":\"a\"}\n",
b"\xff\xfe\n{\"method\":\"a\"}\n",
] {
let (r, calls) = run(junk);
assert!(calls.is_empty(), "{calls:?}");
assert_eq!(r.len(), 1);
assert_eq!(r[0]["ok"], false);
if writeln!(out, "{reply}").is_err() {
break;
}
}
#[test]
fn a_request_before_junk_still_runs() {
let (r, calls) = run(b"{\"method\":\"a\"}\nGET / HTTP/1.1\n{\"method\":\"b\"}\n");
assert_eq!(calls, ["a"]);
assert_eq!(r.len(), 2);
assert_eq!(r[1]["ok"], false);
}
#[test]
fn overlong_line_is_refused() {
// One byte over the limit, then a valid request: neither runs.
let mut input = vec![b'x'; MAX_LINE + 1];
input.extend_from_slice(b"\n{\"method\":\"a\"}\n");
let (r, calls) = run(&input);
assert!(calls.is_empty(), "{calls:?}");
assert_eq!(r.len(), 1);
assert!(r[0]["error"].as_str().unwrap().contains("longer than"));
}
#[test]
fn line_at_the_limit_is_accepted() {
let mut buf = Vec::new();
let line = vec![b'a'; 10];
let mut input = line.clone();
input.push(b'\n');
assert_eq!(read_bounded_line(&mut &input[..], &mut buf, 10), Ok(true));
assert_eq!(buf, line);
assert!(read_bounded_line(&mut &[b'a'; 11][..], &mut buf, 10).is_err());
}
#[test]
fn tcp_connection_is_closed_after_junk() {
// End to end over a real loopback socket (the transport the app uses).
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let addr = listener.local_addr().unwrap();
let server = std::thread::spawn(move || {
let (s, _) = listener.accept().unwrap();
let read = s.try_clone().unwrap();
serve_lines(read, s, |_, _| Some(json!({"ok": true})));
});
let mut c = TcpStream::connect(addr).unwrap();
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
c.write_all(b"{\"id\":7,\"method\":\"x\"}\nhello\n{\"method\":\"y\"}\n").unwrap();
let mut text = String::new();
c.read_to_string(&mut text).unwrap(); // returns at EOF: the server closed the connection
server.join().unwrap();
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 2, "{text}");
assert!(lines[0].contains("\"id\":7"));
assert!(lines[1].contains("closing the connection"));
}
#[test]
fn extra_connections_are_refused() {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let addr = listener.local_addr().unwrap();
let (tx, _rx) = channel::<ControlRequest>();
std::thread::spawn(move || accept_loop(listener, tx, egui::Context::default()));
// Idle connections are accepted in order and held open.
let held: Vec<TcpStream> = (0..16).map(|_| TcpStream::connect(addr).unwrap()).collect();
let mut extra = TcpStream::connect(addr).unwrap();
extra.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
let mut line = String::new();
BufReader::new(&mut extra).read_line(&mut line).unwrap();
assert!(line.contains("too many control connections"), "{line}");
drop(held);
}
}

View File

@ -1,220 +0,0 @@
//! Settings ▸ Startup & Repair ▸ Window Graphics, and the launch guard behind it.
//!
//! Some graphics drivers crash EffectCraft as its window's device is created (an access
//! violation in an older Intel UHD driver with DirectX 12 / Vulkan, #243), or a moment after
//! its first frame, as the window is shown and its surface re-created (AMD's Vulkan driver for
//! a Radeon RX 6800M, Adrenalin 26.9.2: an access violation in amdvlk64.dll on every launch).
//! A crash in a driver can't be caught, so each launch leaves a marker in the settings folder
//! until its window has kept drawing for [`SETTLE`] (or the app quits normally before then). A
//! launch that finds the previous one's marker switches Window Graphics to OpenGL, which those
//! drivers run, and says so once the window is up. `WGPU_BACKEND` (wgpu's own override) still
//! wins over both. The marker is locked while its launch runs, so a second copy opened meanwhile
//! isn't taken for a crash; and a window OpenGL can't open either puts Window Graphics back to
//! Automatic ([`opengl_failed`]) rather than failing every launch.
use std::fs::File;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use effectcraft_engine::prefs::{PREFS_FILE, Prefs};
use eframe::wgpu::Backends;
/// Left in the settings folder from a launch until its window has settled.
const MARKER: &str = "launch-pending";
/// How long the window keeps drawing after its first frame before the launch counts as made.
/// A driver that fails as the window is shown and its surface re-created does so within the
/// first second; a user quitting sooner than this is told the next launch uses OpenGL and can
/// switch back in Settings.
pub const SETTLE: Duration = Duration::from_secs(3);
/// This launch's window graphics.
#[derive(Debug, Default)]
pub struct Launch {
/// The graphics APIs the window may use (`None`: eframe's default).
pub backends: Option<Backends>,
/// Switched to OpenGL because the previous launch stopped as its window opened: the message
/// to show.
pub notice: Option<String>,
marker: Option<PathBuf>,
/// Holds the marker's lock until the launch settles (or the process ends).
lock: Option<File>,
/// When the window drew its first frame, until the launch settles.
first_frame: Option<Instant>,
}
impl Launch {
/// Read Window Graphics in the settings folder `dir`, switch it to OpenGL when the previous
/// launch stopped as its window opened (where OpenGL exists: not macOS), and leave this
/// launch's marker. `env_override`: `WGPU_BACKEND` is set.
pub fn begin(dir: Option<&Path>, env_override: bool) -> Launch {
let Some(dir) = dir.filter(|_| !env_override) else { return Launch::default() };
let prefs_path = dir.join(PREFS_FILE);
let mut prefs = std::fs::read_to_string(&prefs_path).map(|t| Prefs::from_json(&t)).unwrap_or_default();
let marker = dir.join(MARKER);
// Locked by another launch that is still opening its window: not a crash.
let held = File::open(&marker).is_ok_and(|f| matches!(f.try_lock(), Err(std::fs::TryLockError::WouldBlock)));
let mut notice = None;
if marker.exists() && !held && prefs.startup.window_graphics == "auto" && cfg!(not(target_os = "macos")) {
prefs.startup.window_graphics = "gl".into();
match std::fs::create_dir_all(dir).and_then(|()| std::fs::write(&prefs_path, prefs.to_json())) {
Ok(()) => log::warn!("the last launch stopped as its window opened: switching Window Graphics to OpenGL"),
Err(e) => log::warn!("the last launch stopped as its window opened; saving Window Graphics failed: {e}"),
}
notice = Some(
"EffectCraft's last launch stopped as its window opened, so it now draws with OpenGL \
(Settings ▸ Startup & Repair ▸ Window Graphics). Updating the graphics driver may let it use \
Automatic again."
.to_string(),
);
}
let backends = (prefs.startup.window_graphics == "gl").then_some(Backends::GL);
let (marker, lock) = match std::fs::create_dir_all(dir).and_then(|()| File::create(&marker)) {
// Where locks aren't available the guard works as before.
Ok(f) => (Some(marker), f.try_lock().is_ok().then_some(f)),
Err(e) => {
log::warn!("launch marker: {e}");
(None, None)
}
};
Launch { backends, notice, marker, lock, first_frame: None }
}
/// The window drew a frame. Until the launch has settled, returns how long that still takes
/// (ask for a repaint then, so that an idle window settles too).
pub fn frame_drawn(&mut self) -> Option<Duration> {
self.frame_drawn_at(Instant::now())
}
fn frame_drawn_at(&mut self, now: Instant) -> Option<Duration> {
self.marker.as_ref()?;
let first = *self.first_frame.get_or_insert(now);
let left = SETTLE.saturating_sub(now.saturating_duration_since(first));
if left.is_zero() {
self.settled();
return None;
}
Some(left)
}
/// The app quits normally: however soon after opening, that's no crash.
pub fn exited(&mut self) {
self.settled();
}
/// The launch made it: release and remove its marker.
fn settled(&mut self) {
self.lock = None;
if let Some(m) = self.marker.take()
&& let Err(e) = std::fs::remove_file(&m)
{
log::warn!("launch marker: {e}");
}
}
}
/// The window couldn't open with OpenGL (an error, not a crash): back to Automatic, and no marker.
pub fn opengl_failed(dir: Option<&Path>) {
let Some(dir) = dir else { return };
let prefs_path = dir.join(PREFS_FILE);
let mut prefs = std::fs::read_to_string(&prefs_path).map(|t| Prefs::from_json(&t)).unwrap_or_default();
prefs.startup.window_graphics = "auto".into();
if let Err(e) = std::fs::write(&prefs_path, prefs.to_json()) {
log::warn!("saving Window Graphics failed: {e}");
}
let _ = std::fs::remove_file(dir.join(MARKER));
}
#[cfg(test)]
mod tests {
use super::*;
fn window_graphics(dir: &Path) -> String {
Prefs::from_json(&std::fs::read_to_string(dir.join(PREFS_FILE)).unwrap_or_default()).startup.window_graphics
}
/// A launch that never drew switches the next one to OpenGL, which then stays (#243).
#[test]
fn a_launch_that_never_drew_switches_the_next_to_opengl() {
let dir = std::env::temp_dir().join(format!("ec-launch-guard-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
// A first launch that draws: nothing changes. Its first frames don't settle it (a driver
// may still crash as the window is shown); frames for `SETTLE` do.
let mut l = Launch::begin(Some(&dir), false);
assert_eq!((l.backends, l.notice.is_some()), (None, false));
assert!(dir.join(MARKER).exists());
let t0 = Instant::now();
assert_eq!(l.frame_drawn_at(t0), Some(SETTLE));
assert!(l.frame_drawn_at(t0 + SETTLE / 2).is_some_and(|left| left <= SETTLE / 2));
assert!(dir.join(MARKER).exists());
assert_eq!(l.frame_drawn_at(t0 + SETTLE), None);
assert!(!dir.join(MARKER).exists());
assert_eq!(l.frame_drawn_at(t0 + SETTLE), None, "settled stays settled");
// A launch that crashes before drawing (it never calls `drawn`)…
drop(Launch::begin(Some(&dir), false));
// …makes the next one use OpenGL and say so, once.
let mut l = Launch::begin(Some(&dir), false);
if cfg!(target_os = "macos") {
assert_eq!((l.backends, l.notice.is_some()), (None, false), "no OpenGL on macOS");
} else {
assert_eq!(l.backends, Some(Backends::GL));
assert!(l.notice.is_some());
assert_eq!(window_graphics(&dir), "gl", "saved in Settings");
l.exited();
let l = Launch::begin(Some(&dir), false);
assert_eq!((l.backends, l.notice.is_some()), (Some(Backends::GL), false), "it stays, without the notice");
}
// `WGPU_BACKEND` wins, and leaves no marker.
let _ = std::fs::remove_file(dir.join(MARKER));
assert_eq!(Launch::begin(Some(&dir), true).backends, None);
assert!(!dir.join(MARKER).exists());
let _ = std::fs::remove_dir_all(&dir);
}
/// A launch that stops moments after its first frame (AMD's Vulkan driver crashing as the
/// window is shown and its surface re-created) switches the next one to OpenGL too; a
/// normal quit in that time doesn't.
#[test]
fn a_launch_that_stops_after_its_first_frame_switches_the_next_to_opengl() {
let dir = std::env::temp_dir().join(format!("ec-launch-guard-3-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut l = Launch::begin(Some(&dir), false);
assert!(l.frame_drawn().is_some());
drop(l);
let l = Launch::begin(Some(&dir), false);
if cfg!(target_os = "macos") {
assert_eq!(l.backends, None, "no OpenGL on macOS");
} else {
assert_eq!(l.backends, Some(Backends::GL));
assert!(l.notice.is_some());
}
drop(l);
let _ = std::fs::remove_dir_all(&dir);
// Quitting right after the window opened leaves no marker.
let mut l = Launch::begin(Some(&dir), false);
assert!(l.frame_drawn().is_some());
l.exited();
assert!(!dir.join(MARKER).exists());
assert_eq!(Launch::begin(Some(&dir), false).backends, None);
let _ = std::fs::remove_dir_all(&dir);
}
/// A second copy opened while the first is still opening isn't a crash; OpenGL that can't
/// open a window either goes back to Automatic instead of failing every launch.
#[test]
fn a_second_copy_or_failed_opengl_doesnt_strand_window_graphics() {
let dir = std::env::temp_dir().join(format!("ec-launch-guard-2-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let _first = Launch::begin(Some(&dir), false);
assert_eq!(Launch::begin(Some(&dir), false).backends, None);
assert_eq!(window_graphics(&dir), "auto");
drop(_first);
if cfg!(not(target_os = "macos")) {
assert_eq!(Launch::begin(Some(&dir), false).backends, Some(Backends::GL), "a real crash still switches");
opengl_failed(Some(&dir));
assert_eq!(window_graphics(&dir), "auto");
assert_eq!(Launch::begin(Some(&dir), false).backends, None);
}
let _ = std::fs::remove_dir_all(&dir);
}
}

View File

@ -2,170 +2,69 @@
//!
//! Usage: `effectcraft [--control <port>] [--demo|--empty|--home] [project.ecproj | media files…]`
//!
//! Without a project the app starts with an empty Untitled Project, as After Effects does;
//! `--demo` opens the demo project instead (also Help ▸ Open Demo Project).
//!
//! `--control <port>` (or `EFFECTCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server;
//! see `effectcraft_ui_egui::control` for the methods.
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
// Built everywhere so its tests run on every platform; only Linux AppImages use it.
mod appearance;
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
mod appimage;
#[cfg(target_os = "macos")]
mod apple_events;
mod audio_out;
mod control_server;
mod launch_guard;
#[cfg(target_os = "macos")]
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;
/// The app icon for the window, taskbar and Dock (assets/app-icon, regenerated by
/// `packaging/icons.sh`). macOS gets the padded variant so it sits on Apple's icon grid in the Dock.
/// The 256 px app icon (Windows, Linux; the AppImage's desktop integration installs it too).
#[cfg(not(target_os = "macos"))]
const ICON_PNG: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.effectcraft.png");
fn app_icon() -> egui::IconData {
#[cfg(target_os = "macos")]
let png: &[u8] = include_bytes!("../../../assets/app-icon/effectcraft-macos-512.png");
#[cfg(not(target_os = "macos"))]
let png: &[u8] = ICON_PNG;
let png: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.effectcraft.png");
eframe::icon_data::from_png_bytes(png).unwrap_or_default()
}
/// 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.
fn is_project_file(path: &str) -> bool {
std::path::Path::new(path)
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| ext.eq_ignore_ascii_case("ecproj") || ext.eq_ignore_ascii_case("ecprojx"))
}
/// winit 0.30 cannot deliver native Wayland file drops. XWayland can.
#[cfg(target_os = "linux")]
fn wayland_drop_notice(wayland_display: Option<&std::ffi::OsStr>, backend: Option<&std::ffi::OsStr>) -> Option<&'static str> {
(wayland_display.is_some_and(|v| !v.is_empty()) && backend != Some(std::ffi::OsStr::new("x11")))
.then_some("Native Wayland file drops are unavailable. Use File ▸ Import ▸ File… or run via XWayland (see README, Linux).")
}
/// The command line.
#[derive(Debug, Default, PartialEq)]
struct Args {
control_port: Option<u16>,
files: Vec<String>,
/// Open the demo project when no project is given (`--demo`).
demo: bool,
/// Show or skip the Home screen (`--home`), else the Startup preference decides.
home: Option<bool>,
}
/// Parse the arguments after the program name; `None` for `--version`. `env_port` is
/// `EFFECTCRAFT_CONTROL_PORT`.
fn parse_args(args: impl IntoIterator<Item = String>, env_port: Option<String>) -> Option<Args> {
let mut out = Args { control_port: env_port.and_then(|p| p.parse().ok()), ..Default::default() };
let mut args = args.into_iter();
while let Some(a) = args.next() {
match a.as_str() {
"--control" => out.control_port = args.next().and_then(|p| p.parse().ok()),
"--demo" => out.demo = true,
"--empty" => out.demo = false,
"--home" => out.home = Some(true),
"--version" => return None,
_ => out.files.push(a),
}
}
Some(out)
}
fn main() -> eframe::Result {
// First, so the panic hook and every start-up warning are recorded. Records go to stderr
// and the session log; Help ▸ Enable Logging writes through this logger too, and warnings
// feed the compatibility report.
// Help ▸ Enable Logging writes through this logger; warnings feed the compatibility report.
effectcraft_engine::logging::install();
effectcraft_engine::logging::install_panic_hook();
let Some(Args { control_port, files, demo, home }) = parse_args(std::env::args().skip(1), std::env::var("EFFECTCRAFT_CONTROL_PORT").ok()) else {
println!("effectcraft {}", env!("CARGO_PKG_VERSION"));
return Ok(());
};
// The session log lives in the settings folder (`Logs/effectcraft.log`, the previous two runs
// kept): a launch from the Dock or a menu has no terminal. Opened after the arguments, so
// `--version` leaves no file behind; records logged until now are written to it first.
if let Some(dir) = effectcraft_host::config_dir() {
match effectcraft_engine::logging::attach_session_log(&effectcraft_engine::logging::log_dir(Some(&dir))) {
Ok(path) => log::info!("effectcraft {}, log file {}", env!("CARGO_PKG_VERSION"), path.display()),
Err(e) => log::warn!("no log file: {e}"),
let mut control_port: Option<u16> = std::env::var("EFFECTCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
let mut files = Vec::new();
let mut demo = true;
let mut home: Option<bool> = None;
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
match a.as_str() {
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
"--demo" => demo = true,
"--empty" => demo = false,
"--home" => home = Some(true),
"--version" => {
println!("effectcraft {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
_ => files.push(a),
}
}
// 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.
#[cfg(target_os = "linux")]
appimage::integrate_from_env(ICON_PNG);
// Settings ▸ Startup & Repair ▸ Window Graphics, switched to OpenGL when the last launch
// stopped as its window opened (a crashing graphics driver, #243).
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 options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("EffectCraft")
.with_inner_size(window::DEFAULT_SIZE)
.with_min_inner_size(window::MIN_SIZE)
.with_inner_size([1680.0, 1020.0])
.with_min_inner_size([960.0, 600.0])
.with_drag_and_drop(true)
// Started for an agent (`--control`): open without taking the user's keyboard focus.
.with_active(control_port.is_none())
.with_icon(app_icon())
// Wayland matches the window to `ai.storyteller.effectcraft.desktop` by this ID.
.with_app_id(appimage::APP_ID),
.with_app_id("ai.storyteller.effectcraft"),
event_loop_builder: agent_event_loop(control_port.is_some()),
wgpu_options: wgpu_options(launch.backends),
..Default::default()
};
// Start-up milestones go to stderr and the session log, so a window that never appears
// shows how far start-up got (#234).
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.
#[cfg(target_os = "macos")]
let apple_events = apple_events::AppleEvents::install();
#[cfg(target_os = "macos")]
let apple_events = &apple_events;
let result = eframe::run_native(
eframe::run_native(
"EffectCraft",
options,
Box::new(move |cc| {
match &cc.wgpu_render_state {
Some(rs) => log::info!("graphics: {:?}", rs.adapter.get_info()),
None => log::info!("graphics: no wgpu device"),
}
// This executable owns the shared device's handlers. Install them before
// EffectCraft's compositor pipelines are built, without changing handlers from
// inside Gpu::new. eframe has already constructed its presentation renderer here.
let gpu_failures = effectcraft_ui_egui::gpu_failure::GpuFailureBridge::new(&cc.egui_ctx);
if let Some(rs) = &cc.wgpu_render_state {
let errors = gpu_failures.clone();
rs.device.on_uncaptured_error(std::sync::Arc::new(move |error| {
let message = format!("uncaptured GPU error: {error}");
if errors.report(&message, false) {
log::error!("{message}");
}
}));
let lost = gpu_failures.clone();
rs.device.set_device_lost_callback(move |reason, message| {
lost.report(&format!("GPU device lost ({reason:?}): {message}"), true);
});
}
let mut session = effectcraft_host::session();
// Lazy open and non-blocking auto-save: footage is checked and auto-saves are
// written on background threads (M13.14).
@ -173,7 +72,7 @@ fn main() -> eframe::Result {
session.autosave.background = true;
// Settings, shortcut presets and the crash-recovery sentinel live in the platform
// config directory. Agent-driven runs (`--control`) skip crash recovery.
if let Some(dir) = effectcraft_host::config_dir() {
if let Some(dir) = config_dir() {
// WebAssembly effect plug-ins in <config>/Plug-ins load before the menus are built.
let plugins = dir.join("Plug-ins");
if plugins.is_dir() {
@ -182,88 +81,44 @@ 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();
let project = files.iter().find(|f| f.ends_with(".ecproj")).cloned();
if let Some(p) = project {
if let Err(e) = session.execute("file.open", json!({"path": p})) {
log::warn!("{p}: {e}");
eprintln!("effectcraft: {e}");
}
} else if demo {
let _ = session.execute("file.openDemoProject", json!({}));
}
let media: Vec<String> = files.iter().filter(|f| !is_project_file(f)).cloned().collect();
let media: Vec<String> = files.iter().filter(|f| !f.ends_with(".ecproj")).cloned().collect();
if !media.is_empty()
&& let Err(e) = session.execute("file.import", json!({"paths": media}))
{
log::warn!("import: {e}");
eprintln!("effectcraft: {e}");
}
let show_home = home.unwrap_or(session.prefs.startup.show_home_on_launch && files.is_empty() && control_port.is_none());
// The ScriptUI panels open when the app last quit open again (a failing one is
// reported in a toast and forgotten).
let _ = session.execute("window.restoreScriptPanels", json!({}));
let mut app = EffectcraftApp::new(session);
app.set_gpu_failure_bridge(gpu_failures);
app.ui.start_screen = show_home;
if let Some(n) = notice {
app.ui.status = n;
}
if let Some(r) = recovery {
app.offer_recovery(r);
}
#[cfg(target_os = "linux")]
if let Some(note) = wayland_drop_notice(std::env::var_os("WAYLAND_DISPLAY").as_deref(), std::env::var_os("WINIT_UNIX_BACKEND").as_deref())
&& app.ui.status.is_empty()
{
app.ui.status = note.into();
}
app.hooks.pick_files = Some(Box::new(|exts: &[&str]| {
rfd::FileDialog::new().add_filter("Media", exts).pick_files().unwrap_or_default().into_iter().map(|p| p.to_string_lossy().to_string()).collect()
}));
app.hooks.pick_save = Some(Box::new(|name: &str| {
rfd::FileDialog::new().add_filter("EffectCraft Project", &["ecproj"]).set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string())
}));
// The window closes after a lost graphics device (#591); say why, unless an agent drives
// the app through the control channel.
if control_port.is_none() {
app.hooks.device_lost = Some(Box::new(|saved: Option<Result<String, String>>| {
let saved = match saved {
Some(Ok(path)) => format!("\n\nYour unsaved changes were saved to:\n{path}\n\nReopen EffectCraft to recover them."),
Some(Err(e)) => format!("\n\nYour unsaved changes could not be saved ({e})."),
None => String::new(),
};
rfd::MessageDialog::new()
.set_level(rfd::MessageLevel::Error)
.set_title("EffectCraft: graphics device lost")
.set_description(format!("The graphics driver stopped responding, so EffectCraft has to close.{saved}"))
.show();
}));
}
app.hooks.pick_open_project = Some(Box::new(|| {
rfd::FileDialog::new().add_filter("EffectCraft Project", &["ecproj", "ecprojx"]).pick_file().map(|p| p.to_string_lossy().to_string())
}));
app.hooks.system_theme = appearance::service();
app.hooks.audio_device = Some(Box::new(audio_out::open));
app.hooks.audio_devices = Some(Box::new(audio_out::devices));
app.hooks.pick_folder = Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string())));
app.hooks.pick_save_file = Some(Box::new(|name: &str, ext: &str| {
// A default with a folder that exists opens the dialog there.
let path = std::path::Path::new(name);
// (No filter for a name without an extension, such as a folder to create.)
let mut d = if ext.is_empty() { rfd::FileDialog::new() } else { rfd::FileDialog::new().add_filter(ext, &[ext]) };
if let Some(dir) = path.parent().filter(|d| d.is_dir()) {
d = d.set_directory(dir);
}
let file = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
d.set_file_name(file).save_file().map(|p| p.to_string_lossy().to_string())
rfd::FileDialog::new().add_filter(ext, &[ext]).set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string())
}));
#[cfg(target_os = "macos")]
{
// The commands' file dialogs open as sheets instead of nested modal loops (#511).
let ctx = cc.egui_ctx.clone();
app.hooks.file_dialog_async = Some(Box::new(move |dialog, done| sheet_dialogs::show(dialog, done, ctx.clone())));
app.hooks.app_action = Some(Box::new(native_menu::app_action));
app.hooks.clipboard_text = Some(Box::new(native_menu::clipboard_text));
}
@ -272,77 +127,13 @@ fn main() -> eframe::Result {
let rx = control_server::start(port, cc.egui_ctx.clone());
app = app.with_control(rx);
}
log::info!("app created; the window shows after its first frame");
Ok(Box::new(Desktop {
#[cfg(target_os = "macos")]
menu: native_menu::NativeBar::new(&cc.egui_ctx),
#[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,
}))
}),
);
match &result {
Ok(()) => log::info!("the window closed"),
Err(e) => {
if on_gl {
launch_guard::opengl_failed(effectcraft_host::config_dir().as_deref());
}
startup_failed(e, !on_gl && cfg!(not(target_os = "macos")));
}
}
result
}
/// The window's wgpu device asks for the adapter's own limits (textures up to 16384 px)
/// instead of eframe's fixed set: an older GPU that can't meet those (Intel HD Graphics 5500,
/// #198) still opens the app, and the compositor then checks what it needs and falls back to
/// the CPU when the device can't run it. `backends`: Window Graphics' choice, when it makes one.
fn wgpu_options(backends: Option<eframe::wgpu::Backends>) -> eframe::WgpuConfiguration {
use eframe::egui_wgpu::WgpuSetup;
let mut config = eframe::WgpuConfiguration::default();
if let WgpuSetup::CreateNew(setup) = &mut config.wgpu_setup {
if let Some(b) = backends {
setup.instance_descriptor.backends = b;
}
setup.device_descriptor = std::sync::Arc::new(|adapter| eframe::wgpu::DeviceDescriptor {
label: Some("EffectCraft device"),
required_limits: device_limits(adapter.limits()),
..Default::default()
});
}
config
}
/// The limits to request from an adapter that offers `adapter`: all of them, with textures
/// capped at 16384 px like the headless renderer's device.
fn device_limits(adapter: eframe::wgpu::Limits) -> eframe::wgpu::Limits {
eframe::wgpu::Limits { max_texture_dimension_2d: adapter.max_texture_dimension_2d.min(16384), ..adapter }
}
/// The window couldn't open (no usable graphics adapter or device): say so instead of quitting
/// silently, since release builds on Windows have no console. `gl_next`: the launch guard has
/// the next launch try OpenGL.
fn startup_failed(e: &eframe::Error, gl_next: bool) {
let next = if gl_next { "The next launch will try OpenGL instead. " } else { "" };
let message = format!(
"EffectCraft couldn't start its window: {e}\n\n{next}Updating the graphics driver often helps. If it keeps happening, please report it at https://github.com/storytold/effectcraft/issues with this message."
);
// Standard error and the session log; standard error alone when RUST_LOG turned errors off.
if log::log_enabled!(log::Level::Error) {
log::error!("{message}");
} else {
eprintln!("effectcraft: {message}");
}
let _ = rfd::MessageDialog::new()
.set_level(rfd::MessageLevel::Error)
.set_title("EffectCraft")
.set_description(message)
.set_buttons(rfd::MessageButtons::Ok)
.show();
)
}
/// The desktop app: the egui app plus, on macOS, the system menu bar.
@ -350,14 +141,6 @@ struct Desktop {
app: EffectcraftApp,
#[cfg(target_os = "macos")]
menu: native_menu::NativeBar,
/// 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,
}
impl eframe::App for Desktop {
@ -369,31 +152,35 @@ 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");
}
// The launch guard's marker goes once the window has kept drawing for a few seconds: a
// driver may still crash as the window is shown and its surface re-created.
if let Some(left) = self.launch.frame_drawn() {
ui.ctx().request_repaint_after(left);
}
}
fn on_exit(&mut self) {
self.launch.exited();
self.window.save(self.app.session.config.as_deref());
self.app.on_exit();
}
fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
#[cfg(target_os = "macos")]
self.apple_events.feed(self.app.is_ready(), ctx, raw_input);
self.app.raw_input_hook(ctx, raw_input);
}
}
/// The platform config directory for EffectCraft (`EFFECTCRAFT_CONFIG_DIR` overrides):
/// `~/Library/Application Support/EffectCraft` (macOS), `%APPDATA%\EffectCraft` (Windows),
/// `$XDG_CONFIG_HOME/effectcraft` or `~/.config/effectcraft` (Linux and others).
fn config_dir() -> Option<std::path::PathBuf> {
use std::path::PathBuf;
if let Some(d) = std::env::var_os("EFFECTCRAFT_CONFIG_DIR") {
return Some(PathBuf::from(d));
}
let home = || std::env::var_os("HOME").map(PathBuf::from);
if cfg!(target_os = "macos") {
return home().map(|h| h.join("Library/Application Support/EffectCraft"));
}
if cfg!(target_os = "windows") {
return std::env::var_os("APPDATA").map(|a| PathBuf::from(a).join("EffectCraft"));
}
std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| home().map(|h| h.join(".config"))).map(|c| c.join("effectcraft"))
}
/// Driven by an agent (`--control`): don't activate the app on launch, so the user's keyboard
/// focus stays where it is (macOS; winit activates ignoring other apps by default).
fn agent_event_loop(agent: bool) -> Option<eframe::EventLoopBuilderHook> {
@ -421,62 +208,3 @@ fn disable_app_nap() {
std::mem::forget(info.beginActivityWithOptions_reason(opts, &reason));
}
}
#[cfg(test)]
mod tests {
use super::{Args, parse_args};
fn parse(args: &[&str]) -> Option<Args> {
parse_args(args.iter().map(|a| a.to_string()), None)
}
#[cfg(target_os = "linux")]
#[test]
fn wayland_drop_notice_recommends_import_or_xwayland() {
use std::ffi::OsStr;
let warning = super::wayland_drop_notice(Some(OsStr::new("wayland-0")), None).unwrap();
assert!(warning.contains("File ▸ Import") && warning.contains("XWayland"));
assert!(super::wayland_drop_notice(None, None).is_none());
assert!(super::wayland_drop_notice(Some(OsStr::new("")), None).is_none());
assert!(super::wayland_drop_notice(Some(OsStr::new("wayland-0")), Some(OsStr::new("x11"))).is_none());
}
/// The window's device never asks for more than the adapter offers (#198).
#[test]
fn device_limits_fit_the_adapter() {
use eframe::wgpu::Limits;
for adapter in [Limits::downlevel_defaults(), Limits::downlevel_webgl2_defaults(), Limits::default()] {
let l = super::device_limits(adapter.clone());
assert!(l.check_limits(&adapter), "{l:?}");
assert_eq!(l.max_storage_buffers_per_shader_stage, adapter.max_storage_buffers_per_shader_stage);
}
let big = Limits { max_texture_dimension_2d: 32768, ..Limits::default() };
assert_eq!(super::device_limits(big).max_texture_dimension_2d, 16384);
}
/// A positional .ecprojx is a project file, not an unsupported media import (#528).
#[test]
fn launch_classifies_xml_and_json_projects_as_projects() {
for path in ["Copy.ecprojx", "/tmp/Scene.ecproj", "C:\\\\work\\\\Shot.ECPROJX"] {
assert!(super::is_project_file(path), "{path}");
}
for path in ["scene.ecprojx.png", "scene.mp4", "scene", "copy.ecprojx.bak"] {
assert!(!super::is_project_file(path), "{path}");
}
let files = ["scene.ecprojx".to_string(), "clip.mov".to_string()];
assert_eq!(files.iter().find(|f| super::is_project_file(f)).map(String::as_str), Some("scene.ecprojx"));
assert_eq!(files.iter().filter(|f| !super::is_project_file(f)).map(String::as_str).collect::<Vec<_>>(), vec!["clip.mov"],);
}
/// Launching without a project opens an empty project, not the demo (#204).
#[test]
fn no_arguments_start_an_empty_project() {
assert_eq!(parse(&[]), Some(Args::default()));
assert!(parse(&["--demo"]).is_some_and(|a| a.demo));
assert!(parse(&["--demo", "--empty"]).is_some_and(|a| !a.demo));
let a = parse(&["--control", "9877", "--home", "a.ecproj"]).unwrap_or_default();
assert_eq!((a.control_port, a.home, a.files), (Some(9877), Some(true), vec!["a.ecproj".to_string()]));
assert_eq!(parse(&["--version"]), None);
assert_eq!(parse_args(Vec::new(), Some("9000".into())).map(|a| a.control_port), Some(Some(9000)));
}
}

View File

@ -94,8 +94,8 @@ impl NativeBar {
let sub = Submenu::new(label, true);
self.fill(&sub, children);
match label.as_str() {
"Window" | "ウィンドウ" => sub.set_as_windows_menu_for_nsapp(),
"Help" | "ヘルプ" => sub.set_as_help_menu_for_nsapp(),
"Window" => sub.set_as_windows_menu_for_nsapp(),
"Help" => sub.set_as_help_menu_for_nsapp(),
_ => {}
}
if let Err(e) = menu.append(&sub) {

View File

@ -1,76 +0,0 @@
//! File dialogs that don't block the frame (macOS), for `Hooks::file_dialog_async`.
//!
//! A blocking `rfd::FileDialog` runs a nested modal event loop inside eframe's frame, that is
//! inside winit's event handler. On macOS a file dragged over the window while that loop ran
//! re-entered winit's handler, which panics across the Objective-C boundary and aborts the app
//! (#511). `rfd::AsyncFileDialog` shows the dialog as a sheet on the window and returns at once;
//! the answer arrives when the sheet closes, outside the frame, and the app runs the command then.
use effectcraft_ui_egui::{DialogDone, FileDialog};
use rfd::{AsyncFileDialog, FileHandle};
use std::future::Future;
use std::pin::Pin;
type Answer = Pin<Box<dyn Future<Output = Vec<String>> + Send>>;
/// Show `dialog` (on the main thread: the sheet opens here) and hand its paths to `done` once it
/// closes, then wake the UI.
pub fn show(dialog: FileDialog, done: DialogDone, ctx: egui::Context) {
let answer = open(dialog);
let spawned = std::thread::Builder::new().name("ec-file-dialog".into()).spawn(move || {
done(pollster::block_on(answer));
ctx.request_repaint();
});
if let Err(e) = spawned {
log::error!("file dialog: {e}");
}
}
fn paths(files: Option<Vec<FileHandle>>) -> Vec<String> {
files.unwrap_or_default().into_iter().map(|f| f.path().to_string_lossy().to_string()).collect()
}
fn path(file: Option<FileHandle>) -> Vec<String> {
paths(file.map(|f| vec![f]))
}
/// The dialog, as the blocking hooks in `main.rs` show it.
fn open(dialog: FileDialog) -> Answer {
match dialog {
FileDialog::Open { exts, many } => {
let d = AsyncFileDialog::new().add_filter("Media", &exts);
if many {
let f = d.pick_files();
Box::pin(async move { paths(f.await) })
} else {
let f = d.pick_file();
Box::pin(async move { path(f.await) })
}
}
FileDialog::OpenProject => {
let f = AsyncFileDialog::new().add_filter("EffectCraft Project", &["ecproj", "ecprojx"]).pick_file();
Box::pin(async move { path(f.await) })
}
FileDialog::Folder => {
let f = AsyncFileDialog::new().pick_folder();
Box::pin(async move { path(f.await) })
}
FileDialog::Save { default, ext, project } => {
let at = std::path::Path::new(&default);
let name = at.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
let mut d = if project {
AsyncFileDialog::new().add_filter("EffectCraft Project", &["ecproj"])
} else if ext.is_empty() {
AsyncFileDialog::new()
} else {
AsyncFileDialog::new().add_filter(&ext, &[ext.as_str()])
};
// A default with a folder that exists opens the dialog there.
if let Some(dir) = at.parent().filter(|d| d.is_dir()) {
d = d.set_directory(dir);
}
let f = d.set_file_name(name).save_file();
Box::pin(async move { path(f.await) })
}
}
}

View File

@ -1,371 +0,0 @@
//! The main window's size and position. The first launch fits the window to its monitor, opening
//! it maximized where the default size leaves no room for a taskbar or Dock. Later launches restore
//! the size, position and maximized state saved in the config directory (`window.json`), moved
//! onto a monitor that is still connected.
use effectcraft_engine::config::ConfigStore;
use serde::{Deserialize, Serialize};
use winit::dpi::{LogicalSize, PhysicalPosition, PhysicalSize};
const FILE: &str = "window.json";
const MAX_BYTES: usize = 4096;
/// Seconds a window opened maximized gets to say so before its size is tracked.
const MAXIMIZE_WAIT: f64 = 2.0;
/// The window's geometry between launches.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct WindowGeometry {
/// The outer top-left corner in physical pixels; `None` where the system doesn't say (Wayland).
pub pos: Option<[i32; 2]>,
/// The inner size in points; while maximized, the size to return to.
pub size: [f32; 2],
pub maximized: bool,
}
/// The first window's size, in points.
pub const DEFAULT_SIZE: [f32; 2] = [1680.0, 1020.0];
/// The smallest window, in points.
pub const MIN_SIZE: [f32; 2] = [960.0, 600.0];
/// Room the first window leaves around itself for a taskbar, Dock or menu bar (the system doesn't
/// say how much of the screen they take): a monitor with less room opens it maximized.
const SCREEN_ROOM: [f32; 2] = [64.0, 128.0];
/// A monitor: its top-left corner and size in physical pixels, and its scale factor.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Monitor {
pos: [i64; 2],
size: [i64; 2],
scale: f32,
}
impl Monitor {
pub fn new(pos: PhysicalPosition<i32>, size: PhysicalSize<u32>, scale: f64) -> Self {
let scale = scale as f32;
let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
Self { pos: [pos.x.into(), pos.y.into()], size: [size.width.into(), size.height.into()], scale }
}
/// Its size in points.
fn points(&self) -> [f32; 2] {
self.size.map(|s| s as f32 / self.scale)
}
/// Its size in points less room for a taskbar, Dock or menu bar.
fn usable(&self) -> [f32; 2] {
let points = self.points();
std::array::from_fn(|i| points[i] - SCREEN_ROOM[i])
}
/// A window of `size` points on this monitor, in physical pixels.
fn pixels(&self, size: [f32; 2]) -> [i64; 2] {
size.map(|s| (s * self.scale) as i64)
}
/// How much of a window at `pos` of `size` points this monitor shows, in square pixels.
fn overlap(&self, pos: [i32; 2], size: [f32; 2]) -> i64 {
let px = self.pixels(size);
let span = |i: usize| {
let start = i64::from(pos[i]).max(self.pos[i]);
let end = i64::from(pos[i]).saturating_add(px[i]).min(self.pos[i] + self.size[i]);
(end - start).max(0)
};
span(0).saturating_mul(span(1))
}
/// The top-left corner nearest to `pos` (centred when none) that keeps a window of `size`
/// points on this monitor.
fn keep_on(&self, pos: Option<[i32; 2]>, size: [f32; 2]) -> [i32; 2] {
let px = self.pixels(size);
std::array::from_fn(|i| {
let lo = self.pos[i];
let hi = lo + self.size[i].saturating_sub(px[i]).max(0);
let want = pos.map_or(lo + (hi - lo) / 2, |p| i64::from(p[i]));
i32::try_from(want.max(lo).min(hi)).unwrap_or_default()
})
}
}
/// Where the window opens: `saved` (the last session's geometry) kept on a connected monitor, else
/// the default size on `home` (the monitor the system opens it on). Without monitors (the system
/// doesn't list them, so nothing to check a saved geometry against) the default size.
pub fn place(saved: Option<WindowGeometry>, monitors: &[Monitor], home: Option<usize>) -> WindowGeometry {
let default = WindowGeometry { pos: None, size: DEFAULT_SIZE, maximized: false };
let Some(home) = home.and_then(|i| monitors.get(i)).or(monitors.first()) else {
return default;
};
let Some(saved) = saved else {
let usable = home.usable();
if (0..2).all(|i| DEFAULT_SIZE[i] <= usable[i]) {
return default;
}
// A small screen: maximized, with a size that fits once un-maximized.
let size = fit(DEFAULT_SIZE, usable);
return WindowGeometry { pos: Some(home.keep_on(None, size)), size, maximized: true };
};
// The monitor showing most of the window; none when it was on a monitor that is gone.
let shown = saved.pos.and_then(|p| {
let (area, m) = monitors.iter().map(|m| (m.overlap(p, saved.size), m)).max_by_key(|(area, _)| *area)?;
(area > 0).then_some(m)
});
let m = shown.unwrap_or(home);
// Bigger than its monitor (a smaller screen than last time): maximized, like the first window.
let room = m.points();
let too_big = (0..2).any(|i| saved.size[i] > room[i]);
let size = fit(saved.size, if too_big { m.usable() } else { room });
WindowGeometry {
// Moved fully onto its monitor, or centred on the home monitor.
pos: saved.pos.map(|p| m.keep_on(shown.map(|_| p), size)),
size,
maximized: saved.maximized || too_big,
}
}
/// `size` no bigger than `room`, and no smaller than the smallest window unless `room` is.
fn fit(size: [f32; 2], room: [f32; 2]) -> [f32; 2] {
std::array::from_fn(|i| size[i].min(room[i]).max(MIN_SIZE[i].min(room[i])).max(1.0))
}
/// Size and place the new window (eframe shows it after drawing the first frame) and return its
/// geometry.
pub fn restore(window: &winit::window::Window, saved: Option<WindowGeometry>) -> WindowGeometry {
let handles: Vec<_> = window.available_monitors().collect();
let monitors: Vec<Monitor> = handles.iter().map(|m| Monitor::new(m.position(), m.size(), m.scale_factor())).collect();
let home = window.current_monitor().or_else(|| window.primary_monitor()).and_then(|c| handles.iter().position(|m| *m == c));
let g = place(saved, &monitors, home);
if let Some([x, y]) = g.pos {
window.set_outer_position(PhysicalPosition::new(x, y));
}
// Returns the new size when it applies at once; either way the window gets it.
let _ = window.request_inner_size(LogicalSize::new(g.size[0], g.size[1]));
if g.maximized {
window.set_maximized(true);
}
g
}
/// Per frame: remember the window's geometry for the next launch. Minimized or full screen keeps
/// the last one; maximized only sets its flag, so un-maximizing next time restores the last size.
pub fn track(ctx: &egui::Context, geometry: &mut Option<WindowGeometry>) {
let zoom = ctx.zoom_factor();
ctx.input(|i| {
let v = i.viewport();
if v.minimized == Some(true) || v.fullscreen == Some(true) {
return;
}
if v.maximized == Some(true) {
if let Some(g) = geometry {
g.maximized = true;
}
return;
}
let Some(inner) = v.inner_rect else { return };
let pos = v.outer_rect.map(|r| [r.min.x, r.min.y].map(|c| (c * i.pixels_per_point).round() as i32));
*geometry = Some(WindowGeometry { pos, size: [inner.width() * zoom, inner.height() * zoom], maximized: false });
});
}
/// The geometry saved by the last session; `None` when there is none or it can't be read.
pub fn load(config: &dyn ConfigStore) -> Option<WindowGeometry> {
let text = config.read_bounded(FILE, MAX_BYTES).ok()??;
serde_json::from_str(&text).ok()
}
/// Follows the window and saves its geometry for the next launch: when it has changed (at most
/// once a second, so a session that ends badly still has it) and on exit.
pub struct Tracker {
geometry: Option<WindowGeometry>,
saved: Option<WindowGeometry>,
last_attempt: f64,
/// Opened maximized, and the system hasn't reported it so yet (when the wait ends): the
/// window's first frames report the maximized size as an ordinary one, which would replace
/// the size to return to.
maximizing: Option<f64>,
/// Agent-driven runs (`--control`) leave the user's saved window alone.
save: bool,
}
impl Tracker {
/// `geometry`: what the window opened with.
pub fn new(geometry: Option<WindowGeometry>, saved: Option<WindowGeometry>, save: bool) -> Self {
let maximizing = geometry.is_some_and(|g| g.maximized).then_some(f64::INFINITY);
Self { geometry, saved, last_attempt: f64::NEG_INFINITY, maximizing, save }
}
/// Per frame.
pub fn frame(&mut self, ctx: &egui::Context, config: Option<&dyn ConfigStore>) {
let (now, maximized) = ctx.input(|i| (i.time, i.viewport().maximized == Some(true)));
if let Some(until) = self.maximizing {
// A system that never maximizes the window is believed after a moment.
let until = until.min(now + MAXIMIZE_WAIT);
self.maximizing = (!maximized && now < until).then_some(until);
}
if self.maximizing.is_none() {
track(ctx, &mut self.geometry);
}
if now - self.last_attempt >= 1.0 {
self.last_attempt = now;
self.save(config);
}
}
/// Write the geometry when it differs from the saved one.
pub fn save(&mut self, config: Option<&dyn ConfigStore>) {
let (Some(config), Some(g)) = (config, self.geometry) else { return };
if !self.save || self.saved == Some(g) {
return;
}
match serde_json::to_string(&g).map_err(|e| e.to_string()).and_then(|text| config.write(FILE, &text).map_err(|e| e.to_string())) {
Ok(()) => self.saved = Some(g),
Err(e) => log::warn!("could not save the window geometry: {e}"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use effectcraft_engine::config::MemoryConfig;
fn monitor(x: i32, y: i32, w: u32, h: u32, scale: f64) -> Monitor {
Monitor::new(PhysicalPosition::new(x, y), PhysicalSize::new(w, h), scale)
}
fn saved(pos: Option<[i32; 2]>, size: [f32; 2], maximized: bool) -> Option<WindowGeometry> {
Some(WindowGeometry { pos, size, maximized })
}
#[test]
fn the_first_window_fits_its_screen() {
// 2560 × 1440: the default size, where the system puts it.
let big = [monitor(0, 0, 2560, 1440, 1.0)];
assert_eq!(place(None, &big, Some(0)), WindowGeometry { pos: None, size: DEFAULT_SIZE, maximized: false });
// 1920 × 1080, 1600 × 900, 1366 × 768 and 1920 × 1080 at 150% (1280 × 720 points):
// maximized, and a size that fits the screen with room for the taskbar once un-maximized.
for (m, size) in [
(monitor(0, 0, 1920, 1080, 1.0), [1680.0, 952.0]),
(monitor(0, 0, 1600, 900, 1.0), [1536.0, 772.0]),
(monitor(0, 0, 1366, 768, 1.0), [1302.0, 640.0]),
(monitor(0, 0, 1920, 1080, 1.5), [1216.0, 592.0]),
] {
let g = place(None, &[m], Some(0));
assert!(g.maximized, "{m:?}");
assert_eq!(g.size, size, "{m:?}");
let [x, y] = g.pos.unwrap();
let px = m.pixels(g.size);
assert!(x >= 0 && y >= 0 && i64::from(x) + px[0] <= m.size[0] && i64::from(y) + px[1] <= m.size[1], "{m:?}: {g:?}");
}
// No monitor information: the default size, whatever was saved.
assert_eq!(place(None, &[], None), WindowGeometry { pos: None, size: DEFAULT_SIZE, maximized: false });
assert_eq!(place(saved(Some([9000, 9000]), [1e9, 1e9], true), &[], None).size, DEFAULT_SIZE);
}
#[test]
fn the_saved_window_comes_back_where_it_was() {
let two = [monitor(0, 0, 2560, 1440, 1.0), monitor(2560, 0, 1920, 1080, 1.0)];
for g in [saved(Some([100, 80]), [1200.0, 800.0], false), saved(Some([2700, 50]), [1000.0, 700.0], true), saved(None, [1000.0, 700.0], false)] {
assert_eq!(Some(place(g, &two, Some(0))), g);
}
}
#[test]
fn a_saved_window_stays_on_a_connected_monitor() {
let laptop = [monitor(0, 0, 1600, 900, 1.0)];
// Last time on a second monitor that is gone: centred on the remaining one.
let g = place(saved(Some([2700, 50]), [1000.0, 700.0], false), &laptop, Some(0));
assert_eq!(g, WindowGeometry { pos: Some([300, 100]), size: [1000.0, 700.0], maximized: false });
// Half off the monitor's right and bottom edges: moved fully onto it.
let g = place(saved(Some([1200, 600]), [1000.0, 700.0], false), &laptop, Some(0));
assert_eq!(g.pos, Some([600, 200]));
// Bigger than the screen now (it was on a larger one): it fits, and opens maximized.
let g = place(saved(Some([0, 0]), [2400.0, 1300.0], false), &laptop, Some(0));
assert_eq!(g, WindowGeometry { pos: Some([0, 0]), size: [1536.0, 772.0], maximized: true });
// A monitor left of the primary one (negative coordinates) still counts.
let left = [monitor(0, 0, 1920, 1080, 1.0), monitor(-1920, 0, 1920, 1080, 1.0)];
assert_eq!(place(saved(Some([-1800, 40]), [1000.0, 700.0], false), &left, Some(0)).pos, Some([-1800, 40]));
// Nonsense sizes from a damaged preferences file are kept within the screen.
let g = place(saved(Some([i32::MAX, i32::MIN]), [-5.0, f32::MAX], false), &laptop, Some(0));
assert_eq!(g, WindowGeometry { pos: Some([320, 64]), size: [960.0, 772.0], maximized: true });
let g = place(saved(Some([5, 5]), [f32::NEG_INFINITY, f32::INFINITY], false), &laptop, Some(0));
assert_eq!(g, WindowGeometry { pos: Some([320, 64]), size: [960.0, 772.0], maximized: true });
}
#[test]
fn the_window_geometry_is_tracked_for_the_next_launch() {
let ctx = egui::Context::default();
let mut geometry = None;
let mut frame = |info: egui::ViewportInfo| {
let mut input = egui::RawInput::default();
input.viewports.insert(egui::ViewportId::ROOT, info);
ctx.run_ui(input, |ui| track(ui.ctx(), &mut geometry)).textures_delta.clear();
geometry
};
let rect = egui::Rect::from_min_size(egui::pos2(20.0, 30.0), egui::vec2(1000.0, 600.0));
let normal = egui::ViewportInfo {
native_pixels_per_point: Some(1.5),
inner_rect: Some(rect),
outer_rect: Some(rect),
maximized: Some(false),
..Default::default()
};
// Position in physical pixels, size in points.
let g = WindowGeometry { pos: Some([30, 45]), size: [1000.0, 600.0], maximized: false };
assert_eq!(frame(normal.clone()), Some(g));
// Maximized or minimized: the size to restore stays.
let big = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 700.0));
let maximized = egui::ViewportInfo { inner_rect: Some(big), outer_rect: Some(big), maximized: Some(true), ..normal.clone() };
assert_eq!(frame(maximized), Some(WindowGeometry { maximized: true, ..g }));
let minimized = egui::ViewportInfo { inner_rect: None, outer_rect: None, minimized: Some(true), ..normal.clone() };
assert_eq!(frame(minimized), Some(WindowGeometry { maximized: true, ..g }));
assert_eq!(frame(normal), Some(g));
}
/// A window restored maximized keeps the size it returns to, though its first frames report
/// the maximized size as an ordinary one.
#[test]
fn opening_maximized_keeps_the_size_to_return_to() {
let ctx = egui::Context::default();
let g = WindowGeometry { pos: Some([40, 30]), size: [1000.0, 600.0], maximized: true };
let mut tracker = Tracker::new(Some(g), Some(g), true);
let big = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1728.0, 1052.0));
let small = egui::Rect::from_min_size(egui::pos2(40.0, 30.0), egui::vec2(900.0, 500.0));
let frame = |tracker: &mut Tracker, time: f64, rect: egui::Rect, maximized: bool| {
let info = egui::ViewportInfo { inner_rect: Some(rect), outer_rect: Some(rect), maximized: Some(maximized), ..Default::default() };
let mut input = egui::RawInput { time: Some(time), ..Default::default() };
input.viewports.insert(egui::ViewportId::ROOT, info);
ctx.run_ui(input, |ui| tracker.frame(ui.ctx(), None)).textures_delta.clear();
tracker.geometry
};
assert_eq!(frame(&mut tracker, 0.0, big, false), Some(g));
assert_eq!(frame(&mut tracker, 0.5, big, true), Some(g));
// Un-maximized by the user afterwards: tracked as usual.
let restored = WindowGeometry { pos: Some([40, 30]), size: [900.0, 500.0], maximized: false };
assert_eq!(frame(&mut tracker, 0.6, small, false), Some(restored));
// A system that doesn't maximize: the window's real size is tracked after the wait.
let mut tracker = Tracker::new(Some(g), Some(g), true);
assert_eq!(frame(&mut tracker, 10.0, small, false), Some(g));
assert_eq!(frame(&mut tracker, 10.0 + MAXIMIZE_WAIT, small, false), Some(restored));
}
/// The reported bug: the window opened at the default size and place on every launch.
#[test]
fn the_window_geometry_is_saved_and_loaded() {
let config = MemoryConfig::default();
assert_eq!(load(&config), None);
let g = WindowGeometry { pos: Some([30, 45]), size: [1000.0, 600.0], maximized: true };
let mut tracker = Tracker::new(Some(g), None, true);
tracker.save(Some(&config));
assert_eq!(load(&config), Some(g));
// Positions the system doesn't report (Wayland) round-trip too.
tracker.geometry = Some(WindowGeometry { pos: None, ..g });
tracker.save(Some(&config));
assert_eq!(load(&config), Some(WindowGeometry { pos: None, ..g }));
// An agent-driven run leaves the saved window alone.
Tracker::new(Some(g), None, false).save(Some(&config));
assert_eq!(load(&config).and_then(|g| g.pos), None);
// A damaged or oversized file is ignored: the window opens at its default.
for text in ["{", "null", "{\"pos\":[1],\"size\":[1,2],\"maximized\":false}", &" ".repeat(MAX_BYTES + 1)] {
config.write(FILE, text).unwrap();
assert_eq!(load(&config), None, "{text:.20}");
}
}
}

View File

@ -1,409 +0,0 @@
{
"schema": 1,
"repo": "storytold/effectcraft",
"branch": "main",
"head": "30aaddf7c23329dcdfc72a5bf65b55747bbd6be1",
"head_date": "2026-10-09T17:19:03Z",
"commits": 656,
"unattributed_commits": 0,
"contributors": [
{
"login": "echelon",
"people_entry": true,
"github_name": null,
"prs": 35,
"commits": 366,
"lines_added": 327779,
"lines_deleted": 14400,
"binary_added": 96,
"binary_deleted": 0,
"first_commit": "2026-10-01T16:48:39Z",
"last_commit": "2026-10-09T15:37:20Z"
},
{
"login": "storybored379",
"people_entry": false,
"github_name": null,
"prs": 7,
"commits": 8,
"lines_added": 1041,
"lines_deleted": 37,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-03T16:31:41Z",
"last_commit": "2026-10-09T05:22:16Z"
},
{
"login": "bflatastic",
"people_entry": false,
"github_name": null,
"prs": 106,
"commits": 214,
"lines_added": 34610,
"lines_deleted": 6196,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-03T17:19:22Z",
"last_commit": "2026-10-09T17:19:03Z"
},
{
"login": "jonassneu",
"people_entry": true,
"github_name": "Jonas Neubauer",
"prs": 5,
"commits": 6,
"lines_added": 1391,
"lines_deleted": 45,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-05T16:01:41Z",
"last_commit": "2026-10-09T04:22:05Z"
},
{
"login": "fortesnow",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 258,
"lines_deleted": 7,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-06T01:36:48Z",
"last_commit": "2026-10-06T01:36:48Z"
},
{
"login": "dexterlabs1",
"people_entry": false,
"github_name": null,
"prs": 3,
"commits": 4,
"lines_added": 3693,
"lines_deleted": 691,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-06T05:00:31Z",
"last_commit": "2026-10-06T23:54:13Z"
},
{
"login": "midasdf",
"people_entry": false,
"github_name": null,
"prs": 3,
"commits": 3,
"lines_added": 1030,
"lines_deleted": 20,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-06T07:22:23Z",
"last_commit": "2026-10-08T14:36:26Z"
},
{
"login": "EthanSuquet",
"people_entry": false,
"github_name": null,
"prs": 2,
"commits": 2,
"lines_added": 45,
"lines_deleted": 549,
"binary_added": 0,
"binary_deleted": 67,
"first_commit": "2026-10-06T19:02:20Z",
"last_commit": "2026-10-07T14:06:42Z"
},
{
"login": "coygeek",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 4,
"lines_deleted": 0,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T02:36:22Z",
"last_commit": "2026-10-07T02:36:22Z"
},
{
"login": "Luskish",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 252,
"lines_deleted": 32,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T02:51:59Z",
"last_commit": "2026-10-07T02:51:59Z"
},
{
"login": "ulanch",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 4,
"lines_deleted": 3,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T04:38:19Z",
"last_commit": "2026-10-08T04:38:19Z"
},
{
"login": "switch87",
"people_entry": true,
"github_name": null,
"prs": 1,
"commits": 2,
"lines_added": 15,
"lines_deleted": 0,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T09:25:55Z",
"last_commit": "2026-10-08T09:27:34Z"
},
{
"login": "pat-the-wizard",
"people_entry": false,
"github_name": null,
"prs": 2,
"commits": 3,
"lines_added": 64,
"lines_deleted": 10,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T10:14:48Z",
"last_commit": "2026-10-08T11:58:55Z"
},
{
"login": "h-a-n-a-s-h-i",
"people_entry": false,
"github_name": null,
"prs": 0,
"commits": 2,
"lines_added": 74,
"lines_deleted": 74,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T12:13:07Z",
"last_commit": "2026-10-08T14:02:02Z"
},
{
"login": "prekabreki",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 53,
"lines_deleted": 8,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T13:03:19Z",
"last_commit": "2026-10-08T13:03:19Z"
},
{
"login": "XusBadia",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 185,
"lines_deleted": 1,
"binary_added": 1,
"binary_deleted": 0,
"first_commit": "2026-10-08T13:12:32Z",
"last_commit": "2026-10-08T13:12:32Z"
},
{
"login": "FeiLiuEM",
"people_entry": false,
"github_name": null,
"prs": 3,
"commits": 10,
"lines_added": 3493,
"lines_deleted": 659,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T16:42:28Z",
"last_commit": "2026-10-09T12:34:03Z"
},
{
"login": "DWAA1660",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 127,
"lines_deleted": 2,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T16:47:38Z",
"last_commit": "2026-10-08T16:47:38Z"
},
{
"login": "Oldrich333",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 59,
"lines_deleted": 0,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T20:32:51Z",
"last_commit": "2026-10-08T20:32:51Z"
},
{
"login": "MindlessRuff",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 114,
"lines_deleted": 3,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T03:22:49Z",
"last_commit": "2026-10-09T03:22:49Z"
},
{
"login": "HEOJUNFO",
"people_entry": false,
"github_name": null,
"prs": 2,
"commits": 2,
"lines_added": 83,
"lines_deleted": 11,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T03:34:00Z",
"last_commit": "2026-10-09T06:33:04Z"
},
{
"login": "thanhcong2k7",
"people_entry": false,
"github_name": null,
"prs": 3,
"commits": 3,
"lines_added": 138,
"lines_deleted": 31,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T04:52:22Z",
"last_commit": "2026-10-09T05:58:31Z"
},
{
"login": "cjcwwww",
"people_entry": true,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 81,
"lines_deleted": 78,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T05:52:09Z",
"last_commit": "2026-10-09T05:52:09Z"
},
{
"login": "KazW",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 76,
"lines_deleted": 18,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T10:38:07Z",
"last_commit": "2026-10-09T10:38:07Z"
},
{
"login": "dmatviichuk",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 2,
"lines_added": 1528,
"lines_deleted": 18,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T12:46:20Z",
"last_commit": "2026-10-09T12:46:20Z"
},
{
"login": "cdinhoo",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 215,
"lines_deleted": 11,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T12:56:05Z",
"last_commit": "2026-10-09T12:56:05Z"
},
{
"login": "sanyamsingh001",
"people_entry": true,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 7,
"lines_deleted": 1,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-09T13:03:05Z",
"last_commit": "2026-10-09T13:03:05Z"
}
],
"models": [
{
"id": "anthropic/claude-opus/5.5",
"company": "Anthropic",
"model": "Claude Opus",
"version": "5.5",
"commits": 603,
"lines_added": 362122,
"lines_deleted": 21289
},
{
"id": "anthropic/claude",
"company": "Anthropic",
"model": "Claude",
"version": "",
"commits": 16,
"lines_added": 1577,
"lines_deleted": 146
},
{
"id": "openai/codex",
"company": "OpenAI",
"model": "Codex",
"version": "",
"commits": 5,
"lines_added": 1046,
"lines_deleted": 40
},
{
"id": "anthropic/claude-opus/4.8",
"company": "Anthropic",
"model": "Claude Opus",
"version": "4.8",
"commits": 1,
"lines_added": 1632,
"lines_deleted": 3
},
{
"id": "anthropic/claude-sonnet/5.5",
"company": "Anthropic",
"model": "Claude Sonnet",
"version": "5.5",
"commits": 1,
"lines_added": 59,
"lines_deleted": 0
}
]
}

View File

@ -1,66 +0,0 @@
# Contributor names for the About window (craftrules standards/contributors.md).
#
# Everyone who contributes is credited by GitHub username automatically. Anything more is up to
# you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a
# maintainer may add a person only when that person asked, recording where in consent_source.
#
# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \
# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name]
#
# Or copy this block, replacing your-github-username (every field is optional):
#
# [people.your-github-username]
# real_name = "Your Name" # shown when the viewer picks "Real name"
# display_name = "Nickname" # shown when the viewer picks "Display name"
# sync_github_name = true # or: use the name on your public GitHub profile as display name
#
# To remove your names, delete your entry (or set fields empty) in a PR. The next
# contributors.py run, and the next build, drop them.
[people.echelon]
real_name = "Brandon Thomas"
display_name = "Brandon Thomas"
sync_github_name = false
[people.switch87]
real_name = "Gert Pellin"
display_name = "Gert Pellin"
sync_github_name = false
[people.jonassneu]
sync_github_name = true
[people.cjcwwww]
display_name = "ShirokoWaWu"
[people.sanyamsingh001]
display_name = "sanyamsingh001"
sync_github_name = false
[people.Aavirash]
display_name = "Asquareportal"
[people.tylerjenningsw]
real_name = "Tyler Jennings"
display_name = "tylerjenningsw"
sync_github_name = false
[people.llopjulen]
real_name = "Julen Llop"
display_name = "Julen Llop"
sync_github_name = false
[people.vbasky]
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"

View File

@ -1,136 +0,0 @@
//! Durable MCP checkpoints, isolated from the desktop and other headless sessions.
use std::path::{Path, PathBuf};
use effectcraft_engine::config::{FileOps, StdFiles};
use effectcraft_engine::{Session, autosave, prefs::Prefs};
use serde_json::{Value, json};
pub(crate) struct AutoSave {
folder: PathBuf,
latest: Option<String>,
error: Option<String>,
previous: Vec<Value>,
manifest: Option<Value>,
}
impl AutoSave {
pub fn new(root: &Path, prefs: &Prefs) -> std::io::Result<Self> {
let base = if prefs.auto_save.location == "custom" && !prefs.auto_save.folder.trim().is_empty() {
PathBuf::from(prefs.auto_save.folder.trim()).join("MCP")
} else {
root.join(autosave::AUTOSAVE_FOLDER).join("MCP")
};
if base.to_str().is_none() {
return Err(std::io::Error::other("the MCP auto-save folder must be valid UTF-8; set EFFECTCRAFT_CONFIG_DIR to a UTF-8 path"));
}
std::fs::create_dir_all(&base)?;
let previous = previous_sessions(&base);
let folder = unique_folder(&base)?;
Ok(Self { folder, latest: None, error: None, previous, manifest: None })
}
pub fn folder(&self) -> &Path {
&self.folder
}
pub fn info(&self) -> Value {
json!({
"enabled": true, "folder": self.folder.to_string_lossy(), "latestPath": self.latest,
"error": self.error, "previousSessions": self.previous,
"recovery": "Recover with open_project {path: latestPath}, then save_project {path: a normal project file}. Previous sessions may still be running; they are never opened automatically.",
})
}
pub fn checkpoint(&mut self, s: &mut Session, ended: bool) -> std::io::Result<()> {
let result = self.write(s, ended);
if let Err(e) = &result {
self.error = Some(format!("MCP auto-save in {} failed: {e}", self.folder.display()));
}
result.map_err(|e| std::io::Error::other(format!("MCP auto-save in {} failed: {e}", self.folder.display())))
}
fn write(&mut self, s: &mut Session, ended: bool) -> std::io::Result<()> {
if s.is_dirty() && s.autosave.saved_revision != Some(s.revision) {
s.autosave.background = false;
s.autosave_now().map_err(std::io::Error::other)?;
}
s.autosave_wait().map_err(std::io::Error::other)?;
// Explicit file.autoSave and save-on-render-start use the same override. Keep their
// successful latest path too, even when the revision was already checkpointed.
if let Some(path) = &s.autosave.last_path {
self.latest = Some(path.clone());
}
let record = json!({
"pid": autosave::pid(), "project": s.path, "autosave": self.latest,
"dirty": s.is_dirty(), "revision": s.revision, "ended": ended,
});
// Queries don't rewrite the same manifest or rotate project slots.
if self.manifest.as_ref() != Some(&record) && self.latest.is_some() {
StdFiles.write(&self.folder.join("session.json"), record.to_string().as_bytes())?;
self.manifest = Some(record);
}
self.error = None;
if ended && self.latest.is_none() {
let _ = std::fs::remove_dir(&self.folder);
}
Ok(())
}
}
fn unique_folder(base: &Path) -> std::io::Result<PathBuf> {
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT: AtomicU64 = AtomicU64::new(0);
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos();
for _ in 0..100 {
let folder = base.join(format!("{}-{stamp}-{}", autosave::pid(), NEXT.fetch_add(1, Ordering::Relaxed)));
match std::fs::create_dir(&folder) {
Ok(()) => return Ok(folder),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e),
}
}
Err(std::io::Error::other("cannot reserve a unique MCP auto-save folder"))
}
fn previous_sessions(base: &Path) -> Vec<Value> {
// Scan directory metadata with bounded memory, then read only the newest 200 small
// manifests. Limiting read_dir before sorting could hide a newly killed session once
// older folders accumulated. Recovery is explicit, so running sessions are safe to list.
let Ok(entries) = std::fs::read_dir(base) else { return vec![] };
let mut records = std::collections::BTreeSet::new();
for entry in entries.flatten() {
let path = entry.path().join("session.json");
let Ok(meta) = path.metadata() else { continue };
if !meta.is_file() || meta.len() > 64 * 1024 {
continue;
}
records.insert((meta.modified().ok(), path));
if records.len() > 200 {
records.pop_first();
}
}
records
.into_iter()
.rev()
.filter_map(|(_, path)| {
use std::io::Read;
let mut bytes = Vec::new();
std::fs::File::open(path).ok()?.take(64 * 1024 + 1).read_to_end(&mut bytes).ok()?;
if bytes.len() > 64 * 1024 {
return None;
}
let record: Value = serde_json::from_slice(&bytes).ok()?;
let saved = record.get("autosave")?.as_str()?;
if saved.len() > 4096 || record.get("dirty") != Some(&Value::Bool(true)) || !Path::new(saved).is_file() {
return None;
}
let project = record.get("project").and_then(Value::as_str).filter(|p| p.len() <= 4096);
Some(json!({"autosave": saved, "project": project, "dirty": true,
"ended": record.get("ended").and_then(Value::as_bool).unwrap_or(false),
"pid": record.get("pid").and_then(Value::as_u64),
}))
})
.take(5)
.collect()
}

View File

@ -4,16 +4,9 @@ use effectcraft_engine::Session;
use effectcraft_engine::time::Tick;
use serde_json::{Value, json};
use crate::confine::Confinement;
use crate::{BridgeClient, Error, Result, base64, encode_png, recompress_png};
pub struct Backend {
target: Target,
/// File access confined to folders (`--automation-read-root` / `--automation-write-root`).
confinement: Option<Confinement>,
}
enum Target {
pub enum Backend {
/// An in-process session (no window): engine commands, inspection and rendering.
Headless(Box<Session>),
/// A running desktop app reached over its control channel: everything, plus the live UI.
@ -31,66 +24,43 @@ pub struct Frame {
impl Backend {
pub fn headless(session: Session) -> Self {
Backend { target: Target::Headless(Box::new(session)), confinement: None }
Backend::Headless(Box::new(session))
}
/// Bridge to the app's control channel at `addr` (`9877` or `127.0.0.1:9877`).
pub fn bridge(addr: &str) -> Result<Self> {
Ok(Backend { target: Target::Bridge(BridgeClient::new(addr)?), confinement: None })
}
/// Confine the files the commands run here may read and write ([`Confinement`]).
pub fn confine(&mut self, confinement: Confinement) {
self.confinement = Some(confinement);
}
pub fn confinement(&self) -> Option<&Confinement> {
self.confinement.as_ref()
Ok(Backend::Bridge(BridgeClient::new(addr)?))
}
pub fn is_bridge(&self) -> bool {
matches!(self.target, Target::Bridge(_))
matches!(self, Backend::Bridge(_))
}
pub fn session(&mut self) -> Option<&mut Session> {
match &mut self.target {
Target::Headless(s) => Some(s),
Target::Bridge(_) => None,
match self {
Backend::Headless(s) => Some(s),
Backend::Bridge(_) => None,
}
}
/// Run an engine command by id (checked against the [`Confinement`], if any).
/// Run an engine command by id.
pub fn exec(&mut self, id: &str, params: Value) -> Result<Value> {
let params = if params.is_null() { json!({}) } else { params };
if let Some(c) = self.confinement.clone() {
let lookup = match c.check_command(id, &params)? {
Some(crate::confine::Lookup::ProjectFile) => Some((crate::confine::Lookup::ProjectFile, self.run("project.summary", json!({}))?)),
Some(crate::confine::Lookup::RenderOutputs) => Some((crate::confine::Lookup::RenderOutputs, self.run("renderQueue.list", json!({}))?)),
None => None,
};
if let Some((lookup, found)) = lookup {
c.check_lookup(&lookup, &found)?;
}
}
self.run(id, params)
}
fn run(&mut self, id: &str, params: Value) -> Result<Value> {
match &mut self.target {
Target::Headless(s) => {
match self {
Backend::Headless(s) => {
let r = s.execute_checked(id, params);
s.drain_events();
Ok(r?)
}
Target::Bridge(b) => b.call("engine.execute", json!({"command": id, "params": params})),
Backend::Bridge(b) => b.call("engine.execute", json!({"command": id, "params": params})),
}
}
/// Call a raw control-channel method (bridge only).
pub fn control(&mut self, method: &str, params: Value) -> Result<Value> {
match &mut self.target {
Target::Bridge(b) => b.call(method, if params.is_null() { json!({}) } else { params }),
Target::Headless(_) => Err(Error::BadArgs(NEED_BRIDGE.into())),
match self {
Backend::Bridge(b) => b.call(method, if params.is_null() { json!({}) } else { params }),
Backend::Headless(_) => Err(Error::BadArgs(NEED_BRIDGE.into())),
}
}
@ -103,15 +73,15 @@ impl Backend {
/// [`Backend::render`]; `transparent` keeps the frame's alpha instead of compositing it over
/// the comp's background colour.
pub fn render_with(&mut self, comp: Option<&Value>, time: Option<f64>, max_side: u32, transparent: bool) -> Result<Frame> {
match &mut self.target {
Target::Headless(s) => {
match self {
Backend::Headless(s) => {
let cid = s.resolve_comp(comp).map_err(|_| no_comp(comp))?;
let t = time.map(Tick::from_seconds_f64).unwrap_or_else(|| s.time());
let (w, h, rgba) = s.render_rgba8_alpha(cid, t, max_side, transparent)?;
let png = encode_png(w, h, rgba, max_side)?;
Ok(Frame { png, comp: json!(cid.0), time: t.seconds(), width: w, height: h })
}
Target::Bridge(b) => {
Backend::Bridge(b) => {
let mut p = json!({"max_side": max_side, "base64": true});
if transparent {
p["transparent"] = json!(true);

View File

@ -1,276 +0,0 @@
//! File access confinement for automation (`effectcraft-cli mcp --automation-read-root DIR
//! --automation-write-root DIR`, as in PhotoCraft): an agent may only read files under a read
//! root (or a write root) and only write under a write root.
//!
//! Every engine command an agent runs ([`crate::Backend::exec`], also each step of an
//! `engine.batch`) is checked before it runs: its file parameters must be absolute paths inside
//! the roots (symbolic links resolved, no `..`), any other absolute path in its parameters too,
//! and commands that write to a file they are not given (Save, Increment and Save, rendering the
//! Render Queue) are checked against where they would write. Commands that run code or open
//! other applications (scripts, Execute File, Reveal in Finder…) are refused while confined.
use std::path::{Component, Path, PathBuf};
use serde_json::Value;
use crate::{Error, Result};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Access {
Read,
Write,
}
/// The folders an agent may read and write.
#[derive(Clone, Debug)]
pub struct Confinement {
read: Vec<PathBuf>,
write: Vec<PathBuf>,
}
/// Commands that run code, act outside the project or open other applications.
const REFUSED: &[&str] = &[
"script.run",
"file.runScript",
"file.installScript",
"file.installScriptUIPanel",
"file.uninstallScript",
"file.executeFile",
"file.revealInFinder",
"layer.openSource",
"help.revealLogFile",
"file.autoSave",
];
/// Commands that read files named by these parameters.
const READS: &[(&str, &[&str])] = &[
("file.open", &["path"]),
("file.openRecent", &["path"]),
("file.import", &["path", "paths"]),
("file.importMultiple", &["paths"]),
("file.importRecent", &["path"]),
("file.importLottie", &["path"]),
("file.importTimeline", &["path"]),
("file.replaceFootage", &["path"]),
("file.relinkFootage", &["folder"]),
("file.setProxy", &["path"]),
("file.watchFolder", &["folder"]),
("anim.applyPreset", &["path"]),
("anim.applyRecentPreset", &["path"]),
("keys.rpfCameraImport", &["path"]),
("essential.importTemplate", &["path"]),
("view.importGuides", &["path"]),
("shortcuts.import", &["path"]),
("effect.plugins.load", &["path", "folder"]),
("mediaBrowser.go", &["path"]),
("mediaBrowser.addFavorite", &["path"]),
];
/// Commands that write files named by these parameters.
const WRITES: &[(&str, &[&str])] = &[
("file.save", &["path"]),
("file.saveAs", &["path"]),
("file.saveCopy", &["path"]),
("file.saveCopyAsXml", &["path"]),
("file.collectFiles", &["folder"]),
("file.exportLottie", &["path"]),
("comp.saveFrameAs", &["path"]),
("comp.saveFrameAsPsd", &["path"]),
("comp.saveFrameAsExr", &["path"]),
("anim.savePreset", &["path"]),
("view.exportGuides", &["path"]),
("render.saveCurrentPreview", &["path"]),
("render.preRender", &["output"]),
("essential.exportTemplate", &["path"]),
("shortcuts.export", &["path"]),
("renderQueue.add", &["output", "path"]),
];
/// Commands that write where they are not told: the project's file, the Render Queue's outputs.
const IMPLICIT_WRITES: &[&str] = &["file.save", "file.incrementAndSave", "renderQueue.render", "render.preRender"];
/// What must be looked up before an implicitly writing command may run.
#[derive(Debug, PartialEq, Eq)]
pub enum Lookup {
/// The open project's file (`project.summary` → `path`).
ProjectFile,
/// Every output of the queued Render Queue items (`renderQueue.list`).
RenderOutputs,
}
impl Confinement {
/// Roots that must exist (each resolved to its real path). With no write root nothing may be
/// written; write roots may also be read.
pub fn new(read: &[PathBuf], write: &[PathBuf]) -> Result<Self> {
let real = |p: &PathBuf| p.canonicalize().map_err(|e| Error::BadArgs(format!("automation root {}: {e}", p.display())));
let read = read.iter().map(real).collect::<Result<Vec<_>>>()?;
let write = write.iter().map(real).collect::<Result<Vec<_>>>()?;
Ok(Self { read, write })
}
fn roots(&self, access: Access) -> impl Iterator<Item = &PathBuf> {
let read = if access == Access::Read { &self.read[..] } else { &[] };
read.iter().chain(&self.write)
}
/// `path` resolved, if it is inside the roots for `access`.
pub fn check(&self, path: &str, access: Access) -> Result<PathBuf> {
let denied = |why: &str| {
let kind = if access == Access::Read { "read" } else { "write" };
let roots: Vec<String> = self.roots(access).map(|r| r.display().to_string()).collect();
let roots = if roots.is_empty() { "none".to_string() } else { roots.join(", ") };
Error::BadArgs(format!("cannot {kind} `{path}`: {why} (automation {kind} roots: {roots})"))
};
let p = Path::new(path);
if !p.is_absolute() {
return Err(denied("give an absolute path"));
}
if p.components().any(|c| matches!(c, Component::ParentDir)) {
return Err(denied("`..` is not allowed"));
}
let real = resolve(p).ok_or_else(|| denied("it can't be resolved"))?;
if self.roots(access).any(|r| real.starts_with(r)) { Ok(real) } else { Err(denied("it is outside the automation roots")) }
}
/// Check engine command `id` with `params` before it runs. Returns what must still be looked
/// up and passed to [`Confinement::check_lookup`] for commands that write implicitly.
pub fn check_command(&self, id: &str, params: &Value) -> Result<Option<Lookup>> {
if REFUSED.contains(&id) {
return Err(Error::BadArgs(format!("`{id}` is not available while file access is confined (--automation-read-root/--automation-write-root)")));
}
if id == "engine.batch" {
for step in params.get("steps").and_then(Value::as_array).into_iter().flatten() {
let Some(inner) = step.get("command").or_else(|| step.get("id")).and_then(Value::as_str) else { continue };
let inner_params = step.get("params").cloned().unwrap_or(Value::Null);
let named = keys_of(READS, inner).or_else(|| keys_of(WRITES, inner)).unwrap_or(&[]);
// A step's `$N` reference is resolved only as the batch runs: never for a file.
if named.iter().filter_map(|k| inner_params.get(*k)).any(has_reference) {
return Err(Error::BadArgs(format!("`{inner}` in a batch: file paths can't be `$N` references while file access is confined")));
}
if self.check_command(inner, &inner_params)?.is_some() {
return Err(Error::BadArgs(format!("`{inner}` writes to its project's or queue's files: run it on its own while file access is confined")));
}
}
return Ok(None);
}
let (named, access) = match (keys_of(WRITES, id), keys_of(READS, id)) {
(Some(k), _) => (k, Access::Write),
(None, Some(k)) => (k, Access::Read),
(None, None) if id.starts_with("renderQueue.") || id == "prefs.set" => (&[][..], Access::Write),
(None, None) => (&[][..], Access::Read),
};
let mut given = false;
for key in named {
let Some(v) = params.get(*key).filter(|v| !v.is_null()) else { continue };
given = true;
let paths: Vec<&Value> = match v {
Value::Array(a) => a.iter().collect(),
v => vec![v],
};
for p in paths {
// A Render Queue output may name a template instead of a file.
match p.as_str() {
Some(s) if id == "render.preRender" && !looks_like_path(s) => {}
Some(s) => {
self.check(s, access)?;
}
None => return Err(Error::BadArgs(format!("`{id}`: `{key}` must be a path"))),
}
}
}
// Any other absolute path the command is given.
let mut other = vec![];
absolute_strings(params, named, &mut other);
for s in other {
self.check(&s, access)?;
}
// An entry of a recent list by index is a path the agent didn't give.
if matches!(id, "file.openRecent" | "file.importRecent" | "anim.applyRecentPreset") && !given {
return Err(Error::BadArgs(format!("`{id}`: give `path` while file access is confined")));
}
if IMPLICIT_WRITES.contains(&id) && !(given && id == "file.save") {
return Ok(Some(if id == "file.save" || id == "file.incrementAndSave" { Lookup::ProjectFile } else { Lookup::RenderOutputs }));
}
Ok(None)
}
/// Check what a [`Lookup`] found: `project.summary` or `renderQueue.list`'s result.
pub fn check_lookup(&self, lookup: &Lookup, found: &Value) -> Result<()> {
match lookup {
Lookup::ProjectFile => match found.get("path").and_then(Value::as_str) {
// Increment and Save writes beside it.
Some(p) => self.check(p, Access::Write).map(drop),
None => Err(Error::BadArgs("the project has no file yet: give `path` (Save As) inside an automation write root".into())),
},
Lookup::RenderOutputs => {
let items = found.get("items").or(Some(found)).and_then(Value::as_array).cloned().unwrap_or_default();
for it in items.iter().filter(|it| it.get("render").and_then(Value::as_bool) != Some(false)) {
let extra = it.get("extraOutputs").and_then(Value::as_array).into_iter().flatten().map(|o| o.get("outputPath"));
for p in std::iter::once(it.get("outputPath")).chain(extra).chain([it.get("logPath")]).flatten() {
if let Some(p) = p.as_str() {
self.check(p, Access::Write)?;
}
}
}
Ok(())
}
}
}
}
fn keys_of(table: &[(&str, &'static [&'static str])], id: &str) -> Option<&'static [&'static str]> {
table.iter().find(|(c, _)| *c == id).map(|(_, k)| *k)
}
fn has_reference(v: &Value) -> bool {
match v {
Value::String(s) => s.starts_with('$'),
Value::Array(a) => a.iter().any(has_reference),
_ => false,
}
}
/// A file path rather than a template name: has a separator or an extension.
fn looks_like_path(s: &str) -> bool {
s.contains(['/', '\\']) || Path::new(s).extension().is_some()
}
/// Strings in `v` (recursively, except under the top-level `skip` keys) that are absolute paths,
/// `~` paths or `file:` URLs.
fn absolute_strings(v: &Value, skip: &[&str], out: &mut Vec<String>) {
fn walk(v: &Value, out: &mut Vec<String>, depth: usize) {
if depth > 64 {
return;
}
match v {
Value::String(s) => {
let t = s.trim();
let drive = t.len() > 2 && t.as_bytes().get(1) == Some(&b':') && t.as_bytes().first().is_some_and(u8::is_ascii_alphabetic);
if t.starts_with('/') || t.starts_with('\\') || t.starts_with('~') || drive || t.to_ascii_lowercase().starts_with("file:") {
out.push(t.to_string());
}
}
Value::Array(a) => a.iter().for_each(|x| walk(x, out, depth + 1)),
Value::Object(o) => o.values().for_each(|x| walk(x, out, depth + 1)),
_ => {}
}
}
match v {
Value::Object(o) => o.iter().filter(|(k, _)| !skip.contains(&k.as_str())).for_each(|(_, x)| walk(x, out, 0)),
v => walk(v, out, 0),
}
}
/// `p` with symbolic links resolved: its longest existing ancestor's real path plus the rest
/// (which doesn't exist yet, so can't be a link).
fn resolve(p: &Path) -> Option<PathBuf> {
let mut rest = vec![];
let mut at = p;
loop {
if let Ok(real) = at.canonicalize() {
return Some(rest.iter().rev().fold(real, |acc: PathBuf, c| acc.join(c)));
}
rest.push(at.file_name()?.to_os_string());
at = at.parent()?;
}
}

View File

@ -15,12 +15,9 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod autosave;
pub mod backend;
pub mod base64;
pub mod bridge;
pub mod confine;
mod long_job;
pub mod server;
pub mod tools;
@ -63,8 +60,8 @@ pub fn encode_png(w: u32, h: u32, rgba: Vec<u8>, max_side: u32) -> Result<Vec<u8
let mut out = Vec::new();
let enc = PngEncoder::new_with_quality(&mut out, CompressionType::Fast, FilterType::Adaptive);
let raw = img.into_raw();
let r = if raw.as_chunks::<4>().0.iter().all(|p| p[3] == 255) {
let rgb: Vec<u8> = raw.as_chunks::<4>().0.iter().flat_map(|p| [p[0], p[1], p[2]]).collect();
let r = if raw.chunks_exact(4).all(|p| p[3] == 255) {
let rgb: Vec<u8> = raw.chunks_exact(4).flat_map(|p| [p[0], p[1], p[2]]).collect();
enc.write_image(&rgb, w, h, image::ExtendedColorType::Rgb8)
} else {
enc.write_image(&raw, w, h, image::ExtendedColorType::Rgba8)

View File

@ -1,177 +0,0 @@
//! Long renders over MCP (docs/mcp.md "Progress and cancellation"). While [`McpServer::serve`]
//! runs, a blocking `renderQueue.render` (`command_run`, headless) renders on the session's
//! background render job instead of inline: the server keeps reading stdin, answers every other
//! request in between (the job renders a snapshot of the project), reports progress as
//! `notifications/progress` when the request carried `_meta.progressToken`, and stops the render
//! on `notifications/cancelled` (deleting the partial output, sending no response).
use std::io::Write;
use std::sync::mpsc::{Receiver, RecvTimeoutError};
use std::time::Duration;
use serde_json::{Value, json};
use crate::server::{McpServer, call_result};
use crate::tools::{self, Reply};
/// A `tools/call` result for `call`, with `resultType` for modern clients.
fn result_for(call: &LongCall, r: Result<Reply, crate::Error>) -> Value {
let mut result = call_result(r);
if call.modern {
result["resultType"] = json!("complete");
}
result
}
/// How often the job is polled (and at most how often progress is reported).
const POLL: Duration = Duration::from_millis(100);
/// A `tools/call` that runs as a long job.
pub struct LongCall {
pub id: Value,
pub params: Value,
pub token: Option<Value>,
/// The request declared MCP 2026-07-28+ (its result then carries `resultType`).
pub modern: bool,
}
/// Whether `msg` is a blocking `renderQueue.render` call (valid arguments, headless backend).
pub fn long_call(server: &mut McpServer, msg: &Value) -> Option<LongCall> {
let id = msg.get("id").filter(|i| i.is_string() || i.is_number())?;
if msg.get("method").and_then(Value::as_str) != Some("tools/call") || server.backend().is_bridge() {
return None;
}
let p = msg.get("params")?;
let name = p.get("name")?.as_str()?;
let args = p.get("arguments").cloned().unwrap_or(json!({}));
if !matches!(name, "command_run" | "execute_command")
|| args.get("id").or_else(|| args.get("command")).and_then(Value::as_str) != Some("renderQueue.render")
{
return None;
}
if tools::find(name).and_then(|t| t.unknown_arg(&args)).is_some() {
return None; // the normal path reports it
}
let params = match args.get("params") {
None | Some(Value::Null) => json!({}),
Some(v) if v.is_object() => v.clone(),
_ => return None,
};
if params.get("wait").and_then(Value::as_bool) == Some(false) {
return None;
}
let token = p.get("_meta").and_then(|m| m.get("progressToken")).filter(|t| t.is_string() || t.is_number()).cloned();
Some(LongCall { id: id.clone(), params, token, modern: crate::server::is_modern(p) })
}
fn send(out: &mut impl Write, v: &Value) -> std::io::Result<()> {
out.write_all(v.to_string().as_bytes())?;
out.write_all(b"\n")?;
out.flush()
}
/// The `requestId` of a `notifications/cancelled` line.
fn cancelled_id(line: &str) -> Option<Value> {
let v: Value = serde_json::from_str(line).ok()?;
(v.get("method")?.as_str()? == "notifications/cancelled").then(|| v.get("params")?.get("requestId").cloned())?
}
impl McpServer {
/// Run `call` to completion or cancellation, handling the other messages from `inbox`
/// (`None` = stdin closed) meanwhile. Returns false when stdin closed.
pub(crate) fn run_long(&mut self, call: LongCall, inbox: &Receiver<Option<String>>, out: &mut impl Write) -> std::io::Result<bool> {
let mut params = call.params.clone();
params["wait"] = json!(false);
let start = self.guarded("command_run", &json!({"id": "renderQueue.render", "params": params}));
let items = match &start {
Ok(Reply::Json(v)) => v["items"].as_array().cloned().unwrap_or_default(),
_ => {
send(out, &json!({"jsonrpc": "2.0", "id": call.id, "result": result_for(&call, start)}))?;
return Ok(true);
}
};
let ids: Vec<u64> = items.iter().filter_map(|i| i["id"].as_u64()).collect();
let shared = self.backend().session().and_then(|s| s.render_job.as_ref().map(|job| job.shared.clone()));
let mut last = -1.0f64;
let mut last_report: Option<std::time::Instant> = None;
let mut open = true;
let mut cancelled = false;
loop {
let state = shared.as_ref().map(|job| job.snapshot());
let rendering = state.as_ref().is_some_and(|state| !state.finished);
if let (Some(token), Some(st)) = (&call.token, &state)
&& st.items_total > 0
&& !cancelled
{
let frac = if st.total > 0 { st.done as f64 / st.total as f64 } else { 0.0 };
// the current item's frames count until it is done
let progress = (st.items_done as f64 + if st.items_done < st.items_total { frac.min(1.0) } else { 0.0 }).min(st.items_total as f64);
if progress > last && (!rendering || last_report.is_none_or(|at| at.elapsed() >= POLL)) {
last = progress;
last_report = Some(std::time::Instant::now());
let message = format!(
"Rendering item {} of {}: frame {} of {}",
st.items_done.saturating_add(1).min(st.items_total),
st.items_total,
st.done,
st.total
);
send(
out,
&json!({"jsonrpc": "2.0", "method": "notifications/progress",
"params": {"progressToken": token, "progress": progress, "total": st.items_total, "message": message}}),
)?;
}
}
if !rendering {
break;
}
if !open {
// stdin closed: finish the render (a piped script expects its reply), read nothing more
std::thread::sleep(POLL);
continue;
}
match inbox.recv_timeout(POLL) {
Ok(Some(line)) => {
if cancelled_id(&line).as_ref() == Some(&call.id) {
cancelled = true;
if let Some(job) = &shared {
job.cancel.store(true, std::sync::atomic::Ordering::Relaxed);
}
continue;
}
if let Some(other) = serde_json::from_str::<Value>(&line).ok().and_then(|msg| long_call(self, &msg)) {
let result = result_for(&other, Err(crate::Error::Other("a render is already in progress".into())));
send(out, &json!({"jsonrpc":"2.0", "id":other.id, "result":result}))?;
continue;
}
if let Some(reply) = self.handle_line(&line) {
out.write_all(reply.as_bytes())?;
out.write_all(b"\n")?;
out.flush()?;
}
}
Ok(None) | Err(RecvTimeoutError::Disconnected) => open = false,
Err(RecvTimeoutError::Timeout) => {}
}
}
let Some(s) = self.backend().session() else { return Ok(open) };
s.poll_render();
s.drain_events();
let list = self.guarded("command_run", &json!({"id": "renderQueue.list"}));
let items: Vec<Value> = match &list {
Ok(Reply::Json(v)) => {
v["items"].as_array().cloned().unwrap_or_default().into_iter().filter(|i| i["id"].as_u64().is_some_and(|id| ids.contains(&id))).collect()
}
_ => Vec::new(),
};
if cancelled {
// Exporters remove only files they actually opened for this interrupted output.
return Ok(open); // MCP: no response for a cancelled request
}
let failed = ids.len().saturating_sub(items.len()).saturating_add(items.iter().filter(|item| item["statusLabel"].as_str() != Some("Done")).count());
let result = result_for(&call, Ok(Reply::Json(json!({"rendering": false, "items": items, "failed": failed}))));
send(out, &json!({"jsonrpc": "2.0", "id": call.id, "result": result}))?;
Ok(open)
}
}

View File

@ -1,58 +1,31 @@
//! MCP server: JSON-RPC 2.0, one message per line (the MCP stdio transport). Implements
//! tools and JSON resources. The stdio loop handles progress and cancellation for long renders.
//! `initialize`, `ping`, `tools/list` and `tools/call`; notifications are accepted and ignored.
use std::io::{BufRead, Write};
use std::path::Path;
use serde_json::{Value, json};
use crate::autosave::AutoSave;
use crate::tools::{self, Reply};
use crate::{Backend, Error, base64};
/// Protocol revisions we speak, newest first. We answer with the client's if we know it.
pub const PROTOCOL_VERSIONS: &[&str] = &["2025-06-18", "2025-03-26", "2024-11-05"];
const INSTRUCTIONS: &str = "EffectCraft is an After Effects-class motion graphics compositor. Everything is an engine command: command_list discovers ids and params, command_run {id, params} runs them (undoable; command_batch runs several in order). Typical flow: command_run comp.new -> command_run layer.newText / layer.newSolid (returns the layer id) -> set_property / add_keyframe -> command_run effect.apply -> render_preview to look at the result. Inspect with doc_inspect, get_project, get_comp and get_layer (every property node carries its `path`, e.g. `transform/position`, `effects/#1/blurriness`). Times are seconds. In bridge mode (app started with `--control <port>`) screenshot and the ui_* tools show and operate the live window.";
const INSTRUCTIONS: &str = "EffectCraft is an After Effects-class motion graphics compositor. Everything is an engine command: list_commands discovers ids and params, execute_command runs them (undoable). Typical flow: execute_command comp.new -> execute_command layer.newText / layer.newSolid (returns the layer id) -> set_property / add_keyframe -> execute_command effect.apply -> render_frame to look at the result. Inspect with get_project, get_comp and get_layer (every property node carries its `path`, e.g. `transform/position`, `effects/#1/blurriness`). Times are seconds. In bridge mode (app started with `--control <port>`) screenshot and the ui_* tools show and operate the live window.";
// JSON-RPC error codes.
const PARSE_ERROR: i64 = -32700;
const INVALID_REQUEST: i64 = -32600;
const METHOD_NOT_FOUND: i64 = -32601;
const INVALID_PARAMS: i64 = -32602;
const INTERNAL_ERROR: i64 = -32603;
const RESOURCE_NOT_FOUND: i64 = -32002;
pub struct McpServer {
backend: Backend,
autosave: Option<AutoSave>,
}
impl McpServer {
pub fn new(backend: Backend) -> Self {
Self { backend, autosave: None }
}
/// Opt in to durable headless checkpoints in a separate folder per server.
/// `root` is the platform config directory; settings are read by the caller.
pub fn with_autosave(mut self, root: &Path) -> crate::Result<Self> {
let s = self.backend.session().ok_or_else(|| crate::Error::BadArgs("--autosave is for headless MCP; the bridged app owns its auto-saves".into()))?;
s.autosave.background = false;
let autosave = AutoSave::new(root, &s.prefs).map_err(|e| crate::Error::Other(format!("MCP auto-save: {e}")))?;
s.autosave_folder_override = Some(autosave.folder().to_path_buf());
self.autosave = Some(autosave);
Ok(self)
}
fn autosave_info(&self) -> Value {
self.autosave.as_ref().map(AutoSave::info).unwrap_or_else(|| json!({"enabled": false}))
}
fn checkpoint(&mut self, finish: bool) -> std::io::Result<()> {
match (&mut self.autosave, self.backend.session()) {
(Some(a), Some(s)) => a.checkpoint(s, finish),
_ => Ok(()),
}
Self { backend }
}
pub fn backend(&mut self) -> &mut Backend {
@ -80,10 +53,7 @@ impl McpServer {
};
let params = msg.get("params").cloned().unwrap_or(json!({}));
Some(match self.request(method, &params) {
Ok(mut result) => {
modern_result_fields(method, &params, &mut result);
json!({"jsonrpc": "2.0", "id": id, "result": result})
}
Ok(result) => json!({"jsonrpc": "2.0", "id": id, "result": result}),
Err((code, m)) => error(id, code, &m),
})
}
@ -101,79 +71,34 @@ impl McpServer {
Some(reply.to_string())
}
/// Serve until EOF on `input`. Input is read on its own thread so a long render can report
/// progress, be cancelled and let other requests through while it runs (`long_job`). The
/// reader thread is detached: when writing a reply fails, the server returns at once instead
/// of waiting for a read that may never complete.
pub fn serve(&mut self, input: impl BufRead + Send + 'static, mut output: impl Write) -> std::io::Result<()> {
let (tx, inbox) = std::sync::mpsc::channel::<Option<String>>();
// A read error ends the input like EOF and is returned once the queued requests are answered.
let read_error = std::sync::Arc::new(std::sync::Mutex::new(None::<std::io::Error>));
let reader_error = read_error.clone();
std::thread::Builder::new().name("mcp-input".into()).spawn(move || {
for line in input.lines() {
let line = match line {
Ok(line) => line,
Err(e) => {
*reader_error.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(e);
break;
}
};
if tx.send(Some(line)).is_err() {
return;
}
/// Serve until EOF on `input`.
pub fn serve(&mut self, input: impl BufRead, mut output: impl Write) -> std::io::Result<()> {
for line in input.lines() {
if let Some(reply) = self.handle_line(&line?) {
output.write_all(reply.as_bytes())?;
output.write_all(b"\n")?;
output.flush()?;
}
let _ = tx.send(None);
})?;
let served = (|| {
while let Ok(Some(line)) = inbox.recv() {
let long = serde_json::from_str::<Value>(line.trim()).ok().and_then(|m| crate::long_job::long_call(self, &m));
if let Some(call) = long {
if !self.run_long(call, &inbox, &mut output)? {
break;
}
continue;
}
if let Some(reply) = self.handle_line(&line) {
output.write_all(reply.as_bytes())?;
output.write_all(b"\n")?;
output.flush()?;
}
}
match read_error.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take() {
Some(e) => Err(e),
None => Ok(()),
}
})();
match (served, self.checkpoint(true)) {
(r, Ok(())) => r,
(Ok(()), Err(e)) => Err(e),
(Err(transport), Err(save)) => Err(std::io::Error::other(format!("{transport}; {save}"))),
}
Ok(())
}
/// Serve on stdin/stdout (the MCP stdio transport).
pub fn serve_stdio(&mut self) -> std::io::Result<()> {
self.serve(std::io::BufReader::new(std::io::stdin()), std::io::stdout().lock())
self.serve(std::io::stdin().lock(), std::io::stdout().lock())
}
fn request(&mut self, method: &str, params: &Value) -> Result<Value, (i64, String)> {
match method {
"initialize" => {
let asked = params.get("protocolVersion").and_then(Value::as_str).unwrap_or("");
let version = PROTOCOL_VERSIONS.iter().find(|v| **v == asked).copied().unwrap_or(PROTOCOL_VERSIONS.first().copied().unwrap_or("2025-06-18"));
let version = PROTOCOL_VERSIONS.iter().find(|v| **v == asked).copied().unwrap_or(PROTOCOL_VERSIONS[0]);
let mode = if self.backend.is_bridge() { "bridge" } else { "headless" };
let instructions = if self.autosave.is_some() {
format!("{INSTRUCTIONS} Headless auto-save and recovery: {}", self.autosave_info())
} else {
INSTRUCTIONS.to_string()
};
Ok(json!({
"protocolVersion": version,
"capabilities": {"tools": {"listChanged": false}, "resources": {"listChanged": false, "subscribe": false}},
"capabilities": {"tools": {"listChanged": false}},
"serverInfo": {"name": "effectcraft", "title": format!("EffectCraft ({mode})"), "version": env!("CARGO_PKG_VERSION")},
"instructions": instructions,
"_meta": {"effectcraftAutoSave": self.autosave_info()},
"instructions": INSTRUCTIONS,
}))
}
"ping" => Ok(json!({})),
@ -183,62 +108,16 @@ impl McpServer {
}
"tools/call" => {
let name = params.get("name").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing tool `name`".to_string()))?;
let args = params.get("arguments").cloned().unwrap_or(json!({}));
if tools::available(self.backend.is_bridge()).all(|t| t.name != name) {
let hint = if tools::find(name).is_some() { format!(" ({})", crate::backend::NEED_BRIDGE) } else { String::new() };
return Err((INVALID_PARAMS, format!("unknown tool `{name}`{hint}")));
}
if let Some(m) = tools::find(name).and_then(|t| t.unknown_arg(&args)) {
return Err((INVALID_PARAMS, m));
}
let mut result = call_result(self.guarded(name, &args));
// Also checkpoint a partially successful tool that returned an error. The
// project is durable before the client receives its reply (even if it kills
// the process immediately afterwards).
if let Err(e) = self.checkpoint(false) {
let _ = writeln!(std::io::stderr(), "{e}");
if let Some(content) = result.get_mut("content").and_then(Value::as_array_mut) {
content.push(json!({"type": "text", "text": format!("Warning: {e}. Use save_project with a writable path before disconnecting.")}));
}
}
if self.autosave.is_some()
&& let Some(obj) = result.as_object_mut()
{
let meta = obj.entry("_meta").or_insert_with(|| json!({}));
if let Some(meta) = meta.as_object_mut() {
meta.insert("effectcraftAutoSave".into(), self.autosave_info());
}
}
if self.autosave.is_some()
&& name == "get_project"
&& result["isError"] == false
&& let Some(text) = result.get_mut("content").and_then(Value::as_array_mut).and_then(|c| c.first_mut()).and_then(|c| c.get_mut("text"))
&& let Some(summary) = text.as_str().and_then(|t| serde_json::from_str::<Value>(t).ok())
{
let mut summary = summary;
summary["autosave"] = self.autosave_info();
*text = Value::String(summary.to_string());
}
Ok(result)
let args = params.get("arguments").cloned().unwrap_or(json!({}));
Ok(call_result(tools::run(&mut self.backend, name, &args)))
}
"resources/list" => Ok(json!({"resources": [
{"uri": DOCUMENT_URI, "name": "document", "title": "Project", "description": "The project overview and the active composition (same as doc_inspect).", "mimeType": "application/json"},
{"uri": COMMANDS_URI, "name": "commands", "title": "Command catalog", "description": "Every engine command with id, label, menu, shortcut and params (same as command_list).", "mimeType": "application/json"},
]})),
// Advertised as absent, but answer politely for clients that probe anyway.
"resources/list" => Ok(json!({"resources": []})),
"resources/templates/list" => Ok(json!({"resourceTemplates": []})),
"resources/read" => {
let uri = params.get("uri").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing `uri`".to_string()))?;
let tool = match uri {
DOCUMENT_URI => "doc_inspect",
COMMANDS_URI => "command_list",
_ => return Err((RESOURCE_NOT_FOUND, format!("resource not found: {uri}"))),
};
match self.guarded(tool, &json!({})) {
Ok(Reply::Json(v)) => Ok(json!({"contents": [{"uri": uri, "mimeType": "application/json", "text": v.to_string()}]})),
Ok(Reply::Image { .. }) => Err((INTERNAL_ERROR, "unexpected image".into())),
Err(e) => Err((INTERNAL_ERROR, e.to_string())),
}
}
"prompts/list" => Ok(json!({"prompts": []})),
m => Err((METHOD_NOT_FOUND, format!("method not found: {m}"))),
}
@ -248,9 +127,7 @@ impl McpServer {
/// A `tools/call` result: tool failures are reported in-band (`isError`) so the model can react.
pub fn call_result(r: Result<Reply, Error>) -> Value {
match r {
Ok(Reply::Json(v)) => {
json!({"content": [{"type": "text", "text": v.to_string()}], "isError": v.get("failed").and_then(Value::as_u64).is_some_and(|n| n > 0)})
}
Ok(Reply::Json(v)) => json!({"content": [{"type": "text", "text": v.to_string()}], "isError": false}),
Ok(Reply::Image { png, info }) => json!({
"content": [
{"type": "image", "data": base64::encode(&png), "mimeType": "image/png"},
@ -262,47 +139,6 @@ pub fn call_result(r: Result<Reply, Error>) -> Value {
}
}
impl McpServer {
/// Run a tool with panics caught: a panic becomes an error result and the session keeps
/// serving (docs/mcp.md).
pub(crate) fn guarded(&mut self, name: &str, args: &Value) -> Result<Reply, Error> {
let backend = &mut self.backend;
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| tools::run(backend, name, args))).unwrap_or_else(|p| {
let msg = p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into());
Err(Error::Other(format!("internal error: {msg}")))
})
}
}
/// The craft catalog resources (docs/mcp.md).
const DOCUMENT_URI: &str = "effectcraft://document";
const COMMANDS_URI: &str = "effectcraft://commands";
/// Whether a request declares MCP 2026-07-28 or later in its `_meta` (a "modern" client, which
/// negotiates per request instead of through `initialize`).
pub(crate) fn is_modern(params: &Value) -> bool {
params.pointer("/_meta/io.modelcontextprotocol~1protocolVersion").and_then(Value::as_str).is_some_and(|v| v >= "2026-07-28")
}
/// Modern clients reject list/read results without `resultType`, `ttlMs` and `cacheScope`; those
/// fields are added for them. Sessions that negotiated an older revision through `initialize` keep
/// the legacy shape. The tool and resource lists never change while the server runs (cacheable
/// for ten minutes); reads follow the project, so they are never cached.
pub(crate) fn modern_result_fields(method: &str, params: &Value, result: &mut Value) {
let ttl_ms = match method {
"tools/list" | "resources/list" | "resources/templates/list" | "prompts/list" => Some(600_000),
"resources/read" => Some(0),
"tools/call" => None,
_ => return,
};
let Some(obj) = result.as_object_mut().filter(|_| is_modern(params)) else { return };
obj.insert("resultType".into(), json!("complete"));
if let Some(ttl_ms) = ttl_ms {
obj.insert("ttlMs".into(), json!(ttl_ms));
obj.insert("cacheScope".into(), json!("private"));
}
}
fn error(id: Value, code: i64, message: &str) -> Value {
json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message}})
}

View File

@ -35,28 +35,6 @@ fn call_json(s: &mut McpServer, name: &str, args: Value) -> Value {
serde_json::from_str(c[0]["text"].as_str().unwrap()).unwrap()
}
#[test]
fn property_group_error_points_to_layer_inspection() {
let mut s = server();
call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "Main"}}));
let l = call_json(&mut s, "execute_command", json!({"command": "layer.newText", "params": {"text": "Hi"}}))["layer"].clone();
call_json(&mut s, "execute_command", json!({"command": "comp.renderer", "params": {"renderer": "advanced3d"}}));
for tool in ["get_property", "set_property", "add_keyframe"] {
let mut args = json!({"layer": l, "path": "geometryOptions", "time": 0});
if tool != "get_property" {
args["value"] = json!(0);
}
let (content, err) = call(&mut s, tool, args);
assert!(err, "{tool}: {content:?}");
let text = content[0]["text"].as_str().unwrap();
assert!(text.contains("is a property group"), "{tool}: {text}");
assert!(text.contains("get_layer"), "{tool}: {text}");
}
let tree = call_json(&mut s, "get_layer", json!({"layer": l, "flat": true}));
assert!(tree["properties"].as_array().unwrap().iter().any(|p| p["path"] == "geometryOptions/extrusionDepth"));
assert!(call_json(&mut s, "get_property", json!({"layer": l, "path": "geometryOptions/extrusionDepth"}))["value"].is_number());
}
#[test]
fn initialize_and_list_tools() {
let mut s = server();
@ -157,21 +135,6 @@ fn headless_workflow() {
let mid = call_json(&mut s, "get_property", json!({"layer": l, "path": "transform/position", "time": 1.0}));
assert!((mid["value"][0].as_f64().unwrap() - 160.0).abs() < 1.0, "{mid}");
// Comp-time keys on an offset layer (#257): the key lands where the Timeline shows it.
call_json(&mut s, "execute_command", json!({"command": "layer.timing", "params": {"layer": l, "start": 1.0}}));
let p = call_json(
&mut s,
"add_keyframe",
json!({"layer": l, "path": "transform/scale", "keys": [{"time": 1.5, "value": [50, 50]}, {"time": 2.5, "value": [100, 100]}], "timeBase": "comp", "interpolation": "hold"}),
);
let times: Vec<f64> = p["keys"].as_array().unwrap().iter().map(|k| k["time"].as_f64().unwrap()).collect();
// Comp times snap to the comp's frames (29.97 fps) before becoming layer time.
assert!((times[0] - 0.5).abs() < 0.02 && (times[1] - 1.5).abs() < 0.02, "layer-time keys: {times:?}");
assert_eq!(p["keys"][0]["out"], "Hold", "the comp-time keys were selected for the interpolation");
call_json(&mut s, "set_property", json!({"layer": l, "path": "transform/scale", "value": [75, 75], "time": 3.0, "timeBase": "comp"}));
let p = call_json(&mut s, "get_property", json!({"layer": l, "path": "transform/scale"}));
assert!(p["keys"].as_array().unwrap().iter().any(|k| (k["time"].as_f64().unwrap() - 2.0).abs() < 0.02), "{p}");
// Expression via set_property.
let p = call_json(&mut s, "set_property", json!({"layer": l, "path": "transform/rotation", "expression": "time * 90"}));
assert_eq!(p["expression"], "time * 90");
@ -214,28 +177,6 @@ fn headless_workflow() {
let _ = std::fs::remove_dir_all(dir);
}
/// An expression with a syntax error is kept but disabled; set_property and get_property report
/// its error, and `evaluated` is the static value that renders (#163).
#[test]
fn expression_syntax_errors_reach_the_reply() {
let mut s = McpServer::new(Backend::headless(effectcraft_host::session()));
call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "A", "width": 64, "height": 64}}));
call_json(&mut s, "execute_command", json!({"command": "layer.newSolid", "params": {"name": "S", "color": "#ff0000", "width": 32, "height": 8}}));
let at = |expr: &str| json!({"layer": "S", "path": "transform/rotation", "expression": expr});
call_json(&mut s, "set_property", json!({"layer": "S", "path": "transform/rotation", "value": 30}));
let set = call_json(&mut s, "set_property", at("thisIsBroken("));
let got = call_json(&mut s, "get_property", json!({"layer": "S", "path": "transform/rotation"}));
for r in [&set, &got] {
assert_eq!((&r["expression"], &r["evaluated"]), (&json!("thisIsBroken("), &json!(30.0)), "{r}");
assert!(r["expressionError"].as_str().unwrap().contains("SyntaxError"), "{r}");
}
// A runtime error and a valid expression report as before.
let r = call_json(&mut s, "set_property", at("nope*2"));
assert!(r["expressionError"].as_str().unwrap().contains("nope is not defined"), "{r}");
let r = call_json(&mut s, "set_property", at("60"));
assert_eq!((&r["evaluated"], r.get("expressionError")), (&json!(60.0), None), "{r}");
}
#[test]
fn stdio_loop() {
let mut s = server();
@ -248,200 +189,13 @@ fn stdio_loop() {
.map(|v| v.to_string() + "\n")
.collect::<String>();
let mut out = Vec::new();
s.serve(std::io::Cursor::new(input), &mut out).unwrap();
s.serve(input.as_bytes(), &mut out).unwrap();
let lines: Vec<Value> = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect();
assert_eq!(lines.len(), 2);
assert_eq!(lines[1]["id"], 2);
assert_eq!(lines[1]["result"]["isError"], false);
}
/// Regression for #258: a dirty headless project must survive an MCP disconnect.
#[test]
fn headless_autosave_on_eof_preserves_unsaved_project() {
let dir = std::env::temp_dir().join(format!("ec-mcp-autosave-eof-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut s = server().with_autosave(&dir).unwrap();
let input = [
json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "execute_command", "arguments": {"command": "comp.new", "params": {"name": "Keep my work", "width": 320, "height": 180}}}}),
json!({"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "execute_command", "arguments": {"command": "layer.newText", "params": {"text": "Unsaved title"}}}}),
].iter().map(|v| v.to_string() + "\n").collect::<String>();
let mut output = Vec::new();
s.serve(std::io::Cursor::new(input), &mut output).unwrap();
let path = s.backend().session().unwrap().autosave.last_path.clone();
assert!(path.is_some(), "disconnect discarded the dirty project without an auto-save");
let path = path.unwrap();
let mut fresh = server();
call_json(&mut fresh, "open_project", json!({"path": path}));
let comp = call_json(&mut fresh, "get_comp", json!({"comp": "Keep my work"}));
assert_eq!(comp["layers"].as_array().unwrap().len(), 1);
assert!(s.backend().session().unwrap().is_dirty(), "an auto-save must not mark the original project saved");
std::fs::remove_dir_all(dir).unwrap();
}
fn autosave_test_dir(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("ec-mcp-autosave-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn autosave_status(s: &mut McpServer) -> Value {
rpc(s, 1, "initialize", json!({}))["_meta"]["effectcraftAutoSave"].clone()
}
#[test]
fn autosave_is_durable_before_reply_and_queries_do_not_rotate() {
let dir = autosave_test_dir("durable");
let mut s = server().with_autosave(&dir).unwrap();
call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "Durable"}}));
let first = autosave_status(&mut s);
let path = first["latestPath"].as_str().unwrap();
assert!(std::fs::read_to_string(path).unwrap().contains("Durable"));
let summary = call_json(&mut s, "get_project", json!({}));
assert_eq!(summary["autosave"]["latestPath"], path);
assert_eq!(summary["path"], Value::Null);
assert_eq!(summary["dirty"], true);
assert_eq!(autosave_status(&mut s)["latestPath"], path);
assert!(s.backend().session().unwrap().config.is_none());
drop(s); // Abrupt process loss has no EOF cleanup; the checkpoint already exists.
let mut restarted = server().with_autosave(&dir).unwrap();
let init = rpc(&mut restarted, 1, "initialize", json!({}));
// The instructions carry the auto-save state as JSON, where a Windows path's backslashes
// are escaped.
assert!(init["instructions"].as_str().unwrap().contains(&Value::from(path).to_string()));
assert_eq!(init["_meta"]["effectcraftAutoSave"]["previousSessions"][0]["autosave"], path);
call_json(&mut restarted, "open_project", json!({"path": path}));
assert_eq!(call_json(&mut restarted, "get_project", json!({}))["items"][0]["name"], "Durable");
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn autosave_errors_preserve_tool_success_retry_and_fail_shutdown() {
let dir = autosave_test_dir("write-error");
let mut s = server().with_autosave(&dir).unwrap();
let folder = std::path::PathBuf::from(autosave_status(&mut s)["folder"].as_str().unwrap());
std::fs::remove_dir(&folder).unwrap();
std::fs::write(&folder, "blocked by file").unwrap();
let (content, failed) = call(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "Still in memory"}}));
assert!(!failed, "the edit succeeded even though its checkpoint failed");
assert!(content.iter().any(|c| c["text"].as_str().unwrap_or("").contains("Warning:")));
assert!(s.backend().session().unwrap().is_dirty());
assert!(autosave_status(&mut s)["error"].as_str().unwrap().contains("failed"));
let error = s.serve(&b""[..], Vec::new()).unwrap_err();
assert_eq!(error.kind(), std::io::ErrorKind::Other);
assert!(error.to_string().contains("MCP auto-save"));
std::fs::remove_file(&folder).unwrap();
// Even a query retries the outstanding dirty revision.
let summary = call_json(&mut s, "get_project", json!({}));
assert_eq!(summary["autosave"]["error"], Value::Null);
let path = summary["autosave"]["latestPath"].as_str().unwrap();
assert!(std::fs::read_to_string(path).unwrap().contains("Still in memory"));
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn autosave_keeps_sessions_and_desktop_recovery_separate() {
let dir = autosave_test_dir("isolated");
let desktop_save = dir.join(effectcraft_engine::autosave::AUTOSAVE_FOLDER).join("Untitled Project auto-save 1.ecproj");
std::fs::create_dir_all(desktop_save.parent().unwrap()).unwrap();
std::fs::write(&desktop_save, "desktop backup").unwrap();
std::fs::write(dir.join("session.lock"), "desktop sentinel").unwrap();
let mut a = server().with_autosave(&dir).unwrap();
let mut b = server().with_autosave(&dir).unwrap();
assert_ne!(autosave_status(&mut a)["folder"], autosave_status(&mut b)["folder"]);
for index in 0..7 {
for (s, name) in [(&mut a, "A"), (&mut b, "B")] {
call_json(s, "execute_command", json!({"command": "comp.new", "params": {"name": format!("{name}{index}")}}));
}
}
for (s, mine, other) in [(&mut a, "A6", "B6"), (&mut b, "B6", "A6")] {
let status = autosave_status(s);
let json = std::fs::read_to_string(status["latestPath"].as_str().unwrap()).unwrap();
assert!(json.contains(mine));
assert!(!json.contains(other));
let folder = std::path::Path::new(status["folder"].as_str().unwrap());
assert_eq!(effectcraft_engine::autosave::existing(folder, "Untitled Project").len(), 5);
}
assert_eq!(std::fs::read_to_string(desktop_save).unwrap(), "desktop backup");
assert_eq!(std::fs::read_to_string(dir.join("session.lock")).unwrap(), "desktop sentinel");
assert!(!dir.join("prefs.json").exists());
assert!(!dir.join("shortcuts.json").exists());
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn autosave_marks_saved_or_undone_work_clean_without_losing_its_path() {
let dir = autosave_test_dir("clean");
let mut s = server().with_autosave(&dir).unwrap();
call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "Undo me"}}));
let status = autosave_status(&mut s);
let folder = std::path::Path::new(status["folder"].as_str().unwrap());
call_json(&mut s, "undo", json!({}));
let manifest: Value = serde_json::from_str(&std::fs::read_to_string(folder.join("session.json")).unwrap()).unwrap();
assert_eq!(manifest["dirty"], false);
call_json(&mut s, "redo", json!({}));
let path = autosave_status(&mut s)["latestPath"].clone();
call_json(&mut s, "save_project", json!({"path": dir.join("Saved.ecproj")}));
assert_eq!(autosave_status(&mut s)["latestPath"], path, "save resets the engine bookkeeping but the server remembers the recovery file");
let manifest: Value = serde_json::from_str(&std::fs::read_to_string(folder.join("session.json")).unwrap()).unwrap();
assert_eq!(manifest["dirty"], false);
let mut next = server().with_autosave(&dir).unwrap();
assert!(autosave_status(&mut next)["previousSessions"].as_array().unwrap().is_empty());
std::fs::remove_dir_all(dir).unwrap();
}
/// A reply that cannot be written ends `serve` with the write error, even while stdin stays open
/// (the client is gone but never closed its end): the reader thread must not be waited for.
#[test]
fn broken_output_returns_while_stdin_stays_open() {
struct Broken;
impl Write for Broken {
fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
Err(std::io::ErrorKind::BrokenPipe.into())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
let (in_r, mut in_w) = std::io::pipe().unwrap();
writeln!(in_w, "{}", json!({"jsonrpc": "2.0", "id": 1, "method": "ping"})).unwrap();
let (done_tx, done) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = done_tx.send(server().serve(BufReader::new(in_r), Broken));
});
let result = done.recv_timeout(std::time::Duration::from_secs(10)).expect("serve must return while stdin is still open");
assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::BrokenPipe);
drop(in_w);
}
#[test]
fn autosave_flushes_on_read_errors_and_broken_output() {
struct Broken;
impl Write for Broken {
fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
Err(std::io::ErrorKind::BrokenPipe.into())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
let dir = autosave_test_dir("transports");
for invalid_input in [true, false] {
let mut s = server().with_autosave(&dir).unwrap();
// Simulate an edit whose client went away before it received a tool response.
s.backend().exec("comp.new", json!({"name": "Transport failure"})).unwrap();
let error = if invalid_input {
s.serve(&b"\xff\n"[..], Vec::new()).unwrap_err()
} else {
s.serve(&b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\"}\n"[..], Broken).unwrap_err()
};
assert_eq!(error.kind(), if invalid_input { std::io::ErrorKind::InvalidData } else { std::io::ErrorKind::BrokenPipe });
let status = autosave_status(&mut s);
assert!(std::fs::read_to_string(status["latestPath"].as_str().unwrap()).unwrap().contains("Transport failure"));
}
std::fs::remove_dir_all(dir).unwrap();
}
/// A fake desktop control channel: answers `engine.execute` from a real session, and canned
/// `render.frame` / `ui.screenshot` replies.
fn fake_app() -> u16 {
@ -639,622 +393,3 @@ fn high_level_tools_add_effect_and_report_state() {
let (c, err) = call(&mut s, "add_effect", json!({"layer": l, "effect": "No Such Effect"}));
assert!(err && c[0]["text"].as_str().unwrap().contains("unknown effect"), "{c:?}");
}
/// add_effect is one undo step, and a call that fails leaves the layer and the history as they
/// were (#175).
#[test]
fn add_effect_is_one_undo_step_and_all_or_nothing() {
let mut s = server();
call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "A", "width": 64, "height": 64}}));
call_json(&mut s, "execute_command", json!({"command": "layer.newSolid", "params": {"name": "S", "color": "#ff0000"}}));
let undo = |s: &mut McpServer| call_json(s, "get_project", json!({}))["undo"].as_array().unwrap().clone();
let effects = |s: &mut McpServer| {
let l = call_json(s, "get_layer", json!({"layer": "S", "flat": true}));
l["properties"].as_array().unwrap().iter().filter(|p| p["path"].as_str().unwrap().starts_with("effects/")).count()
};
let before = undo(&mut s);
let (c, err) = call(&mut s, "add_effect", json!({"layer": "S", "effect": "Gaussian Blur", "values": {"blurriness": 8, "repeatEdgePixels": true}}));
assert!(err && c[0]["text"].as_str().unwrap().contains("effects/#1/repeatEdgePixels"), "{c:?}");
assert_eq!((undo(&mut s), effects(&mut s)), (before.clone(), 0), "nothing applied");
// The corrected retry lands at #1, in one undo step named like effect.apply's.
let r = call_json(&mut s, "add_effect", json!({"layer": "S", "effect": "Gaussian Blur", "values": {"blurriness": 8, "repeatEdge": true}}));
assert_eq!((&r["path"], &r["set"]), (&json!("effects/#1"), &json!(["effects/#1/blurriness", "effects/#1/repeatEdge"])), "{r}");
let after = undo(&mut s);
assert_eq!(after.len(), before.len() + 1, "{after:?}");
assert_eq!(after.last(), Some(&json!("Apply Gaussian Blur")));
call_json(&mut s, "undo", json!({}));
assert_eq!((undo(&mut s), effects(&mut s)), (before, 0), "one undo removes the effect and its values");
}
#[test]
fn list_fonts_lists_families_with_styles() {
let mut s = server();
let all = call_json(&mut s, "list_fonts", json!({}));
let fams = all["families"].as_array().unwrap();
assert!(fams.iter().any(|f| f["family"] == "Inter" && f["origin"] == "bundled"), "{all}");
// The query filters by substring: the bundled JetBrains Mono, and any installed family whose
// name contains it (JetBrainsMono Nerd Font…) (#149).
let one = call_json(&mut s, "list_fonts", json!({"query": "jetbrains"}));
let matched = one["families"].as_array().unwrap();
assert_eq!(one["count"].as_u64(), Some(matched.len() as u64), "{one}");
assert!(matched.iter().all(|f| f["family"].as_str().unwrap().to_lowercase().contains("jetbrains")), "{one}");
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
/// licence, chosen, refused when unknown.
#[test]
fn trained_models_over_mcp() {
let mut s = server();
for (prefix, id) in [("roto", "mobilesam"), ("face", "mediapipe-face")] {
let list = call_json(&mut s, "execute_command", json!({"command": format!("{prefix}.models"), "params": {}}));
let models = list["models"].as_array().unwrap();
assert!(models.iter().any(|m| m["id"] == id && m["licence"] == "Apache-2.0" && m["authors"].is_string() && m["installed"] == false), "{list}");
let r = call_json(&mut s, "execute_command", json!({"command": format!("{prefix}.model.select"), "params": {"id": id}}));
assert_eq!(r["model"], id);
let (c, err) = call(&mut s, "execute_command", json!({"command": format!("{prefix}.model.select"), "params": {"id": "nope"}}));
assert!(err && c[0]["text"].as_str().unwrap().contains("unknown model"), "{c:?}");
}
}
/// docs/agents.md's Tools table lists every tool, and an entry that lists arguments lists every
/// argument the tool's input schema accepts (#137).
#[test]
fn agents_doc_tools_table_is_current() {
// (Checkouts may have CRLF line endings.)
let doc = include_str!("../../../docs/agents.md").replace("\r\n", "\n");
let table: String = doc.split("### Tools").nth(1).and_then(|t| t.split("\n\n").nth(1)).unwrap_or_default().to_string();
assert!(table.starts_with("| Tool |"), "no Tools table");
let mut missing = vec![];
for t in crate::tools::catalogue() {
// `name` (no arguments) or `name {a, b?, …}`.
let Some(at) = table.find(&format!("`{}", t.name)).filter(|i| {
let rest = &table[i + 1 + t.name.len()..];
rest.starts_with('`') || rest.starts_with(" {")
}) else {
missing.push(format!("{}: no entry", t.name));
continue;
};
let rest = &table[at + 1 + t.name.len()..];
if !rest.starts_with(" {") {
continue;
}
let args = rest.split("}`").next().unwrap_or_default();
let schema = t.input_schema();
for prop in schema["properties"].as_object().into_iter().flat_map(|p| p.keys()) {
if !args.split(|c: char| !(c.is_alphanumeric() || c == '_')).any(|w| w == prop) {
missing.push(format!("{}: argument `{prop}`", t.name));
}
}
}
assert!(missing.is_empty(), "docs/agents.md's Tools table is missing: {missing:#?}");
}
#[test]
fn autosave_checkpoints_batch_and_partially_failed_tools() {
let dir = autosave_test_dir("partial");
let mut s = server().with_autosave(&dir).unwrap();
let msgs = json!([
{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute_command","arguments":{"command":"comp.new","params":{"name":"Batch"}}}},
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"execute_command","arguments":{"command":"layer.newText","params":{"text":"Batch title"}}}}
]);
let reply = s.handle(&msgs).unwrap();
assert_eq!(reply.as_array().unwrap().len(), 2);
let (_, failed) = call(&mut s, "add_keyframe", json!({"layer":"#1","path":"transform/opacity","keys":[{"time":0,"value":37}],"interpolation":"invalid"}));
assert!(failed, "the key was added, then the interpolation failed");
let status = autosave_status(&mut s);
let mut recovered = server();
call_json(&mut recovered, "open_project", json!({"path":status["latestPath"]}));
let prop = call_json(&mut recovered, "get_property", json!({"layer":"#1","path":"transform/opacity"}));
assert_eq!(prop["keys"].as_array().unwrap().len(), 1);
assert_eq!(prop["keys"][0]["value"], 37.0);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn autosave_isolation_also_applies_to_engine_autosaves_and_preference_changes() {
let dir = autosave_test_dir("engine-save");
let desktop = dir.join("DesktopCustom");
std::fs::create_dir_all(&desktop).unwrap();
std::fs::write(desktop.join("Untitled Project auto-save 1.ecproj"), "desktop backup").unwrap();
let mut session = Session::default();
session.prefs.auto_save.location = "custom".into();
session.prefs.auto_save.folder = desktop.to_string_lossy().into_owned();
let mut s = McpServer::new(Backend::headless(session)).with_autosave(&dir).unwrap();
call_json(&mut s, "execute_command", json!({"command":"comp.new","params":{"name":"Isolated"}}));
let status = autosave_status(&mut s);
let explicit = call_json(&mut s, "execute_command", json!({"command":"file.autoSave"}));
assert!(std::path::Path::new(explicit["path"].as_str().unwrap()).starts_with(status["folder"].as_str().unwrap()));
assert_eq!(autosave_status(&mut s)["latestPath"], explicit["path"]);
// The host override survives preference edits even within a single batch tool call.
call_json(
&mut s,
"batch",
json!({"steps":[
{"command":"prefs.set","params":{"values":{"autoSave.location":"custom","autoSave.folder":desktop.to_string_lossy()}}},
{"command":"file.autoSave","params":{}}
]}),
);
call_json(&mut s, "save_project", json!({"path":dir.join("Named.ecproj")}));
call_json(&mut s, "execute_command", json!({"command":"layer.newText","params":{"text":"Latest isolated edit"}}));
let current = autosave_status(&mut s);
assert!(std::path::Path::new(current["latestPath"].as_str().unwrap()).starts_with(status["folder"].as_str().unwrap()));
assert!(std::fs::read_to_string(current["latestPath"].as_str().unwrap()).unwrap().contains("Latest isolated edit"));
let recovery = call_json(&mut s, "execute_command", json!({"command":"file.recoveryInfo"}));
assert_eq!(recovery["folder"], status["folder"]);
assert_eq!(std::fs::read_to_string(desktop.join("Untitled Project auto-save 1.ecproj")).unwrap(), "desktop backup");
assert_eq!(std::fs::read_dir(&desktop).unwrap().count(), 2, "only the original desktop file and the MCP subfolder");
std::fs::remove_dir_all(dir).unwrap();
}
#[cfg(unix)]
#[test]
fn autosave_non_utf8_path_returns_an_error_instead_of_panicking() {
use std::os::unix::ffi::OsStringExt;
let root = std::path::PathBuf::from(std::ffi::OsString::from_vec(b"/tmp/ec-mcp-\xff".to_vec()));
let result = server().with_autosave(&root);
assert!(matches!(result, Err(crate::Error::Other(message)) if message.contains("UTF-8")));
}
#[test]
fn core_tools_annotations_and_strict_keys() {
let mut s = server();
let tools = rpc(&mut s, 1, "tools/list", json!({}));
let tools = tools["tools"].as_array().unwrap();
for name in ["command_list", "command_run", "command_batch", "doc_inspect", "render_preview"] {
assert!(tools.iter().any(|t| t["name"] == name), "missing {name}");
}
for t in tools {
assert!(t["title"].as_str().is_some_and(|s| !s.is_empty()));
for hint in ["readOnlyHint", "destructiveHint", "idempotentHint", "openWorldHint"] {
assert!(t["annotations"][hint].is_boolean(), "{t}");
}
let msg = json!({"jsonrpc":"2.0", "id":2, "method":"tools/call", "params":{"name":t["name"], "arguments":{"typo_key":1}}});
let reply = s.handle(&msg).unwrap();
assert_eq!(reply["error"]["code"], -32602, "{reply}");
assert!(reply["error"]["message"].as_str().unwrap().contains("typo_key"));
}
let r = call_json(&mut s, "command_run", json!({"id":"comp.new", "params":{"width":32,"height":32}}));
assert!(r.is_object());
let (content, err) = call(&mut s, "command_batch", json!({"steps":[{"id":"no.such.command"},{"id":"layer.newNull"}]}));
assert!(err);
let result: Value = serde_json::from_str(content[0]["text"].as_str().unwrap()).unwrap();
assert_eq!(result["completed"], 0);
assert_eq!(result["failed"], 1);
assert_eq!(result["results"].as_array().unwrap().len(), 1);
let (content, err) = call(&mut s, "command_batch", json!({"stop_on_error":false,"steps":[{"id":"no.such.command"},{"id":"layer.newNull"}]}));
assert!(err);
let result: Value = serde_json::from_str(content[0]["text"].as_str().unwrap()).unwrap();
assert_eq!(result["completed"], 1);
assert_eq!(result["failed"], 1);
assert!(call_json(&mut s, "doc_inspect", json!({}))["activeComp"].is_object());
assert!(!call(&mut s, "render_preview", json!({"max_side":16})).1);
}
#[test]
fn resources_match_tools_and_recover_after_parse_error() {
let mut s = server();
let bad: Value = serde_json::from_str(&s.handle_line("{broken").unwrap()).unwrap();
assert_eq!(bad["error"]["code"], -32700);
assert!(bad["id"].is_null());
assert_eq!(rpc(&mut s, 1, "ping", json!({})), json!({}));
let list = rpc(&mut s, 2, "resources/list", json!({}));
for (uri, tool) in [("effectcraft://document", "doc_inspect"), ("effectcraft://commands", "command_list")] {
assert!(list["resources"].as_array().unwrap().iter().any(|r| r["uri"] == uri));
let r = rpc(&mut s, 3, "resources/read", json!({"uri":uri}));
assert_eq!(r["contents"][0]["mimeType"], "application/json");
let value: Value = serde_json::from_str(r["contents"][0]["text"].as_str().unwrap()).unwrap();
assert_eq!(value, call_json(&mut s, tool, json!({})));
}
}
/// Rendering is a direct tool call, outside Session::execute's existing panic guard.
#[test]
fn render_panic_is_an_error_and_session_remains_usable() {
use effectcraft_engine::{
color::Label,
project::{Footage, FootageKind, ItemId, ItemKind},
raster::Image,
render::FootageSource,
time::Tick,
};
use std::sync::{Arc, Mutex};
struct BrokenFootage(Mutex<bool>);
impl FootageSource for BrokenFootage {
fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<Image>> {
let mut first = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if *first {
*first = false;
panic!("synthetic decoder failure");
}
Some(Arc::new(Image::new(16, 16)))
}
}
let mut session = Session { footage: Arc::new(BrokenFootage(Mutex::new(true))), ..Default::default() };
session.execute("comp.new", json!({"width":16,"height":16})).unwrap();
let item = Arc::make_mut(&mut session.project).add_item(
"synthetic",
Label::Aqua,
None,
ItemKind::Footage(Footage { path: "synthetic.png".into(), kind: FootageKind::Still, width: 16, height: 16, has_video: true, ..Default::default() }),
);
session.execute("layer.addItem", json!({"item":item.0})).unwrap();
let mut s = McpServer::new(Backend::headless(session));
let (content, err) = call(&mut s, "render_frame", json!({}));
assert!(err);
assert!(content[0]["text"].as_str().unwrap().contains("synthetic decoder failure"));
assert_eq!(rpc(&mut s, 2, "ping", json!({})), json!({}));
assert!(!call(&mut s, "render_frame", json!({})).1);
}
/// MCP 2026-07-28 ("modern") clients declare their revision per request in `_meta` and reject
/// list/read results without `resultType`, `ttlMs` and `cacheScope`; legacy sessions (revision
/// negotiated through `initialize`) keep the old result shape.
#[test]
fn modern_clients_get_result_type() {
let mut s = server();
let meta = json!({
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {"name": "t", "version": "1"},
"io.modelcontextprotocol/clientCapabilities": {},
});
for (id, method, params) in [
(1, "tools/list", json!({"_meta": meta})),
(2, "resources/list", json!({"_meta": meta})),
(3, "resources/templates/list", json!({"_meta": meta})),
(4, "resources/read", json!({"_meta": meta, "uri": "effectcraft://document"})),
(5, "tools/call", json!({"_meta": meta, "name": "command_list", "arguments": {}})),
] {
let r = rpc(&mut s, id, method, params);
assert_eq!(r["resultType"], "complete", "{method}: {r}");
if method != "tools/call" {
assert!(r["ttlMs"].is_u64(), "{method}: {r}");
assert!(matches!(r["cacheScope"].as_str(), Some("public" | "private")), "{method}: {r}");
}
}
let mut s = server();
rpc(&mut s, 1, "initialize", json!({"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "t", "version": "1"}}));
for (id, method, params) in
[(2, "tools/list", json!({})), (3, "resources/list", json!({})), (4, "resources/read", json!({"uri": "effectcraft://document"}))]
{
let r = rpc(&mut s, id, method, params);
let res = r.as_object().unwrap();
assert!(!res.contains_key("resultType") && !res.contains_key("ttlMs") && !res.contains_key("cacheScope"), "{method}: {r}");
}
}
#[test]
fn render_job_state_recovers_a_poisoned_lock() {
let shared = std::sync::Arc::new(effectcraft_engine::render_queue::JobShared::default());
let worker = shared.clone();
let _ = std::thread::spawn(move || {
let mut state = worker.state.lock().unwrap();
state.done = 3;
panic!("synthetic worker panic while holding the progress lock");
})
.join();
assert_eq!(shared.snapshot().done, 3);
}
/// A render that takes ~2 ms per frame and writes its file first (so a cancel leaves a partial
/// one to clean up).
struct SlowExporter;
impl effectcraft_engine::Exporter for SlowExporter {
fn formats(&self) -> Vec<effectcraft_engine::project::render_queue::OutputFormat> {
effectcraft_engine::project::render_queue::OutputFormat::ALL.to_vec()
}
fn export(&self, job: &effectcraft_engine::ExportJob, progress: &mut dyn FnMut(u64, u64) -> bool) -> Result<effectcraft_engine::ExportResult, String> {
std::fs::write(job.path, b"partial").map_err(|e| e.to_string())?;
let n = 2000;
for k in 0..=n {
if !progress(k, n) {
std::fs::remove_file(job.path).map_err(|e| e.to_string())?;
return Err(effectcraft_engine::render_queue::CANCELLED.into());
}
std::thread::sleep(std::time::Duration::from_millis(2));
}
Ok(effectcraft_engine::ExportResult { path: job.path.to_string(), frames: n, ..Default::default() })
}
}
/// docs/mcp.md "Requests during a render": while a blocking render runs, every way of starting a
/// second render is refused (blocking, `wait: false`, inside a batch), and edits are applied to
/// the project without changing the running job, which renders the snapshot it started with.
#[test]
fn second_render_is_refused_and_edits_apply_during_a_render() {
let dir = std::env::temp_dir().join(format!("effectcraft-mcp-busy-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let (in_r, mut in_w) = std::io::pipe().unwrap();
let (out_r, out_w) = std::io::pipe().unwrap();
let server = std::thread::spawn(move || {
let session = Session { exporter: Some(std::sync::Arc::new(SlowExporter)), ..Default::default() };
McpServer::new(Backend::headless(session)).serve(BufReader::new(in_r), out_w).unwrap();
});
let mut lines = BufReader::new(out_r).lines();
let mut next = || -> Value { serde_json::from_str(&lines.next().unwrap().unwrap()).unwrap() };
let mut send = |v: Value| writeln!(in_w, "{v}").unwrap();
let call =
|id: u64, name: &str, arguments: Value| json!({"jsonrpc": "2.0", "id": id, "method": "tools/call", "params": {"name": name, "arguments": arguments}});
send(json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18"}}));
next();
send(call(2, "command_run", json!({"id": "comp.new", "params": {"width": 64, "height": 64, "duration": 1}})));
assert_eq!(next()["result"]["isError"], false);
let a = dir.join("a.mp4").to_string_lossy().to_string();
send(call(3, "command_run", json!({"id": "renderQueue.add", "params": {"output": a}})));
assert_eq!(next()["result"]["isError"], false);
send(call(4, "command_run", json!({"id": "renderQueue.render"})));
send(call(5, "command_run", json!({"id": "renderQueue.render", "params": {"wait": false}})));
send(call(6, "command_run", json!({"id": "renderQueue.render"})));
send(call(7, "command_batch", json!({"steps": [{"id": "renderQueue.render", "params": {"wait": false}}]})));
send(call(8, "command_run", json!({"id": "comp.new", "params": {"name": "Added while rendering", "width": 32, "height": 32, "duration": 1}})));
let mut replies = std::collections::HashMap::new();
while !replies.contains_key(&4) {
let m = next();
replies.insert(m["id"].as_u64().unwrap(), m);
}
for id in [5, 6, 7] {
let m = &replies[&id];
assert_eq!(m["result"]["isError"], true, "a second render is refused: {m}");
let text = m["result"]["content"][0]["text"].as_str().unwrap();
assert!(text.contains("already in progress") || text.contains("is rendering"), "{m}");
}
assert_eq!(replies[&8]["result"]["isError"], false, "edits are applied during a render");
let done = &replies[&4];
assert_eq!(done["result"]["isError"], false, "{done}");
let v: Value = serde_json::from_str(done["result"]["content"][0]["text"].as_str().unwrap()).unwrap();
assert_eq!(v["items"].as_array().unwrap().len(), 1, "{v}");
assert_eq!(v["items"][0]["statusLabel"], "Done", "{v}");
send(call(9, "doc_inspect", json!({})));
let doc = next();
assert!(doc.to_string().contains("Added while rendering"), "{doc}");
drop(in_w);
server.join().unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
/// docs/mcp.md "Progress and cancellation" over the stdio loop: a blocking `renderQueue.render`
/// reports `notifications/progress` for its token, other requests are answered while it renders,
/// and `notifications/cancelled` stops it, deletes the partial file and sends no response.
#[test]
fn render_progress_and_cancel() {
let dir = std::env::temp_dir().join(format!("effectcraft-mcp-progress-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let (in_r, mut in_w) = std::io::pipe().unwrap();
let (out_r, out_w) = std::io::pipe().unwrap();
let server = std::thread::spawn(move || {
let session = Session { exporter: Some(std::sync::Arc::new(SlowExporter)), ..Default::default() };
McpServer::new(Backend::headless(session)).serve(BufReader::new(in_r), out_w).unwrap();
});
let mut lines = BufReader::new(out_r).lines();
let mut next = || -> Value { serde_json::from_str(&lines.next().unwrap().unwrap()).unwrap() };
let mut send = |v: Value| writeln!(in_w, "{v}").unwrap();
let run = |id: u64, cmd: &str, params: Value, token: Option<Value>| {
let mut p = json!({"name": "command_run", "arguments": {"id": cmd, "params": params}});
if let Some(t) = token {
p["_meta"] = json!({"progressToken": t, "io.modelcontextprotocol/protocolVersion":"2026-07-28"});
}
json!({"jsonrpc": "2.0", "id": id, "method": "tools/call", "params": p})
};
send(json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18"}}));
next();
send(run(2, "comp.new", json!({"width": 64, "height": 64, "duration": 1}), None));
assert_eq!(next()["result"]["isError"], false);
let a = dir.join("a.mp4").to_string_lossy().to_string();
send(run(3, "renderQueue.add", json!({"output": a}), None));
assert_eq!(next()["result"]["isError"], false);
// progress for the token, strictly increasing; doc_inspect answered while rendering
send(run(4, "renderQueue.render", json!({}), Some(json!("tok"))));
let (mut last, mut notes, mut asked, mut answered) = (-1.0, 0, false, false);
let done = loop {
let m = next();
if m["method"] == "notifications/progress" {
assert_eq!(m["params"]["progressToken"], "tok", "{m}");
let p = m["params"]["progress"].as_f64().unwrap();
assert!(p > last && p <= m["params"]["total"].as_f64().unwrap(), "{m}");
(last, notes) = (p, notes + 1);
if !asked && p > 0.0 {
asked = true;
send(json!({"jsonrpc": "2.0", "id": 5, "method": "tools/call", "params": {"name": "doc_inspect", "arguments": {}}}));
}
} else if m["id"] == 5 {
answered = true;
assert_eq!(m["result"]["isError"], false, "{m}");
} else {
assert_eq!(m["id"], 4, "{m}");
break m;
}
};
assert_eq!(done["result"]["isError"], false, "{done}");
assert_eq!(done["result"]["resultType"], "complete");
assert_eq!(last, 1.0, "final progress reaches the total");
let v: Value = serde_json::from_str(done["result"]["content"][0]["text"].as_str().unwrap()).unwrap();
assert_eq!(v["items"][0]["statusLabel"], "Done", "{v}");
assert!(notes >= 2 && answered, "{notes} notifications; doc_inspect answered during the render: {answered}");
// cancel after the first notification: no response, the partial file is gone
let b = dir.join("b.mp4");
send(run(6, "renderQueue.add", json!({"output": b.to_string_lossy()}), None));
assert_eq!(next()["result"]["isError"], false);
send(run(7, "renderQueue.render", json!({}), Some(json!(7))));
loop {
let m = next();
assert_ne!(m["id"], 7, "{m}");
if m["method"] == "notifications/progress" && m["params"]["progress"].as_f64().unwrap() > 0.0 {
break;
}
}
assert!(b.exists(), "the render started writing");
send(json!({"jsonrpc": "2.0", "method": "notifications/cancelled", "params": {"requestId": 7, "reason": "test"}}));
send(json!({"jsonrpc": "2.0", "id": 8, "method": "ping"}));
let m = loop {
let m = next();
if m["method"] != "notifications/progress" {
break m;
}
};
assert_eq!(m["id"], 8, "no response for the cancelled request: {m}");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
let v = loop {
send(run(9, "renderQueue.list", json!({}), None));
let v: Value = serde_json::from_str(next()["result"]["content"][0]["text"].as_str().unwrap()).unwrap();
// the queue stops rendering a moment before the stopped item's status is updated
if v["rendering"] == false && v["items"][1]["statusLabel"] != "Rendering" {
break v;
}
assert!(std::time::Instant::now() < deadline, "cancel did not stop the job: {v}");
std::thread::sleep(std::time::Duration::from_millis(20));
};
assert!(!b.exists(), "partial output deleted");
assert!(std::path::Path::new(&a).exists(), "completed earlier queue item is retained");
assert_eq!(v["items"][1]["statusLabel"], "User Stopped", "{v}");
drop(in_w);
server.join().unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn render_without_token_finishes_after_stdin_closes() {
let dir = std::env::temp_dir().join(format!("ec-mcp-eof-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("complete.mp4");
let mut s = McpServer::new(Backend::headless(Session { exporter: Some(std::sync::Arc::new(SlowExporter)), ..Default::default() }));
let mut input = String::new();
for (id, command, params) in
[(1, "comp.new", json!({"width":16,"height":16})), (2, "renderQueue.add", json!({"output":path})), (3, "renderQueue.render", json!({}))]
{
input.push_str(&format!(
"{}\n",
json!({"jsonrpc":"2.0","id":id,"method":"tools/call","params":{"name":"command_run","arguments":{"id":command,"params":params}}})
));
}
let mut out = Vec::new();
s.serve(std::io::Cursor::new(input), &mut out).unwrap();
let replies: Vec<Value> = String::from_utf8(out).unwrap().lines().map(|line| serde_json::from_str(line).unwrap()).collect();
assert_eq!(replies.len(), 3, "no token means no notifications: {replies:?}");
assert_eq!(replies[2]["result"]["isError"], false);
assert!(path.exists());
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn panicked_render_worker_finishes_with_an_error() {
struct BrokenExporter;
impl effectcraft_engine::Exporter for BrokenExporter {
fn formats(&self) -> Vec<effectcraft_engine::project::render_queue::OutputFormat> {
effectcraft_engine::project::render_queue::OutputFormat::ALL.to_vec()
}
fn export(&self, _: &effectcraft_engine::ExportJob, _: &mut dyn FnMut(u64, u64) -> bool) -> Result<effectcraft_engine::ExportResult, String> {
panic!("synthetic exporter failure");
}
}
let mut session = Session { exporter: Some(std::sync::Arc::new(BrokenExporter)), ..Default::default() };
session.execute("comp.new", json!({"width":16,"height":16})).unwrap();
session.execute("renderQueue.add", json!({"output":"unused.mp4"})).unwrap();
session.execute("renderQueue.render", json!({"wait":false})).unwrap();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
while session.is_rendering() && std::time::Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert!(!session.is_rendering(), "a panicked worker must not leave an unfinishable job");
session.poll_render();
let queue = session.execute("renderQueue.list", json!({})).unwrap();
assert!(queue.to_string().contains("synthetic exporter failure"), "{queue}");
session.execute("renderQueue.add", json!({"output":"unused-again.mp4"})).unwrap();
let mut server = McpServer::new(Backend::headless(session));
let input =
json!({"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"command_run","arguments":{"id":"renderQueue.render"}}}).to_string() + "\n";
let mut out = Vec::new();
server.serve(std::io::Cursor::new(input), &mut out).unwrap();
let reply: Value = serde_json::from_slice(&out).unwrap();
assert_eq!(reply["result"]["isError"], true, "{reply}");
}
/// `id` is an accepted alias of `command` (#365 made unknown keys an error).
#[test]
fn command_tools_accept_id_as_an_alias() {
let mut s = server();
let d = call_json(&mut s, "describe_command", json!({"id": "comp.new"}));
assert_eq!(d["id"], "comp.new", "{d}");
call_json(&mut s, "execute_command", json!({"id": "comp.new", "params": {"name": "Alias", "width": 64, "height": 36}}));
let (_, err) = call(&mut s, "execute_command", json!({}));
assert!(err, "neither key is an error");
}
/// #500: `--automation-read-root` / `--automation-write-root` confine the files an agent reads
/// and writes: inside the roots works, outside (or relative, `..`, through a symbolic link, by
/// a batch step, by Save to the project's own file, from a script) is refused.
#[test]
fn automation_roots_confine_file_access() {
use crate::confine::Confinement;
let base = std::env::temp_dir().join(format!("ec-mcp-confine-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let (read, write, outside) = (base.join("in"), base.join("out"), base.join("elsewhere"));
for d in [&read, &write, &outside] {
std::fs::create_dir_all(d).unwrap();
}
let p = |d: &std::path::Path, f: &str| d.join(f).to_string_lossy().into_owned();
// Unconfined (the default): anywhere.
let mut s = server();
call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "A", "width": 64, "height": 64}}));
call_json(&mut s, "save_project", json!({"path": p(&outside, "free.ecproj")}));
call_json(&mut s, "save_project", json!({"path": p(&read, "source.ecproj")}));
let mut s = server();
s.backend().confine(Confinement::new(std::slice::from_ref(&read), std::slice::from_ref(&write)).unwrap());
let denied = |s: &mut McpServer, name: &str, args: Value| {
let (c, err) = call(s, name, args.clone());
assert!(err, "{name} {args} should be refused: {c:?}");
c[0]["text"].as_str().unwrap_or_default().to_string()
};
// Reading: inside a read root, not outside.
call_json(&mut s, "open_project", json!({"path": p(&read, "source.ecproj")}));
let msg = denied(&mut s, "open_project", json!({"path": p(&outside, "free.ecproj")}));
assert!(msg.contains("outside the automation roots"), "{msg}");
// Writing: inside a write root only; not into a read root, not relative, not with `..`.
call_json(&mut s, "save_project", json!({"path": p(&write, "saved.ecproj")}));
assert!(write.join("saved.ecproj").is_file());
denied(&mut s, "save_project", json!({"path": p(&read, "copy.ecproj")}));
denied(&mut s, "save_project", json!({"path": "relative.ecproj"}));
denied(&mut s, "save_project", json!({"path": p(&write, "../elsewhere/up.ecproj")}));
denied(&mut s, "execute_command", json!({"command": "comp.saveFrameAs", "params": {"path": p(&outside, "f.png")}}));
// Any other absolute path a command is given.
denied(&mut s, "execute_command", json!({"command": "renderQueue.add", "params": {"output": p(&outside, "movie.mp4")}}));
// A batch step.
let steps = json!([{"command": "comp.new", "params": {"name": "B"}}, {"command": "file.saveAs", "params": {"path": p(&outside, "b.ecproj")}}]);
denied(&mut s, "batch", json!({"steps": steps}));
let steps = json!([{"command": "file.saveAs", "params": {"path": "$1.path"}}]);
denied(&mut s, "batch", json!({"steps": steps}));
// Save (and Increment and Save) write the open project's own file: it must be writable.
call_json(&mut s, "open_project", json!({"path": p(&read, "source.ecproj")}));
denied(&mut s, "save_project", json!({}));
denied(&mut s, "execute_command", json!({"command": "file.incrementAndSave"}));
// Rendering the queue writes its items' outputs (by default beside the project).
call_json(&mut s, "execute_command", json!({"command": "renderQueue.add", "params": {"comp": "A"}}));
let queue = call_json(&mut s, "execute_command", json!({"command": "renderQueue.list"}));
assert!(queue["items"][0]["outputPath"].as_str().is_some_and(|o| o.starts_with(&*read.to_string_lossy())), "{queue}");
let msg = denied(&mut s, "execute_command", json!({"command": "renderQueue.render"}));
assert!(msg.contains("cannot write"), "{msg}");
call_json(&mut s, "open_project", json!({"path": p(&write, "saved.ecproj")}));
call_json(&mut s, "save_project", json!({}));
// Through a symbolic link out of a root.
#[cfg(unix)]
{
std::os::unix::fs::symlink(&outside, write.join("link")).unwrap();
denied(&mut s, "save_project", json!({"path": p(&write.join("link"), "escaped.ecproj")}));
assert!(!outside.join("escaped.ecproj").exists());
}
// Scripts can read and write anything: refused.
denied(&mut s, "run_script", json!({"code": "1 + 1"}));
denied(&mut s, "execute_command", json!({"command": "file.runScript", "params": {"path": p(&read, "x.jsx")}}));
// Writes to the files the agent named: none outside.
assert_eq!(std::fs::read_dir(&outside).unwrap().count(), 1, "only the unconfined save");
let _ = std::fs::remove_dir_all(&base);
}

View File

@ -31,62 +31,10 @@ impl ToolDef {
pub fn input_schema(&self) -> Value {
(self.schema)()
}
/// The MCP `Tool` object, with a title and annotations (docs/mcp.md).
/// The MCP `Tool` object.
pub fn descriptor(&self) -> Value {
let title = title_of(self.name);
let read_only = READ_ONLY.contains(&self.name);
let files = WRITES_FILES.contains(&self.name);
json!({
"name": self.name,
"title": title,
"description": self.description,
"inputSchema": self.input_schema(),
"annotations": {
"title": title,
"readOnlyHint": read_only,
"destructiveHint": !read_only && !files,
"idempotentHint": read_only || files,
"openWorldHint": false,
},
})
json!({"name": self.name, "description": self.description, "inputSchema": self.input_schema()})
}
/// The first argument this tool doesn't take, as a message naming the accepted ones.
pub fn unknown_arg(&self, args: &Value) -> Option<String> {
let Some(m) = args.as_object() else { return Some("arguments must be an object".into()) };
let schema = self.input_schema();
let props = schema.get("properties")?.as_object()?;
let bad = m.keys().find(|k| !props.contains_key(*k))?;
let accepted: Vec<&str> = props.keys().map(String::as_str).collect();
Some(format!("unknown argument \"{bad}\" for {}; expected: {}", self.name, accepted.join(", ")))
}
}
/// Tools that only read.
const READ_ONLY: &[&str] = &[
"command_list",
"list_commands",
"describe_command",
"doc_inspect",
"render_preview",
"get_project",
"get_comp",
"get_layer",
"get_property",
"list_effects",
"list_fonts",
"get_state",
"ui_inspect",
"ui_elements",
];
/// Tools that write files without changing the project.
const WRITES_FILES: &[&str] = &["save_project", "render_frame", "screenshot"];
/// `get_project` → "Get project".
fn title_of(name: &str) -> String {
let s = name.replace('_', " ");
let mut c = s.chars();
c.next().map(|f| f.to_uppercase().collect::<String>() + c.as_str()).unwrap_or_default()
}
/// All tools (headless + bridge-only).
@ -103,19 +51,12 @@ pub fn available(bridge: bool) -> impl Iterator<Item = &'static ToolDef> {
TOOLS.iter().filter(move |t| bridge || !t.bridge_only)
}
/// Tools refused while file access is confined: they drive the live app like a person (menus,
/// shortcuts, Open Recent…) or call control-channel methods directly, past the command checks.
const UNCONFINED_ONLY: &[&str] = &["control", "ui_click", "ui_drag", "ui_key", "ui_type", "ui_set", "script_ui"];
/// Run a tool by name.
pub fn run(b: &mut Backend, name: &str, args: &Value) -> Result<Reply> {
let t = find(name).ok_or_else(|| Error::BadArgs(format!("unknown tool `{name}`")))?;
if t.bridge_only && !b.is_bridge() {
return Err(Error::BadArgs(NEED_BRIDGE.into()));
}
if b.confinement().is_some() && UNCONFINED_ONLY.contains(&name) {
return Err(Error::BadArgs(format!("`{name}` is not available while file access is confined (--automation-read-root/--automation-write-root)")));
}
let empty = json!({});
(t.run)(b, if args.is_null() { &empty } else { args })
}
@ -123,7 +64,7 @@ pub fn run(b: &mut Backend, name: &str, args: &Value) -> Result<Reply> {
// ------------------------------------------------------------------ schema fragments
fn schema(props: Value, required: &[&str]) -> Value {
json!({"type": "object", "properties": props, "required": required, "additionalProperties": false})
json!({"type": "object", "properties": props, "required": required})
}
fn comp_s() -> Value {
json!({"type": ["integer", "string"], "description": "Composition item id or name; omit for the active comp."})
@ -209,12 +150,7 @@ fn list_effects(b: &mut Backend, a: &Value) -> Result<Reply> {
json_reply(b.exec("effect.list", obj(&[("filter", get(a, "filter"))]))?)
}
fn list_fonts(b: &mut Backend, a: &Value) -> Result<Reply> {
json_reply(b.exec("text.fonts", obj(&[("query", get(a, "query")), ("variable", get(a, "variable")), ("rescan", get(a, "rescan"))]))?)
}
/// `effect.apply` on one layer, then set parameters on the new instance by param id: one batch,
/// so one undo step, and a failing value leaves nothing applied.
/// `effect.apply` on one layer, then set parameters on the new instance by param id.
fn add_effect(b: &mut Backend, a: &Value) -> Result<Reply> {
let (layer, effect, comp) = (need(a, "layer")?, need(a, "effect")?, get(a, "comp"));
let count = |t: &Value| effects_group(t).and_then(|g| g.get("children")).and_then(Value::as_array).map_or(0, Vec::len);
@ -222,22 +158,16 @@ fn add_effect(b: &mut Backend, a: &Value) -> Result<Reply> {
let index = count(&before) + 1;
let mut p = obj(&[("effect", Some(effect)), ("comp", comp)]);
p["layers"] = json!([layer]);
let mut steps = vec![json!({"command": "effect.apply", "params": p})];
b.exec("effect.apply", p)?;
let prefix = format!("effects/#{index}");
let mut set = vec![];
if let Some(vals) = get(a, "values").and_then(Value::as_object) {
for (k, v) in vals {
let path = format!("{prefix}/{k}");
steps.push(
json!({"command": "prop.set", "params": obj(&[("layer", Some(layer)), ("comp", comp), ("path", Some(&json!(path))), ("value", Some(v))])}),
);
b.exec("prop.set", obj(&[("layer", Some(layer)), ("comp", comp), ("path", Some(&json!(path))), ("value", Some(v))]))?;
set.push(path);
}
}
// The undo step is named like effect.apply's own ("Apply Gaussian Blur").
let name = effect.as_str().map(|e| effectcraft_engine::effects::lookup(e).map_or(e, |s| s.name));
let label = name.map(|n| json!(format!("Apply {n}")));
b.exec("engine.batch", obj(&[("steps", Some(&json!(steps))), ("label", label.as_ref())]))?;
let tree = b.exec("layer.tree", obj(&[("layer", Some(layer)), ("comp", comp)]))?;
let inst = effects_group(&tree).and_then(|g| g.get("children")).and_then(Value::as_array).and_then(|c| c.get(index - 1)).cloned();
let mut params = vec![];
@ -326,9 +256,6 @@ fn set_property(b: &mut Backend, a: &Value) -> Result<Reply> {
match time {
Some(t) => {
p["time"] = t.clone();
if let Some(tb) = get(a, "timeBase") {
p["timeBase"] = tb.clone();
}
b.exec("prop.addKey", p)?;
}
None => {
@ -361,9 +288,6 @@ fn add_keyframe(b: &mut Backend, a: &Value) -> Result<Reply> {
if let Some(v) = get(k, "value") {
p["value"] = v.clone();
}
if let Some(tb) = get(a, "timeBase") {
p["timeBase"] = tb.clone();
}
b.exec("prop.addKey", p)?;
}
let info = b.exec("prop.get", obj(&base))?;
@ -373,11 +297,7 @@ fn add_keyframe(b: &mut Backend, a: &Value) -> Result<Reply> {
b.exec("comp.open", json!({"comp": c}))?;
}
let sel: Vec<Value> = keys.iter().map(|k| json!({"layer": info["layer"], "prop": info["uid"], "time": k["time"]})).collect();
let mut select = json!({"keys": sel});
if let Some(tb) = get(a, "timeBase") {
select["timeBase"] = tb.clone();
}
b.exec("keys.select", select)?;
b.exec("keys.select", json!({"keys": sel}))?;
match interp.to_ascii_lowercase().as_str() {
"easyease" | "ease" => b.exec("keys.easyEase", json!({}))?,
"easyeasein" => b.exec("keys.easyEase", json!({"which": "in"}))?,
@ -396,9 +316,6 @@ fn render_frame(b: &mut Backend, a: &Value) -> Result<Reply> {
let f = b.render_with(get(a, "comp"), get(a, "time").and_then(Value::as_f64), max_side, transparent)?;
let mut info = json!({"comp": f.comp, "time": f.time, "width": f.width, "height": f.height});
if let Some(p) = get(a, "path").and_then(Value::as_str) {
if let Some(c) = b.confinement() {
c.check(p, crate::confine::Access::Write)?;
}
std::fs::write(p, &f.png).map_err(|e| Error::Other(format!("cannot write {p}: {e}")))?;
info["path"] = json!(p);
}
@ -492,9 +409,6 @@ fn batch_tool(b: &mut Backend, a: &Value) -> Result<Reply> {
fn screenshot(b: &mut Backend, a: &Value) -> Result<Reply> {
let max_side = get(a, "max_side").and_then(Value::as_u64).unwrap_or(1600) as u32;
let keep = get(a, "path").and_then(Value::as_str).map(str::to_string);
if let (Some(c), Some(p)) = (b.confinement(), &keep) {
c.check(p, crate::confine::Access::Write)?;
}
let path =
keep.clone().unwrap_or_else(|| std::env::temp_dir().join(format!("effectcraft-mcp-shot-{}.png", std::process::id())).to_string_lossy().into_owned());
b.control("ui.screenshot", obj(&[("path", Some(&json!(path))), ("panel", get(a, "panel")), ("id", get(a, "id"))]))?;
@ -560,12 +474,7 @@ static TOOLS: &[ToolDef] = &[
name: "describe_command",
description: "One command in full: id, label, menu path, shortcut, the `params` doc, a JSON Schema of its parameters (`schema`: properties, required, additionalProperties) and whether it can run now (`why` if not). Use before execute_command when unsure of the parameters; execute_command rejects unknown keys.",
bridge_only: false,
schema: || {
schema(
json!({"command": {"type": "string", "description": "Command id, e.g. `layer.newText`."}, "id": {"type": "string", "description": "Alias of `command`."}}),
&[],
)
},
schema: || schema(json!({"command": {"type": "string", "description": "Command id, e.g. `layer.newText`."}}), &["command"]),
run: describe_command,
},
ToolDef {
@ -576,10 +485,9 @@ static TOOLS: &[ToolDef] = &[
schema(
json!({
"command": {"type": "string", "description": "Command id, e.g. `comp.new`, `layer.newText`, `effect.apply` (see list_commands)."},
"id": {"type": "string", "description": "Alias of `command`."},
"params": {"type": "object", "description": "Command parameters as documented by list_commands.", "additionalProperties": true}
}),
&[],
&["command"],
)
},
run: execute_command,
@ -615,7 +523,7 @@ static TOOLS: &[ToolDef] = &[
},
ToolDef {
name: "get_layer",
description: "A layer's property tree (transform, masks, effects, text, shape contents, ...): leaf properties have a `path` usable with get_property/set_property/add_keyframe, plus uid, match id, name, type, value at `time`, keyframes and expression. Groups contain children; inspect them here, then select a leaf path. `flat: true` returns just a list of {path, type, value, keys, expression} (compact).",
description: "A layer's property tree (transform, masks, effects, text, shape contents, ...): every node has a `path` usable with get_property/set_property/add_keyframe, plus uid, match id, name, type, value at `time`, keyframes and expression. `flat: true` returns just a list of {path, type, value, keys, expression} (compact).",
bridge_only: false,
schema: || {
schema(
@ -651,7 +559,6 @@ static TOOLS: &[ToolDef] = &[
json!({
"layer": layer_s(), "path": path_s(), "value": value_s(), "comp": comp_s(),
"time": {"type": "number", "description": "Keyframe time in seconds (layer time; equals comp time unless the layer is offset/stretched)."},
"timeBase": {"type": "string", "enum": ["layer", "comp"], "description": "What `time` counts: layer time (the default) or comp time, as the Timeline shows it."},
"expression": {"type": "string", "description": "Expression text; empty string removes the expression."}
}),
&["layer", "path"],
@ -668,7 +575,6 @@ static TOOLS: &[ToolDef] = &[
json!({
"layer": layer_s(), "path": path_s(), "comp": comp_s(),
"time": {"type": "number", "description": "Key time in seconds (layer time)."},
"timeBase": {"type": "string", "enum": ["layer", "comp"], "description": "What `time` counts: layer time (the default) or comp time, as the Timeline shows it."},
"value": value_s(),
"keys": {"type": "array", "items": {"type": "object", "properties": {"time": {"type": "number"}, "value": value_s()}, "required": ["time"]}, "description": "Several keys at once."},
"interpolation": {"type": "string", "enum": ["linear", "bezier", "hold", "easyEase", "easyEaseIn", "easyEaseOut"]}
@ -680,7 +586,7 @@ static TOOLS: &[ToolDef] = &[
},
ToolDef {
name: "add_effect",
description: "Apply an effect to a layer and optionally set its parameters in one undoable call. `effect` is an id (`ec.blur.gaussian`) or After Effects name (`Gaussian Blur`; see list_effects); `values` maps parameter ids to values, e.g. {\"blurriness\": 12}. One undo step; if a value fails, nothing is applied. Returns the instance `path` (`effects/#n`, for set_property / add_keyframe) and its parameters with their paths and current values.",
description: "Apply an effect to a layer and optionally set its parameters in one undoable call. `effect` is an id (`ec.blur.gaussian`) or After Effects name (`Gaussian Blur`; see list_effects); `values` maps parameter ids to values, e.g. {\"blurriness\": 12}. Returns the instance `path` (`effects/#n`, for set_property / add_keyframe) and its parameters with their paths and current values.",
bridge_only: false,
schema: || {
schema(
@ -701,22 +607,6 @@ static TOOLS: &[ToolDef] = &[
schema: || schema(json!({"filter": {"type": "string", "description": "Only effects whose id, name or category contains this text."}}), &[]),
run: list_effects,
},
ToolDef {
name: "list_fonts",
description: "The font families text layers can use, bundled and installed on this machine: family, styles, origin (bundled / system / user), whether it is a variable font and the name in the font's own language. Narrow with `query` and `variable`; `rescan` picks up fonts installed since launch. Set one with execute_command `layer.setText {\"layer\", \"font\", \"style\"}`.",
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."}
}),
&[],
)
},
run: list_fonts,
},
ToolDef {
name: "get_state",
description: "Where things stand: the editor state (active comp, current time, selected layers/keys/items, open comps, tool, work area) plus what this build can do (`app`: version, command and effect counts, GPU effects, import/export formats, After Effects parity summary). Cheap; call it to re-orient after a series of edits.",
@ -909,101 +799,4 @@ static TOOLS: &[ToolDef] = &[
},
run: batch_tool,
},
ToolDef {
name: "command_list",
description: "List engine commands: id, label, menu path, shortcut, `params` doc (e.g. `{name?, width?, height?, frameRate?, duration? (s)}`) and whether it can run now (`why` if not). Every menu item, shortcut and panel gesture maps to one of these ids; run them with execute_command. Narrow with `filter` (e.g. `layer.new`, `keys`, `effect`).",
bridge_only: false,
schema: || {
schema(
json!({
"filter": {"type": "string", "description": "Only commands whose id or label contains this text (case-insensitive)."},
"enabled_only": {"type": "boolean", "description": "Only commands that can run right now."},
"schemas": {"type": "boolean", "description": "Also return each command's parameter JSON Schema (`schema`)."}
}),
&[],
)
},
run: list_commands,
},
ToolDef {
name: "command_run",
description: "Run any engine command by id with JSON params; edits are undoable. Examples: comp.new {\"name\":\"Main\",\"width\":1920,\"height\":1080,\"frameRate\":30,\"duration\":5}; layer.newText {\"text\":\"Hello\",\"size\":120,\"fill\":\"#ffffff\"} (returns {\"layer\":id}); layer.newSolid {\"color\":\"#3366ff\"}; layer.newShape; effect.apply {\"layer\":3,\"effect\":\"Gaussian Blur\"}; time.set {\"time\":2.5}; layer.select {\"layers\":[3]}. Layers are referenced by id, \"#n\" or name; times are seconds.",
bridge_only: false,
schema: || {
schema(
json!({
"id": {"type": "string", "description": "Command id, e.g. `comp.new`, `layer.newText`, `effect.apply` (see list_commands)."},
"params": {"type": "object", "description": "Command parameters as documented by list_commands.", "additionalProperties": true}
}),
&["id"],
)
},
run: execute_command,
},
ToolDef {
name: "render_preview",
description: "Render a composition frame (default: the active comp at the current time) and return it as a PNG image, longest side `max_side` (default 960, 0 = full size). Use it to check your work.",
bridge_only: false,
schema: || {
schema(
json!({
"comp": comp_s(),
"time": {"type": "number", "description": "Comp time in seconds (default: current time)."},
"max_side": {"type": "integer", "description": "Longest side in pixels (default 960; 0 = full comp size)."},
"transparent": {"type": "boolean", "description": "Keep the frame's alpha (RGBA PNG, as an RGB + Alpha render writes it) instead of compositing over the comp's background colour."}
}),
&[],
)
},
run: render_frame,
},
ToolDef {
name: "command_batch",
description: "Run commands in order, returning each result or error. Stop at the first error by default; stop_on_error: false continues. Each edit has its own undo step; use batch for an atomic undo group.",
bridge_only: false,
schema: || {
schema(
json!({"steps": {"type": "array", "items": {"type": "object", "properties": {"id": {"type": "string"}, "params": {"type": "object"}}, "required": ["id"], "additionalProperties": false}}, "stop_on_error": {"type": "boolean"}}),
&["steps"],
)
},
run: command_batch,
},
ToolDef {
name: "doc_inspect",
description: "Inspect the project overview and active composition (null when no composition is active).",
bridge_only: false,
schema: || schema(json!({}), &[]),
run: doc_inspect,
},
];
fn doc_inspect(b: &mut Backend, _: &Value) -> Result<Reply> {
let project = b.exec("project.summary", json!({}))?;
let comp = b.exec("comp.info", json!({})).ok();
json_reply(json!({"project": project, "activeComp": comp}))
}
fn command_batch(b: &mut Backend, a: &Value) -> Result<Reply> {
let steps = need(a, "steps")?.as_array().ok_or_else(|| Error::BadArgs("steps must be an array".into()))?;
let stop = get(a, "stop_on_error").and_then(Value::as_bool).unwrap_or(true);
let mut results = Vec::new();
let (mut completed, mut failed) = (0, 0);
for step in steps {
match execute_command(b, step) {
Ok(Reply::Json(result)) => {
completed += 1;
results.push(json!({"ok": true, "result": result}));
}
Ok(Reply::Image { .. }) => return Err(Error::Other("unexpected command image".into())),
Err(e) => {
failed += 1;
results.push(json!({"ok": false, "error": e.to_string()}));
if stop {
break;
}
}
}
}
json_reply(json!({"completed": completed, "failed": failed, "results": results}))
}

View File

@ -71,17 +71,11 @@ fn kinetic_typography() {
// A precomp of everything, time-remapped to play backwards.
let pre = qa.exec("layer.precompose", json!({"layers": ["Title", "Sub"], "name": "Type Pre", "mode": "move"}));
assert!(pre["comp"].is_u64() && pre["layer"].is_u64(), "{pre}");
// Nested switches pass the precomp layer's blur flag down to the animated title.
qa.exec("layer.setSwitch", json!({"layers": [pre["layer"].clone()], "switch": "motionBlur", "value": true}));
qa.exec("layer.enableTimeRemap", json!({"layers": [pre["layer"].clone()], "value": true}));
qa.tool("add_keyframe", json!({"layer": pre["layer"].clone(), "path": "timeRemap", "keys": [{"time": 0, "value": 1.9}, {"time": 1.9, "value": 0}]}));
// The 1.9 s key sits on frame 23 (1.917 s), so 0.3 s maps to 1.6026 s: compared at that time,
// not 1.6 s, where the moving title (and text drawn at its on-screen scale) differs.
let at = qa.tool("get_property", json!({"layer": pre["layer"].clone(), "path": "timeRemap", "time": 0.3}))["value"].as_f64().unwrap();
assert!((at - 1.6).abs() < 0.01, "time remap 0.3 s → 1.6 s of the precomp: {at}");
let fwd = qa.frame(Some(pre["comp"].clone()), at);
let fwd = qa.frame(Some(pre["comp"].clone()), 1.6);
let rev = qa.frame(Some(comp.clone()), 0.3);
assert!(mean_diff(&fwd, &rev) < 1.0, "the remapped precomp shows its frame at {at} s: {}", mean_diff(&fwd, &rev));
assert!(mean_diff(&fwd, &rev) < 1.0, "time remap 0.3 s → 1.6 s of the precomp");
// Render: H.264 and a PNG sequence through the render queue.
let mp4 = qa.path("kinetic.mp4");

View File

@ -75,7 +75,7 @@ fn check(name: &str, cfg: EncoderConfig, pics: &[Pic]) -> Vec<u32> {
let raw = std::fs::read(&output).expect("decoded yuv");
let _ = std::fs::remove_dir_all(&dir);
let samples: Vec<u16> =
if bd == 8 { raw.iter().map(|&b| b as u16).collect() } else { raw.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes([c[0], c[1]])).collect() };
if bd == 8 { raw.iter().map(|&b| b as u16).collect() } else { raw.chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]])).collect() };
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
let fsize = w * h + 2 * cw * ch;
assert_eq!(samples.len(), fsize * pics.len(), "{name}: decoded frame count");

View File

@ -159,8 +159,7 @@ impl BlendMode {
/// Modes whose formulas are defined for over-range (HDR, 32 bpc) values. The others clamp
/// their inputs to 0..1 before blending, as After Effects documents for 32 bpc projects
/// (in 8/16 bpc everything is already in range). Overlay, Exclusion, Luminosity and the
/// Color Burns keep their plain formula above 1 in After Effects (#494).
/// (in 8/16 bpc everything is already in range).
pub fn supports_hdr(self) -> bool {
matches!(
self,
@ -169,8 +168,6 @@ impl BlendMode {
| BlendMode::DancingDissolve
| BlendMode::Darken
| BlendMode::Multiply
| BlendMode::ColorBurn
| BlendMode::ClassicColorBurn
| BlendMode::LinearBurn
| BlendMode::DarkerColor
| BlendMode::Add
@ -180,9 +177,6 @@ impl BlendMode {
| BlendMode::LighterColor
| BlendMode::Difference
| BlendMode::ClassicDifference
| BlendMode::Overlay
| BlendMode::Exclusion
| BlendMode::Luminosity
| BlendMode::Subtract
| BlendMode::Divide
| BlendMode::StencilAlpha
@ -194,17 +188,6 @@ impl BlendMode {
)
}
/// The source colour a classic mode leaves the backdrop unchanged with. After Effects fades
/// the source toward it by the layer's opacity before applying the classic formula at full
/// strength, rather than mixing the full-strength result by opacity (#494).
fn classic_neutral(self) -> Option<f32> {
match self {
BlendMode::ClassicColorDodge | BlendMode::ClassicDifference => Some(0.0),
BlendMode::ClassicColorBurn => Some(1.0),
_ => None,
}
}
pub fn next(self) -> BlendMode {
let i = BlendMode::ALL.iter().position(|m| *m == self).unwrap_or(0);
BlendMode::ALL[(i + 1) % BlendMode::ALL.len()]
@ -259,8 +242,11 @@ fn separable(mode: BlendMode, cb: f32, cs: f32) -> f32 {
BlendMode::Multiply => cb * cs,
BlendMode::ColorBurn => burn(cb, cs),
BlendMode::ClassicColorBurn => {
// (negative results are kept, as in After Effects)
if cs <= 0.0 { 0.0 } else { 1.0 - (1.0 - cb) / cs }
if cs <= 0.0 {
0.0
} else {
(1.0 - (1.0 - cb) / cs).max(0.0)
}
}
BlendMode::LinearBurn => (cb + cs - 1.0).max(0.0),
BlendMode::Add | BlendMode::LinearDodge => cb + cs,
@ -415,19 +401,11 @@ pub fn blend_pixel(mode: BlendMode, dst: [f32; 4], src: [f32; 4], noise: f32) ->
cs = cs.map(|v| v.clamp(0.0, 1.0));
cb = cb.map(|v| v.clamp(0.0, 1.0));
}
let b = non_separable(mode, cb, cs);
let ao = sa + da - sa * da;
let mut o = [0.0f32; 4];
if let Some(neutral) = mode.classic_neutral() {
// Fade the source toward the neutral colour by its coverage, then apply the formula.
let b = non_separable(mode, cb, cs.map(|v| neutral + sa * (v - neutral)));
for i in 0..3 {
o[i] = (1.0 - da) * src[i] + da * b[i];
}
} else {
let b = non_separable(mode, cb, cs);
for i in 0..3 {
o[i] = (1.0 - da) * src[i] + (1.0 - sa) * dst[i] + sa * da * b[i];
}
for i in 0..3 {
o[i] = (1.0 - da) * src[i] + (1.0 - sa) * dst[i] + sa * da * b[i];
}
o[3] = ao;
o
@ -491,41 +469,12 @@ mod tests {
#[test]
fn hdr_modes() {
// Add and Screen keep over-range values (32 bpc); Soft Light clamps its inputs.
// Add and Screen keep over-range values (32 bpc); Overlay clamps its inputs.
let d = [2.0, 2.0, 2.0, 1.0];
let s = [0.5, 0.5, 0.5, 1.0];
assert!((blend_pixel(BlendMode::Add, d, s, 0.5)[0] - 2.5).abs() < 1e-6);
assert!((blend_pixel(BlendMode::Screen, d, s, 0.5)[0] - 1.5).abs() < 1e-6);
assert!(blend_pixel(BlendMode::SoftLight, d, s, 0.5)[0] <= 1.0);
}
/// #494: After Effects' 32 bpc formulas, measured by the reporter. b = bottom, o = top,
/// k = opacity (premultiplied into the source, as the compositor passes it).
#[test]
fn after_effects_32bpc_formulas() {
let at = |m, b: f32, o: f32, k: f32| blend_pixel(m, opaque(b), [o * k, o * k, o * k, k], 0.5)[0];
let close = |a: f32, e: f32| (a - e).abs() < 1e-5;
// Classic modes fade the source toward their neutral colour first.
for (b, o) in [(0.4, 0.5), (0.8, 0.9), (0.1, 0.3)] {
let k = 0.15;
assert!(close(at(BlendMode::ClassicColorDodge, b, o, k), (b / (1.0 - k * o)).min(1.0)));
assert!(close(at(BlendMode::ClassicDifference, b, o, k), (b - k * o).abs()));
assert!(close(at(BlendMode::ClassicColorBurn, b, o, k), 1.0 - (1.0 - b) / (1.0 - k * (1.0 - o))));
}
// At full opacity the classic formulas are unchanged.
assert!(close(at(BlendMode::ClassicDifference, 0.6, 0.3, 1.0), 0.3));
// Over-range top layers keep the plain formulas.
let (b, o) = (0.7, 3.0);
assert!(close(at(BlendMode::Exclusion, b, o, 1.0), b + o - 2.0 * b * o));
let s = 2.0 * b - 1.0;
assert!(close(at(BlendMode::Overlay, b, o, 1.0), o + s - o * s));
assert!(close(at(BlendMode::ColorBurn, 0.0, 1.35, 0.15), 0.15 * (1.0 - 1.0 / 1.35)));
assert!(close(at(BlendMode::ClassicColorBurn, 0.0, 0.1, 1.0), -9.0), "negative results are kept");
// Luminosity: SetLum + ClipColor on the unclamped top layer.
let l = blend_pixel(BlendMode::Luminosity, [0.2, 0.4, 0.6, 1.0], [2.0, 2.0, 2.0, 1.0], 0.5);
let e = set_lum([0.2, 0.4, 0.6], 2.0);
assert!((0..3).all(|i| close(l[i], e[i])), "{l:?} {e:?}");
assert!((l[0] - 1.0).abs() > 0.1, "not the white of a top layer clamped to 1: {l:?}");
assert!(blend_pixel(BlendMode::Overlay, d, s, 0.5)[0] <= 1.0);
}
#[test]

View File

@ -222,7 +222,7 @@ fn description(t: Option<&[u8]>) -> Option<String> {
// The first record: UTF-16BE.
let len = be32(t, 20)? as usize;
let off = be32(t, 24)? as usize;
let u: Vec<u16> = t.get(off..off + len)?.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes(*c)).collect();
let u: Vec<u16> = t.get(off..off + len)?.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect();
Some(String::from_utf16_lossy(&u).trim_end_matches('\0').to_string())
}
_ => None,

View File

@ -15,7 +15,6 @@ effectcraft-color = { workspace = true }
effectcraft-keyframe = { workspace = true }
effectcraft-project = { workspace = true }
effectcraft-raster = { workspace = true }
effectcraft-segment = { workspace = true }
effectcraft-track = { workspace = true }
rayon = { workspace = true }
serde = { workspace = true }

View File

@ -245,8 +245,10 @@ pub fn process(id: &str, params: &Params, buf: &mut [f32], rate: u32, start: f64
}
let sr = rate as f64;
match id {
"ec.audio.backwards" if params.b("swapChannels") => {
buf.as_chunks_mut::<2>().0.iter_mut().for_each(|f| f.swap(0, 1));
"ec.audio.backwards" => {
if params.b("swapChannels") {
buf.chunks_exact_mut(2).for_each(|f| f.swap(0, 1));
}
}
"ec.audio.basstreble" => {
let bass = params.f("bass") / 100.0 * 12.0;
@ -469,7 +471,7 @@ fn compressor(params: &Params, buf: &mut [f32], sr: f64) {
let limit = db_to_lin(params.f("outputLimit").min(0.0)) as f32;
// Smoothed gain change in dB: attack while the reduction grows, release as it shrinks.
let (mut g, mut gs) = (0.0f64, 0.0f64);
for f in buf.as_chunks_mut::<2>().0.iter_mut() {
for f in buf.chunks_exact_mut(2) {
let peak = f[0].abs().max(f[1].abs()) as f64;
let target = compressor_gain_db(lin_to_db(peak), th, ratio, knee);
let k = if target < g { att } else { rel };
@ -495,7 +497,7 @@ fn gate(params: &Params, buf: &mut [f32], sr: f64) {
// Openness 0..1 (closed..open) is the gain: a closed gate mutes.
let mut g = 0.0f64;
let mut held = 0usize;
for f in buf.as_chunks_mut::<2>().0.iter_mut() {
for f in buf.chunks_exact_mut(2) {
let peak = f[0].abs().max(f[1].abs()) as f64;
let open = if peak >= th {
held = hold;
@ -558,7 +560,7 @@ fn distortion(params: &Params, buf: &mut [f32], sr: f64, start: f64) {
// Sample-and-hold on absolute sample positions, so blocks agree with one long run.
let first = (start * sr).round() as i64;
let mut held = [0.0f32; 2];
for (i, f) in buf.as_chunks_mut::<2>().0.iter_mut().enumerate() {
for (i, f) in buf.chunks_exact_mut(2).enumerate() {
let take = i == 0 || (first + i as i64).rem_euclid(down) == 0;
for ch in 0..2 {
let x = f[ch];
@ -694,7 +696,7 @@ fn stereo_mixer(params: &Params, buf: &mut [f32]) {
let (l_to_l, l_to_r) = pan(params.f("leftPan"));
let (r_to_l, r_to_r) = pan(params.f("rightPan"));
let inv = if params.b("invertPhase") { -1.0 } else { 1.0 };
for f in buf.as_chunks_mut::<2>().0.iter_mut() {
for f in buf.chunks_exact_mut(2) {
let (l, r) = (f[0] * ll, f[1] * rl);
f[0] = (l * l_to_l + r * r_to_l) * inv;
f[1] = (l * l_to_r + r * r_to_r) * inv;

View File

@ -4,8 +4,7 @@
//!
//! Pieces live in "buffer world" space: x, y in buffer pixels, z in buffer pixels away from the
//! viewer (the layer plane is z = 0). A [`Proj`] maps buffer-world points to buffer pixels
//! (homogeneous), so every camera system reduces to one 3×4 matrix. CC Particle World sees its
//! particles through the comp camera with the same [`comp_proj`].
//! (homogeneous), so every camera system reduces to one 3×4 matrix.
use effectcraft_keyframe::Value;
use effectcraft_project::ParamUi;
@ -171,29 +170,6 @@ impl Proj {
(w > self.near).then(|| [r(0) / w, r(1) / w])
}
/// How a buffer-world point `p` moving by `v` is seen: its buffer pixel, its depth W, the
/// buffer pixels per buffer-world pixel of a camera-facing size there, and its velocity in
/// buffer pixels. `None` behind or too close to the camera, or for a degenerate camera.
pub fn view(&self, p: [f64; 3], v: [f64; 3]) -> Option<Seen> {
let row = |i: usize| [self.m[i][0], self.m[i][1], self.m[i][2]];
let (m0, m1, m2) = (row(0), row(1), row(2));
let w = dot(m2, p) + self.m[2][3];
if !(w > self.near && w.is_finite()) {
return None;
}
let at = [(dot(m0, p) + self.m[0][3]) / w, (dot(m1, p) + self.m[1][3]) / w];
// The focal length (pixels per unit at unit depth): |m_i × m2| / |m2|² for K·[R|t]
// scaled by |m2|; the geometric mean of the two image axes.
let l2 = dot(m2, m2);
let scale = (len(cross(m0, m2)) * len(cross(m1, m2))).sqrt() / (l2.sqrt() * w);
if !(scale > 0.0 && scale.is_finite() && at.iter().all(|a| a.is_finite())) {
return None;
}
// d(X / W) = (m_i − at_i · m2) · dp / W.
let vel = [(dot(m0, v) - at[0] * dot(m2, v)) / w, (dot(m1, v) - at[1] * dot(m2, v)) / w];
Some(Seen { at, depth: w, scale, vel })
}
/// The camera's centre in buffer-world space (where the projection collapses), or a point
/// far in front of the layer when it has none (orthographic).
pub fn eye(&self) -> [f64; 3] {
@ -208,15 +184,6 @@ impl Proj {
}
}
/// A point seen through a [`Proj`] (see [`Proj::view`]).
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct Seen {
pub at: [f64; 2],
pub depth: f64,
pub scale: f64,
pub vel: [f64; 2],
}
pub(crate) fn inv3(m: &M3) -> Option<M3> {
let det = m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]);
@ -268,10 +235,24 @@ pub(crate) fn projection(ctx: &EffectCtx, b: &Buf) -> Proj {
None => base,
}
}
2 => match ctx.env.host.and_then(|h| h.comp_scene()).and_then(|sc| sc.camera) {
Some(cam) => comp_proj(&cam, c, b),
None => Proj::simple(c, size * 2.0),
},
2 => {
// Comp Camera: the comp's camera looking at this layer (layer px → layer px).
let base = Proj::simple(c, size * 2.0);
let Some(cam) = ctx.env.host.and_then(|h| h.comp_scene()).and_then(|sc| sc.camera) else { return base };
// buffer world → layer: (p − offset) / s; layer → buffer: · s + offset.
let mut m = [[0.0; 4]; 3];
for i in 0..3 {
for j in 0..3 {
m[i][j] = cam[i][j] / s;
}
m[i][3] = cam[i][3] - (cam[i][0] * b.offset[0] + cam[i][1] * b.offset[1]) / s;
}
let back: M3 = [[s, 0.0, b.offset[0]], [0.0, s, b.offset[1]], [0.0, 0.0, 1.0]];
let p = Proj { m, near: 1e-6 }.then(&back);
// W of the layer centre sets the scale of `near`.
let wc = p.m[2][0] * c[0] + p.m[2][1] * c[1] + p.m[2][3];
Proj { near: (wc.abs() * 0.02).max(1e-9), ..p }
}
_ => {
let zpos = f("cameraPosition/cameraZ", 2.0).max(0.05);
let focal = f("cameraPosition/focalLength", 70.0).max(1.0);
@ -301,25 +282,6 @@ pub(crate) fn projection(ctx: &EffectCtx, b: &Buf) -> Proj {
}
}
/// The comp camera `cam` ([`CompScene::camera`], layer px → layer px) looking at buffer `b`,
/// whose layer centre is buffer pixel `c`.
pub(crate) fn comp_proj(cam: &[[f64; 4]; 3], c: [f64; 2], b: &Buf) -> Proj {
let s = b.scale;
// buffer world → layer: (p − offset) / s; layer → buffer: · s + offset.
let mut m = [[0.0; 4]; 3];
for i in 0..3 {
for j in 0..3 {
m[i][j] = cam[i][j] / s;
}
m[i][3] = cam[i][3] - (cam[i][0] * b.offset[0] + cam[i][1] * b.offset[1]) / s;
}
let back: M3 = [[s, 0.0, b.offset[0]], [0.0, s, b.offset[1]], [0.0, 0.0, 1.0]];
let p = Proj { m, near: 1e-6 }.then(&back);
// W of the layer centre sets the scale of `near`.
let wc = p.m[2][0] * c[0] + p.m[2][1] * c[1] + p.m[2][3];
Proj { near: (wc.abs() * 0.02).max(1e-9), ..p }
}
/// The comp's first light as the effect sees it (layer pixels), from [`crate::EffectHost::comp_scene`].
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct CompLight {
@ -336,51 +298,19 @@ pub struct CompLight {
/// The comp's camera and lights relative to the effect's layer.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct CompScene {
/// Effect-space pixels (x, y, z) → effect-space pixels, homogeneous rows X, Y, W: the comp
/// camera's view of the layer brought back into the layer's own pixel grid (identity on the
/// layer plane for the default camera).
/// Layer pixels (x, y, z) → layer pixels, homogeneous rows X, Y, W: the comp camera's view
/// of the layer brought back into the layer's own pixel grid (identity on the layer plane
/// for the default camera).
pub camera: Option<[[f64; 4]; 3]>,
/// `camera` is an active camera layer's, not the comp's default view (CC Particle World
/// follows only a camera layer).
pub camera_layer: bool,
pub light: Option<CompLight>,
}
impl CompScene {
/// The scene measured from `origin` (in its current coordinates, as [`crate::Buf::rebase`]):
/// a point `p` becomes `p − origin`.
pub fn rebased(mut self, origin: [f64; 2]) -> CompScene {
if let Some(c) = &mut self.camera {
for row in c.iter_mut() {
row[3] += row[0] * origin[0] + row[1] * origin[1];
}
let w = c[2];
for (row, o) in c.iter_mut().zip(origin) {
for (v, wv) in row.iter_mut().zip(w) {
*v -= o * wv;
}
}
}
if let Some(l) = &mut self.light {
l.pos[0] -= origin[0];
l.pos[1] -= origin[1];
}
self
}
}
fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
}
fn len(a: [f64; 3]) -> f64 {
dot(a, a).sqrt()
}
fn norm(a: [f64; 3]) -> [f64; 3] {
let l = dot(a, a).sqrt();
if l < 1e-12 { [0.0, 0.0, -1.0] } else { [a[0] / l, a[1] / l, a[2] / l] }

View File

@ -86,7 +86,6 @@ const CPU_ONLY_CONTROLS: &[(&str, &[&str])] = &[
("ec.blur.cameralens", &["irisProperties/diffractionFringe", "blurMap/blurMapLayer"]),
("ec.noise.removegrain", &["temporalFiltering/enabled"]),
("ec.distort.liquify", &["viewFreezeAreaMask", "viewMesh"]),
("ec.distort.ccpowerpin", &["unstretch"]),
("ec.matte.refinesoft", &["reduceChatter", "useMotionBlur", "invert"]),
("ec.matte.refinehard", &["reduceChatter", "useMotionBlur", "invert"]),
];
@ -174,8 +173,7 @@ mod tests {
std::fs::write(path, &want).unwrap();
return;
}
// A Windows checkout may use CRLF; catalog content must still match exactly.
let have = std::fs::read_to_string(path).unwrap_or_default().replace("\r\n", "\n");
let have = std::fs::read_to_string(path).unwrap_or_default();
assert!(have == want, "docs/effects.md is stale: UPDATE_DOCS=1 cargo test -p effectcraft-effects --lib effects_doc_is_current");
}
@ -202,8 +200,6 @@ mod tests {
assert!(ok("ec.generate.cellpattern", &[("cellPattern", Value::Enum(3))]));
assert!(!ok("ec.generate.cellpattern", &[("cellPattern", Value::Enum(9))]));
assert!(!ok("ec.distort.turbulentdisplace", &[("pinning", Value::Enum(9))]));
assert!(ok("ec.distort.ccpowerpin", &[]));
assert!(!ok("ec.distort.ccpowerpin", &[("unstretch", Value::Bool(true))]));
assert!(ok("ec.blur.gaussian", &[]));
}

View File

@ -171,7 +171,8 @@ const CC_COMPOSITE_MODES: [&str; 16] = [
"Stencil Alpha",
];
/// Composites the original stack input over/under/with the result of preceding effects.
/// Composites the original over/under/with the effect result. The original is the image as it
/// enters this effect (the host does not expose the pre-effect source).
fn cc_composite(ctx: &EffectCtx, mut b: Buf) -> Buf {
let op = (ctx.params.f("opacity") as f32 / 100.0).clamp(0.0, 1.0);
let m = ctx.params.e("compositeOriginal");
@ -193,10 +194,9 @@ fn cc_composite(ctx: &EffectCtx, mut b: Buf) -> Buf {
15 => BlendMode::StencilAlpha,
_ => BlendMode::Normal,
};
let original = ctx.env.host.and_then(|h| h.original()).map(|o| crate::util::fit_buffer(o, b.img.width, b.img.height, b.offset, b.scale));
b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| {
b.img.data.par_iter_mut().for_each(|px| {
let result = *px;
let orig = original.as_ref().and_then(|o| o.data.get(i)).copied().unwrap_or(result).map(|v| v * op);
let orig = result.map(|v| v * op);
let mut o = if m == 1 { blend_pixel(BlendMode::Normal, orig, result, 0.5) } else { blend_pixel(mode, result, orig, 0.5) };
o[3] = o[3].clamp(0.0, 1.0);
if rgb_only {
@ -446,53 +446,3 @@ pub(crate) mod tests {
assert!((sc.img.get(4, 3)[0] - 0.75).abs() < 1e-5);
}
}
#[cfg(test)]
mod original_tests {
use super::*;
use crate::{EffectEnv, EffectHost, Image, LayerPixels};
struct Original(Buf);
impl EffectHost for Original {
fn original(&self) -> Option<&Buf> {
Some(&self.0)
}
fn layer(&self, _: u64, _: bool) -> Option<LayerPixels> {
None
}
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
None
}
}
#[test]
fn composite_preserves_alpha_opacity_and_padded_alignment() {
let spec = crate::find("ec.channel.cccomposite").unwrap();
let mut params = crate::Params { values: spec.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
let host = Original(Buf { img: Image::filled(1, 1, [0.5, 0.0, 0.0, 0.5]), offset: [0.0; 2], scale: 1.0 });
for (mode, opacity, rgb, want) in [
(0, 100.0, false, [0.5, 0.0, 0.125, 0.625]),
(1, 100.0, false, [0.375, 0.0, 0.25, 0.625]),
(0, 50.0, false, [0.25, 0.0, 0.1875, 0.4375]),
(0, 100.0, true, [0.2, 0.0, 0.05, 0.25]),
] {
params.values.insert("compositeOriginal".into(), Value::Enum(mode));
params.values.insert("opacity".into(), num(opacity));
params.values.insert("rgbOnly".into(), Value::Bool(rgb));
let ctx = EffectCtx {
params: &params,
time: 0.0,
layer_size: [1.0; 2],
seed: 0,
adjustment: false,
env: EffectEnv { host: Some(&host), ..Default::default() },
};
let mut input = Buf { img: Image::filled(1, 1, [0.0, 0.0, 0.25, 0.25]), offset: [0.0; 2], scale: 1.0 };
input.pad(1);
let out = cc_composite(&ctx, input);
for (a, b) in out.img.get(1, 1).iter().zip(want) {
assert!((a - b).abs() < 1e-6, "{:?} vs {want:?}", out.img.get(1, 1));
}
assert_eq!(out.img.get(0, 0), [0.0; 4]);
assert_eq!(out.offset, [1.0; 2]);
}
}
}

View File

@ -323,40 +323,11 @@ fn wild(pat: &str, name: &str) -> bool {
}
}
/// Split a selection at its commas. A name in double or single quotes stays whole, commas
/// included (Blender allows `Green, Glossy`), and a backslash takes the next character
/// literally (`Green\, Glossy`, `\"`). Unquoted text is trimmed; the quotes are dropped.
fn selection_names(sel: &str) -> Vec<String> {
let mut out = vec![];
let mut cur = String::new();
let mut quote: Option<char> = None;
let mut chars = sel.chars();
while let Some(c) = chars.next() {
match (c, quote) {
('\\', _) => {
if let Some(next) = chars.next() {
cur.push(next);
}
}
(c, Some(q)) if c == q => quote = None,
('"' | '\'', None) if cur.trim().is_empty() => {
cur.clear();
quote = Some(c);
}
(',', None) => out.push(std::mem::take(&mut cur).trim().to_string()),
(c, _) => cur.push(c),
}
}
out.push(cur.trim().to_string());
out
}
/// Parse a selection: comma-separated names (optionally quoted, see [`selection_names`]) or
/// `<hex>` hashes / wildcards.
/// Parse a selection: comma-separated names (optionally quoted) or `<hex>` hashes / wildcards.
pub fn selection_hashes(sel: &str, manifest: Option<&[(String, u32)]>) -> Vec<u32> {
let mut out = vec![];
for t in selection_names(sel) {
let t = t.as_str();
for tok in sel.split(',') {
let t = tok.trim().trim_matches('"').trim_matches('\'');
if t.is_empty() {
continue;
}
@ -706,25 +677,6 @@ mod tests {
assert_eq!(selection_hashes(&hex, None), vec![crypto_hash("left")]);
}
/// #473: a name with a comma in it (Blender allows `Green, Glossy`) is selected when it is
/// quoted or its comma escaped, instead of being cut into two names that match nothing.
#[test]
fn cryptomatte_selection_keeps_quoted_names_with_commas_whole() {
let manifest = vec![("Green, Glossy".to_string(), 7), ("Green".to_string(), 1), ("Glossy".to_string(), 2), ("ring".to_string(), 3)];
let m = Some(manifest.as_slice());
assert_eq!(selection_hashes(r#""Green, Glossy""#, m), vec![7]);
assert_eq!(selection_hashes("'Green, Glossy', ring", m), vec![7, 3]);
assert_eq!(selection_hashes(r"Green\, Glossy", m), vec![7]);
assert_eq!(selection_hashes(r#"ring , "Green, Glossy""#, m), vec![3, 7]);
// Without a manifest the whole name is hashed.
assert_eq!(selection_hashes(r#""Green, Glossy""#, None), vec![crypto_hash("Green, Glossy")]);
// Plain lists, apostrophes inside names and an unclosed quote still parse.
assert_eq!(selection_hashes("Green, Glossy", m), vec![1, 2]);
assert_eq!(selection_names("Bob's mat, ring"), vec!["Bob's mat", "ring"]);
assert_eq!(selection_names(r#""Green, Glossy"#), vec!["Green, Glossy"]);
assert_eq!(selection_names(r#"say \"hi\""#), vec![r#"say "hi""#]);
}
#[test]
fn fog_and_depth_of_field_follow_depth() {
let h = scene();

View File

@ -43,27 +43,16 @@ fn tritone(ctx: &EffectCtx, mut b: Buf) -> Buf {
b
}
/// Brightness & Contrast without Use Legacy: pin black and white, then a mild S-curve.
///
/// Fitted to After Effects non-legacy grey ramps (#681, kasper9n oracles). Brightness is a
/// power toward white (`1-(1-v)^e`) or black (`v^e`); contrast uses `g = 1 + 0.53 c` instead of
/// the old `2^(2c)` (that crushed 0.25 to ~8/255 at Contrast 100; AE is ~40/255). Values above 1
/// pass through. The GPU kernel mirrors it.
/// Brightness & Contrast without Use Legacy: tone curves that keep black and white fixed (no
/// clipping). Brightness bends the curve with a power (`b` in -1.5..1.5), contrast is an S
/// curve around mid grey (`c` in -1..1). Values above 1 pass through. The GPU kernel mirrors it.
pub fn bc_modern(v: f32, b: f32, c: f32) -> f32 {
if v >= 1.0 {
return v;
}
let mut v = v.max(0.0);
if b > 0.0 {
v = 1.0 - (1.0 - v).powf(2f32.powf(1.24 * b));
} else if b < 0.0 {
v = v.powf(2f32.powf(-1.32 * b));
}
if c != 0.0 {
let g = (1.0 + 0.53 * c).max(0.05);
v = if v < 0.5 { 0.5 * (2.0 * v).powf(g) } else { 1.0 - 0.5 * (2.0 - 2.0 * v).max(0.0).powf(g) };
}
v
let v = v.max(0.0).powf(2f32.powf(-b));
let g = 2f32.powf(2.0 * c);
if v < 0.5 { 0.5 * (2.0 * v).powf(g) } else { 1.0 - 0.5 * (2.0 - 2.0 * v).max(0.0).powf(g) }
}
fn brightness_contrast(ctx: &EffectCtx, mut b: Buf) -> Buf {
@ -1192,34 +1181,6 @@ mod tests {
assert!((bc(&legacy, 0.0) - 0.5).abs() < 1e-5);
}
/// After Effects non-legacy ramps (kasper9n, #681). B0 C-100 matched B0 C-50 and is omitted.
#[test]
fn brightness_contrast_modern_matches_ae_ramp_samples() {
let u8 = |v: f32| (v * 255.0).round() as i32;
let go = |b: f64, c: f64, x: u8| {
u8(px("ec.color.brightnesscontrast", &[("brightness", num(b)), ("contrast", num(c))], [x as f32 / 255.0, x as f32 / 255.0, x as f32 / 255.0, 1.0])
[0])
};
// (brightness, contrast, input, AE 8-bit)
let samples: &[(f64, f64, u8, u8)] = &[
(0.0, 100.0, 64, 40),
(0.0, 100.0, 128, 129),
(0.0, 100.0, 192, 217),
(0.0, 50.0, 64, 53),
(0.0, 50.0, 192, 205),
(0.0, -50.0, 64, 77),
(100.0, 0.0, 128, 209),
(50.0, 0.0, 128, 171),
(106.0, -18.0, 128, 214),
];
for &(b, c, x, ae) in samples {
let got = go(b, c, x);
assert!((got - ae as i32).abs() <= 10, "B{b} C{c} in {x}: got {got} AE {ae}");
}
// Old g=4 S-curve put Contrast 100 at ~8/255 here.
assert!(go(0.0, 100.0, 64) >= 30);
}
#[test]
fn levels_clips_black_and_white_independently() {
let lv = |vals: &[(&str, Value)], v: f32| px("ec.color.levels", vals, [v, v, v, 1.0])[0];

View File

@ -6,7 +6,6 @@ use effectcraft_project::ParamUi;
use effectcraft_raster::Image;
use rayon::prelude::*;
use crate::util::layer_rect;
use crate::{Buf, EffectCtx, EffectSpec, num, p, popup, slider};
fn spec(id: &'static str, name: &'static str, params: Vec<crate::ParamSpec>, render: crate::RenderFn) -> EffectSpec {
@ -68,7 +67,8 @@ fn bulge(ctx: &EffectCtx, mut b: Buf) -> Buf {
// Taper Radius: higher values make the sides of the bulge shallower (a more peaked profile).
let taper = 1.0 + ctx.params.f("taperRadius").max(0.0) / 100.0 * 3.0;
let pin = ctx.params.b("pinAllEdges");
let (lx, ly, lw, lh) = layer_rect(ctx, &b);
let (lx, ly) = (b.offset[0], b.offset[1]);
let (lw, lh) = (b.layer_w(ctx) * b.scale, b.layer_h(ctx) * b.scale);
let edge = (lw.min(lh) * 0.1).max(1.0);
b.img = remap(&b.img, false, |x, y| {
let (dx, dy) = ((x - c.0) / rx, (y - c.1) / ry);
@ -168,8 +168,7 @@ fn wave_warp(ctx: &EffectCtx, mut b: Buf) -> Buf {
if !ctx.adjustment {
b.pad(h.abs().ceil() as u32 + 1);
}
let (x0, y0, lw, lh) = layer_rect(ctx, &b);
let rect = (x0, y0, lw.max(1.0), lh.max(1.0));
let rect = (b.offset[0], b.offset[1], (b.layer_w(ctx) * b.scale).max(1.0), (b.layer_h(ctx) * b.scale).max(1.0));
b.img = remap(&b.img, false, |x, y| {
// Waves travel along `dir`; displacement is perpendicular to it.
let along = x * dx + y * dy;

View File

@ -20,7 +20,7 @@ use effectcraft_raster::{Image, Px};
use rayon::prelude::*;
use crate::generate::value_noise;
use crate::util::{SRC_NAMES, Src, fit_layer, layer_cover_pad, layer_rect, lerp4, map_xy, pick, premul, remap, smoothstep, src_at, unpremul};
use crate::util::{SRC_NAMES, Src, fit_layer, layer_rect, lerp4, map_xy, pick, premul, remap, smoothstep, src_at, unpremul};
use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider};
fn spec(id: &'static str, name: &'static str, params: Vec<crate::ParamSpec>, render: crate::RenderFn) -> EffectSpec {
@ -678,9 +678,6 @@ fn page_turn(ctx: &EffectCtx, mut b: Buf) -> Buf {
fn cc_tiler(ctx: &EffectCtx, mut b: Buf) -> Buf {
let s = (ctx.params.f("scale") / 100.0).max(0.001);
if !ctx.adjustment {
b.pad(layer_cover_pad(ctx, [b.img.width, b.img.height], b.offset, b.scale));
}
let c = b.to_px(ctx.params.v2("center"));
let blend = (ctx.params.f("blendWithOriginal") / 100.0) as f32;
let (x0, y0, w, h) = layer_rect(ctx, &b);

View File

@ -182,9 +182,8 @@ fn cc_power_pin(ctx: &EffectCtx, mut b: Buf) -> Buf {
let (x0, y0) = (-ex[1] / 100.0 * lw, -ex[0] / 100.0 * lh);
let (x1, y1) = (lw + ex[2] / 100.0 * lw, lh + ex[3] / 100.0 * lh);
let corners_layer = [ctx.params.v2("topLeft"), ctx.params.v2("topRight"), ctx.params.v2("bottomRight"), ctx.params.v2("bottomLeft")];
let unstretch = ctx.params.b("unstretch");
// Grow the buffer to hold the pinned quad (Unstretch writes inside the layer rectangle, so it needs no room).
if !ctx.adjustment && !unstretch {
// Grow the buffer to hold the pinned quad.
if !ctx.adjustment {
let mut need = 0.0f64;
for c in &corners_layer {
let (px, py) = b.to_px(*c);
@ -199,8 +198,7 @@ fn cc_power_pin(ctx: &EffectCtx, mut b: Buf) -> Buf {
vec2(p.0, p.1)
});
let persp = (ctx.params.f("perspective") / 100.0).clamp(0.0, 1.0);
let fwd = Mat3::square_to_quad(q);
let Some(inv) = fwd.inverse() else { return b };
let Some(inv) = Mat3::square_to_quad(q).inverse() else { return b };
let src = b.img.clone();
let (sc, off) = (b.scale, b.offset);
let bil = move |u: f64, v: f64| -> (f64, f64) {
@ -210,17 +208,6 @@ fn cc_power_pin(ctx: &EffectCtx, mut b: Buf) -> Buf {
};
b.img = crate::util::gen_image(src.width, src.height, |x, y| {
let (px, py) = (x as f64 + 0.5, y as f64 + 0.5);
if unstretch {
// Reverse pinning: the layer rectangle is the target, and the pinned quad is the region that fills it.
let u = ((px - off[0]) / sc - x0) / (x1 - x0);
let v = ((py - off[1]) / sc - y0) / (y1 - y0);
if !(-1e-9..=1.0 + 1e-9).contains(&u) || !(-1e-9..=1.0 + 1e-9).contains(&v) {
return [0.0; 4];
}
let m = fwd.apply(vec2(u, v));
let (bx, by) = bil(u, v);
return src.sample_bilinear(bx + (m.x - bx) * persp, by + (m.y - by) * persp);
}
let m = inv.apply(vec2(px, py));
let (mut u, mut v) = (m.x, m.y);
if persp < 1.0 {
@ -330,12 +317,9 @@ fn cc_slant(ctx: &EffectCtx, mut b: Buf) -> Buf {
// ---------------------------------------------------------------- CC Smear
fn cc_smear(ctx: &EffectCtx, mut b: Buf) -> Buf {
let reach = ctx.params.f("reach") / 100.0;
if !ctx.adjustment {
b.pad(cc_smear_pad(ctx, [b.img.width, b.img.height], b.offset, b.scale));
}
let from = b.to_px(ctx.params.v2("from"));
let to = b.to_px(ctx.params.v2("to"));
let reach = ctx.params.f("reach") / 100.0;
let radius = (ctx.params.f("radius") * b.scale).max(0.5);
let (dx, dy) = (to.0 - from.0, to.1 - from.1);
let l2 = dx * dx + dy * dy;
@ -354,21 +338,6 @@ fn cc_smear(ctx: &EffectCtx, mut b: Buf) -> Buf {
b
}
/// The transparent pixels to add on every side of a buffer `size` pixels big (with `offset` and
/// `scale`) for CC Smear: it moves pixels by up to Reach × From→To, and they may move out to the
/// layer bounds. A text or shape layer's buffer ends at its content while its bounds are
/// comp-sized, so the smear was cut off at the content's box (#659).
pub fn cc_smear_pad(ctx: &EffectCtx, size: [u32; 2], offset: [f64; 2], scale: f64) -> u32 {
let reach = ctx.params.f("reach") / 100.0;
let (from, to) = (ctx.params.v2("from"), ctx.params.v2("to"));
let len = ((to[0] - from[0]).powi(2) + (to[1] - from[1]).powi(2)).sqrt() * scale;
let moved = (reach.abs() * len).ceil();
if !moved.is_finite() || moved <= 0.0 {
return 0;
}
(moved.min(4096.0) as u32).min(crate::util::layer_cover_pad(ctx, size, offset, scale))
}
// ---------------------------------------------------------------- CC Split / CC Split 2
fn split_impl(ctx: &EffectCtx, mut b: Buf, s1: f64, s2: f64) -> Buf {
@ -1127,43 +1096,6 @@ mod tests {
}
}
/// A text layer's buffer: 20 × 10 opaque pixels at (90, 45)–(110, 55) of its comp-sized
/// (200 × 100) layer bounds, after `id` with `set`.
fn on_text(id: &str, set: &[(&str, Value)]) -> Buf {
let s = crate::find(id).unwrap();
let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
for (k, v) in set {
assert!(params.values.contains_key(*k), "{id}: {k}");
params.values.insert(k.to_string(), v.clone());
}
let mut im = Image::new(20, 10);
im.data.iter_mut().for_each(|p| *p = [1.0; 4]);
let ctx = EffectCtx { params: &params, time: 0.0, layer_size: [200.0, 100.0], seed: 1, adjustment: false, env: Default::default() };
crate::apply(s, &ctx, Buf { img: im, offset: [-90.0, -45.0], scale: 1.0 })
}
/// Alpha at layer point `p` (0 outside the buffer).
fn alpha_at(b: &Buf, p: [f64; 2]) -> f32 {
let (x, y) = b.to_px(p);
b.img.get(x.floor() as i64, y.floor() as i64)[3]
}
/// #659: CC Smear and CC Tiler on a text layer reach past the text's box to the layer bounds
/// (comp-sized, as in After Effects), not cut off where the glyphs end.
#[test]
fn effects_on_text_reach_past_its_box() {
let smear = on_text("ec.distort.ccsmear", &[("from", pt(100.0, 50.0)), ("to", pt(140.0, 50.0)), ("reach", num(100.0)), ("radius", num(30.0))]);
assert!(alpha_at(&smear, [130.0, 50.0]) > 0.9, "the smear is cut off at the text's box");
assert!(smear.img.width < 200, "it grows only as far as pixels move");
let tiles = on_text("ec.distort.cctiler", &[("scale", num(50.0)), ("center", pt(100.0, 50.0))]);
assert!(alpha_at(&tiles, [100.0, 50.0]) > 0.9);
assert!(alpha_at(&tiles, [2.0, 50.0]) > 0.9, "tiles fill the layer bounds");
assert!(alpha_at(&tiles, [50.0, 50.0]) < 0.1);
// A footage layer's buffer is its bounds already: no change.
let o = run("ec.distort.ccsmear", &[], img(40, 30), &[]);
assert_eq!((o.img.width, o.img.height, o.offset), (40, 30, [0.0, 0.0]));
}
#[test]
fn every_warp_style_runs_and_bends() {
let im = img(40, 30);
@ -1195,23 +1127,6 @@ mod tests {
assert!((px[0] - 0.5).abs() < 0.1, "{px:?}");
}
#[test]
fn power_pin_unstretch_expands_pinned_region() {
let im = crate::util::gen_image(24, 16, |_, _| [1.0, 0.0, 0.0, 1.0]);
let inset = [("topLeft", pt(6.0, 4.0)), ("topRight", pt(18.0, 4.0)), ("bottomRight", pt(18.0, 12.0)), ("bottomLeft", pt(6.0, 12.0))];
let opaque = |o: &Buf| o.img.data.iter().filter(|p| p[3] > 0.99).count();
let off = run("ec.distort.ccpowerpin", &inset, im.clone(), &[]);
assert_eq!(opaque(&off), 96);
let mut set = inset.to_vec();
set.push(("unstretch", Value::Bool(true)));
let on = run("ec.distort.ccpowerpin", &set, im.clone(), &[]);
assert_eq!(opaque(&on), 24 * 16);
assert!(on.img.get(0, 0)[3] > 0.99);
// Default corners are the identity either way.
let id = run("ec.distort.ccpowerpin", &[("unstretch", Value::Bool(true))], im.clone(), &[]);
assert!(maxd(&id.img, &im) < 1e-4);
}
#[test]
fn split_opens_a_gap() {
let im = img(40, 30);

View File

@ -1,57 +0,0 @@
//! Deterministic rendered-frame comparison helpers for the fidelity corpus.
use effectcraft_raster::Image;
/// How far a rendered frame is from its reference.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FrameScore {
/// Pixels compared (the frames' width × height).
pub compared_pixels: usize,
/// Mean absolute difference over every channel sample.
pub mean_absolute_error: f64,
/// Largest absolute difference of any channel sample.
pub max_error: f32,
}
/// Compare two premultiplied rendered frames. A size mismatch is reported as an error instead of
/// silently producing a misleading score.
pub fn compare_frames(actual: &Image, reference: &Image) -> Result<FrameScore, String> {
if actual.width != reference.width || actual.height != reference.height || actual.data.len() != reference.data.len() {
return Err(format!("frame size mismatch: {}x{} vs {}x{}", actual.width, actual.height, reference.width, reference.height));
}
let mut sum = 0.0f64;
let mut max = 0.0f32;
for (a, b) in actual.data.iter().zip(&reference.data) {
for (&x, &y) in a.iter().zip(b) {
let error = (x - y).abs();
// A NaN would make the mean NaN, which a `mean > tolerance` check reads as a pass.
if !error.is_finite() {
return Err("frame has a non-finite sample".into());
}
sum += f64::from(error);
max = max.max(error);
}
}
let compared_pixels = actual.data.len();
let samples = compared_pixels.saturating_mul(4).max(1);
Ok(FrameScore { compared_pixels, mean_absolute_error: sum / samples as f64, max_error: max })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reports_pixel_error_and_rejects_size_mismatch() {
let mut actual = Image::filled(2, 1, [0.0, 0.0, 0.0, 1.0]);
let reference = Image::filled(2, 1, [0.0, 0.0, 0.0, 1.0]);
actual.data[1][0] = 0.5;
let score = compare_frames(&actual, &reference).expect("same size");
assert_eq!(score.compared_pixels, 2);
assert!((score.mean_absolute_error - 0.0625).abs() < f64::EPSILON);
assert_eq!(score.max_error, 0.5);
assert!(compare_frames(&actual, &Image::new(1, 1)).is_err());
actual.data[0][1] = f32::NAN;
assert!(compare_frames(&actual, &reference).is_err(), "NaN is an error, not a pass");
}
}

View File

@ -262,7 +262,7 @@ fn circle(ctx: &EffectCtx, mut b: Buf) -> Buf {
// ---- value noise / fBm (our own lattice noise with quintic fade) ----
#[inline]
pub(crate) fn lattice(ix: i32, iy: i32, iz: i32, seed: u32) -> f32 {
fn lattice(ix: i32, iy: i32, iz: i32, seed: u32) -> f32 {
let mut h =
(ix as u32).wrapping_mul(0x27d4_eb2d) ^ (iy as u32).wrapping_mul(0x1656_67b1) ^ (iz as u32).wrapping_mul(0x9e37_79b9) ^ seed.wrapping_mul(0x85eb_ca6b);
h ^= h >> 15;
@ -274,7 +274,7 @@ pub(crate) fn lattice(ix: i32, iy: i32, iz: i32, seed: u32) -> f32 {
}
#[inline]
pub(crate) fn fade(t: f32) -> f32 {
fn fade(t: f32) -> f32 {
t * t * t * (t * (t * 6.0 - 15.0) + 10.0)
}

View File

@ -15,7 +15,7 @@ use effectcraft_raster::{Image, Px};
use rayon::prelude::*;
use crate::util::{Plane, gauss_plane, hash1, layer_or_self, morph_frac, poly_length, premul, smoothstep, unpremul};
use crate::{Buf, EffectCtx, EffectSpec, Params, col, num, p, popup, slider};
use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider};
fn spec(id: &'static str, name: &'static str, params: Vec<crate::ParamSpec>, render: crate::RenderFn) -> EffectSpec {
EffectSpec { id, name, category: "Generate", params, render, gpu: false, float: true }
@ -503,8 +503,10 @@ fn scan_runs(polys: &[(Vec<[f64; 2]>, bool, bool)], fill: u32, ew: f64, d: [f64;
let entering = k % 2 == 0;
let l = ew / sin;
match mode {
0 if entering && k + 1 < xs.len() => {
out.push((t, xs[k + 1].0));
0 => {
if entering && k + 1 < xs.len() {
out.push((t, xs[k + 1].0));
}
}
1 => out.push((t - l * 0.5, t + l * 0.5)),
2 => out.push(if entering { (t, t + l) } else { (t - l, t) }),
@ -1072,66 +1074,23 @@ fn vegas_plan(ctx: &EffectCtx, b: &Buf) -> PaintPlan {
// ---------------------------------------------------------------- Write-on / Glue Gun
/// Write-on draws a stroke along the animated Brush Position, as in After Effects: a dab every
/// Brush Spacing seconds from the layer's start up to now, each one lasting Stroke Length
/// seconds (0: for ever). The dabs' positions come from the host's parameters at their times;
/// with Paint Time Properties ▸ Opacity or Brush Time Properties ▸ Size each dab keeps its own
/// opacity or size, else the current ones apply to every dab. (Colour and hardness are one per
/// stroke: the current ones.) Without a host (or dabs) the current dab draws. Deterministic.
/// Brush dab history: only the brush position at the current time is known to an effect
/// (parameter histories are not passed in), so the stroke is drawn as the dabs laid along
/// the current position — a single dab when the brush is static. Deterministic.
fn write_on(ctx: &EffectCtx, mut b: Buf) -> Buf {
let plan = write_on_plan(ctx, &b);
draw_paint(&mut b, &plan);
b
}
/// The most dabs one Write-on frame draws; longer strokes lay them further apart, joined.
const WRITE_ON_MAX_DABS: i64 = 4096;
fn write_on_plan(ctx: &EffectCtx, b: &Buf) -> PaintPlan {
let pr = ctx.params;
let own_opacity = pr.e("paintTimeProperties") == 1;
let own_size = matches!(pr.e("brushTimeProperties"), 1 | 3);
let opacity = |p: &Params| (p.f("brushOpacity") / 100.0).clamp(0.0, 1.0) as f32;
let radius = |p: &Params| (p.f("brushSize") * b.scale * 0.5).max(0.05);
let dab = |p: &Params| {
let q = b.to_px(p.v2("brushPosition"));
([q.0, q.1], radius(if own_size { p } else { pr }), if own_opacity { opacity(p) } else { 1.0 })
};
let mut segs = vec![];
let t = ctx.time;
let spacing = pr.f("brushSpacing").max(0.001);
let life = pr.f("strokeLength").max(0.0);
if let Some(host) = ctx.env.host
&& t.is_finite()
&& t >= 0.0
{
let start = if life > 0.0 { (t - life).max(0.0) } else { 0.0 };
let k0 = (start / spacing - 1e-9).ceil().max(0.0) as i64;
let k1 = (t / spacing + 1e-9).floor().max(0.0) as i64;
let n = k1.saturating_sub(k0).saturating_add(1);
let stride = (n / WRITE_ON_MAX_DABS).saturating_add(1).max(1);
let mut prev: Option<[f64; 2]> = None;
let mut k = k0;
while k <= k1 {
let tk = k as f64 * spacing;
let at = if (tk - t).abs() < 1e-9 { Some(dab(pr)) } else { host.params_at(tk).map(|p| dab(&p)) };
if let Some((q, r, v)) = at {
// Dabs laid further apart than the brush spacing are joined into a line.
let a = if stride > 1 { prev.unwrap_or(q) } else { q };
segs.push(Seg { a, b: q, r, v });
prev = Some(q);
}
k = k.saturating_add(stride);
}
}
if segs.is_empty() {
let (q, r, v) = dab(pr);
segs.push(Seg { a: q, b: q, r, v });
}
let q = b.to_px(pr.v2("brushPosition"));
let r = (pr.f("brushSize") * b.scale * 0.5).max(0.05);
PaintPlan {
cov: Coverage::Segs { segs, hardness: pr.f("brushHardness") / 100.0 },
cov: Coverage::Segs { segs: vec![Seg { a: [q.0, q.1], b: [q.0, q.1], r, v: 1.0 }], hardness: pr.f("brushHardness") / 100.0 },
color: pr.color("color"),
opacity: if own_opacity { 1.0 } else { opacity(pr) },
opacity: (pr.f("brushOpacity") / 100.0) as f32,
style: pr.e("paintStyle"),
}
}
@ -1643,55 +1602,26 @@ fn audio_waveform(ctx: &EffectCtx, mut b: Buf) -> Buf {
b
}
fn compute_fft_mags(samples: &[f64]) -> (usize, f64, Vec<f64>) {
let m = samples.len();
let n = m.next_power_of_two().max(64);
let mut re = vec![0.0; n];
let mut im = vec![0.0; n];
let denom = (m.max(2) - 1) as f64;
for (i, s) in samples.iter().enumerate() {
let w = 0.5 - 0.5 * (2.0 * PI * i as f64 / denom).cos();
if let Some(r) = re.get_mut(i) {
*r = s * w;
}
}
fft(&mut re, &mut im);
let hz_per_bin = AUDIO_RATE as f64 / n as f64;
let scale = 4.0 / m.max(1) as f64;
let num_bins = n / 2 + 1;
let mut mags = Vec::with_capacity(num_bins);
for k in 0..num_bins {
let r = re.get(k).copied().unwrap_or(0.0);
let im_val = im.get(k).copied().unwrap_or(0.0);
let mag = (r * r + im_val * im_val).sqrt() * scale;
mags.push(mag);
}
(n, hz_per_bin, mags)
}
fn spectrum_plan(ctx: &EffectCtx, b: &Buf) -> Option<MarksPlan> {
let pr = ctx.params;
let samples = audio_window(ctx, pr.f("audioDuration"), pr.f("audioOffset"), 0)?;
let (_n, hz_per_bin, mags) = compute_fft_mags(&samples);
let n = samples.len().next_power_of_two().max(64);
let mut re = vec![0.0; n];
let mut im = vec![0.0; n];
let m = samples.len();
for (i, s) in samples.iter().enumerate() {
// Hann window.
re[i] = s * (0.5 - 0.5 * (2.0 * PI * i as f64 / (m.max(2) - 1) as f64).cos());
}
fft(&mut re, &mut im);
let bands = pr.f("frequencyBands").round().clamp(1.0, 4096.0) as usize;
let f0 = pr.f("startFrequency").max(0.0);
let f0 = pr.f("startFrequency").max(1.0);
let f1 = pr.f("endFrequency").max(f0 + 1.0);
let max_bin = mags.len().saturating_sub(1);
let get_band_mag = |fc: f64| -> f64 {
if mags.is_empty() || hz_per_bin <= 0.0 {
return 0.0;
}
let k_float = (fc / hz_per_bin).clamp(0.0, max_bin as f64);
let k0 = (k_float.floor() as usize).min(max_bin.saturating_sub(1));
let k1 = (k0 + 1).min(max_bin);
let frac = (k_float - k0 as f64).clamp(0.0, 1.0);
let m0 = mags.get(k0).copied().unwrap_or(0.0);
let m1 = mags.get(k1).copied().unwrap_or(0.0);
m0 * (1.0 - frac) + m1 * frac
let hz_per_bin = AUDIO_RATE as f64 / n as f64;
let mag = |f: f64| {
let k = ((f / hz_per_bin).round() as usize).min(n / 2);
(re[k] * re[k] + im[k] * im[k]).sqrt() * 4.0 / m as f64
};
let max_h = pr.f("maximumHeight") * b.scale;
let thick = (pr.f("thickness") * b.scale).max(0.1);
let soft = pr.f("softness") / 100.0;
@ -1702,9 +1632,15 @@ fn spectrum_plan(ctx: &EffectCtx, b: &Buf) -> Option<MarksPlan> {
let mut segs = Vec::new();
for i in 0..bands {
let u = if bands > 1 { i as f64 / (bands - 1) as f64 } else { 0.5 };
let fc = f0 + (f1 - f0) * u;
let v = get_band_mag(fc);
let hgt = if (v * max_h).is_finite() { (v * max_h).clamp(0.0, max_h.max(0.0)) } else { 0.0 };
let fa = f0 * (f1 / f0).powf(i as f64 / bands as f64);
let fb = f0 * (f1 / f0).powf((i + 1) as f64 / bands as f64);
let mut v = 0.0f64;
let k = 4;
for j in 0..k {
v = v.max(mag(fa + (fb - fa) * j as f64 / k as f64));
}
let db = (20.0 * v.max(1e-9).log10() + 60.0) / 60.0;
let hgt = db.clamp(0.0, 1.0) * max_h;
let (q, nrm) = base.at(u);
let (ua, ub) = match side {
0 => (0.0, hgt),
@ -2079,37 +2015,6 @@ mod tests {
s
}
#[test]
fn audio_spectrum_linear_frequency_and_linear_amplitude() {
let host = Tone; // Tone produces 440 Hz
let env = EffectEnv { host: Some(&host), comp_time: 1.0, ..Default::default() };
let img = Image::new(200, 100);
let out = run_env(
"ec.generate.audiospectrum",
&[
("audioLayer", Value::Layer(Some(1))),
("startPoint", Value::Vec2([0.0, 50.0])),
("endPoint", Value::Vec2([200.0, 50.0])),
("startFrequency", num(20.0)),
("endFrequency", num(2000.0)),
("frequencyBands", num(100.0)),
("maximumHeight", num(40.0)),
("thickness", num(2.0)),
("softness", num(0.0)),
],
img,
1.0,
env,
);
// Verify 440 Hz tone produces a peak above baseline, while high frequencies have zero bar height
let peak_zone = alpha_sum(&out.img, 36, 10, 48, 48);
let high_zone = alpha_sum(&out.img, 120, 10, 150, 48);
let silent_zone = alpha_sum(&out.img, 170, 10, 195, 48);
assert!(peak_zone > 5.0, "peak zone has energy: {peak_zone}");
assert!(peak_zone > high_zone * 2.0, "linear peak is distinctly taller than far frequencies: {peak_zone} vs {high_zone}");
assert_eq!(silent_zone, 0.0, "silent end has zero height, not boosted by dB scale");
}
#[test]
fn fractal_deterministic_and_has_inside_set() {
let img = Image::new(64, 48);
@ -2245,75 +2150,6 @@ mod tests {
assert_eq!(g.img.get(0, 0), src.get(0, 0));
}
/// #536: an animated Brush Position draws a stroke that stays (Stroke Length 0), expires
/// after Stroke Length, and lays its dabs Brush Spacing seconds apart.
#[test]
fn write_on_accumulates_a_stroke_along_the_animated_brush() {
// Brush Position from [8, 16] at 0 s to [56, 16] at 1 s; red, hard, 6 px.
struct Brush(Vec<(&'static str, Value)>);
impl Brush {
fn at(&self, t: f64) -> Vec<(&'static str, Value)> {
let mut v = self.0.clone();
v.push(("brushPosition", Value::Vec2([8.0 + 48.0 * t.clamp(0.0, 1.0), 16.0])));
v
}
}
impl EffectHost for Brush {
fn layer(&self, _: u64, _: bool) -> Option<LayerPixels> {
None
}
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
None
}
fn params_at(&self, t: f64) -> Option<Params> {
let s = crate::find("ec.generate.writeon").unwrap();
let mut p = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
for (k, v) in self.at(t) {
p.values.insert(k.to_string(), v);
}
Some(p)
}
}
let render = |extra: &[(&'static str, Value)], t: f64| {
let mut set = vec![("color", col(1.0, 0.0, 0.0)), ("brushSize", num(6.0)), ("brushHardness", num(100.0)), ("brushSpacing", num(0.05))];
set.extend(extra.iter().cloned());
let host = Brush(set);
let env = EffectEnv { host: Some(&host), ..Default::default() };
let at = host.at(t);
run_env("ec.generate.writeon", &at, Image::new(64, 32), t, env).img
};
let on = |img: &Image, x: i64| img.get(x, 16)[3] > 0.9;
let half = render(&[], 0.5);
assert!(on(&half, 8) && on(&half, 20) && on(&half, 32) && !on(&half, 44) && !on(&half, 56));
let end = render(&[], 1.0);
assert!([8, 20, 32, 44, 56].iter().all(|x| on(&end, *x)));
// Stroke Length 0.25 s: only the last quarter second's trail.
let trail = render(&[("strokeLength", num(0.25))], 1.0);
assert!(!on(&trail, 8) && !on(&trail, 32) && on(&trail, 48) && on(&trail, 56));
// Brush Spacing 0.5 s: three separate marks.
let spaced = render(&[("brushSpacing", num(0.5))], 1.0);
assert!(on(&spaced, 8) && on(&spaced, 32) && on(&spaced, 56) && !on(&spaced, 20) && !on(&spaced, 44));
// Paint Time Properties ▸ Opacity: each dab keeps the opacity it was laid with.
struct Fade;
impl EffectHost for Fade {
fn layer(&self, _: u64, _: bool) -> Option<LayerPixels> {
None
}
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
None
}
fn params_at(&self, t: f64) -> Option<Params> {
let mut p = Brush(vec![("brushHardness", num(100.0)), ("brushSpacing", num(0.05)), ("paintTimeProperties", Value::Enum(1))]).params_at(t)?;
p.values.insert("brushOpacity".into(), num(if t < 0.5 { 100.0 } else { 20.0 }));
Some(p)
}
}
let p1 = Fade.params_at(1.0).unwrap();
let set: Vec<(&str, Value)> = p1.values.iter().map(|(k, v)| (k.as_str(), v.clone())).collect();
let faded = run_env("ec.generate.writeon", &set, Image::new(64, 32), 1.0, EffectEnv { host: Some(&Fade), ..Default::default() }).img;
assert!(faded.get(8, 16)[3] > 0.9 && (faded.get(56, 16)[3] - 0.2).abs() < 0.05, "{:?} {:?}", faded.get(8, 16), faded.get(56, 16));
}
#[test]
fn paint_bucket_fills_enclosed_region_only() {
let mut img = Image::filled(32, 32, [1.0, 1.0, 1.0, 1.0]);

View File

@ -340,24 +340,6 @@ fn wire_removal(ctx: &EffectCtx, mut b: Buf) -> Buf {
let (nx, ny) = (-ty, tx);
let src = b.img.clone();
let soft_w = half * slope as f64;
// Frame Offset: the same pixels from the previous or next frame, resampled into this grid.
let offset = ctx.params.f("frameOffset").round();
let neighbour = if style == 1 && offset != 0.0 {
ctx.env.host.and_then(|h| h.self_at(ctx.time + offset / ctx.fps(), ctx.env.effect_index)).map(|nb| {
if nb.img.width == b.img.width && nb.img.height == b.img.height && nb.offset == b.offset && nb.scale == b.scale {
nb.img
} else {
let k = nb.scale / b.scale.max(1e-9);
crate::util::gen_image(b.img.width, b.img.height, |x, y| {
let lx = (x as f64 + 0.5 - b.offset[0]) * k + nb.offset[0];
let ly = (y as f64 + 0.5 - b.offset[1]) * k + nb.offset[1];
nb.img.sample_bilinear(lx, ly)
})
}
})
} else {
None
};
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let (qx, qy) = (x as f64 + 0.5 - ax, y as f64 + 0.5 - ay);
@ -375,9 +357,7 @@ fn wire_removal(ctx: &EffectCtx, mut b: Buf) -> Buf {
let t = ((d + edge) / (2.0 * edge)) as f32;
lerp4(pos(-edge), pos(edge), t.clamp(0.0, 1.0))
}
1 if neighbour.is_some() => neighbour.as_ref().map_or(*px, |n| n.get(x as i64, y as i64)),
// Displace: shift the image across the wire from the nearer side (also Frame Offset
// when no other frame is available).
// Displace: shift the image across the wire from the nearer side.
2 | 1 => {
let s = if d >= 0.0 { d + 2.0 * (half - ad) + 1.0 } else { d - 2.0 * (half - ad) - 1.0 };
let sh = pos(s);
@ -580,49 +560,6 @@ mod tests {
}
}
#[test]
fn wire_removal_frame_offset_uses_neighbour_frame() {
use crate::{EffectHost, LayerPixels};
struct Frames;
impl EffectHost for Frames {
fn layer(&self, _: u64, _: bool) -> Option<LayerPixels> {
None
}
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
None
}
fn self_at(&self, t: f64, _: usize) -> Option<Buf> {
// Frame n (at 10 fps) is a flat grey of n / 10.
let v = (t * 10.0).round() as f32 / 10.0;
Some(Buf { img: Image::filled(20, 20, [v, v, v, 1.0]), offset: [0.0; 2], scale: 1.0 })
}
}
let mut img = Image::filled(20, 20, [0.5, 0.5, 0.5, 1.0]);
for y in 0..20 {
img.set(10, y, [0.0, 0.0, 0.0, 1.0]);
}
let host = Frames;
let run = |style: u32, off: f64| {
let env = EffectEnv { host: Some(&host), frame_rate: 10.0, ..Default::default() };
let vals = [
("pointA", Value::Vec2([10.5, 0.0])),
("pointB", Value::Vec2([10.5, 20.0])),
("removalStyle", Value::Enum(style)),
("thickness", num(3.0)),
("frameOffset", num(off)),
];
run_fx("ec.key.ccsimplewireremoval", &vals, img.clone(), 0.5, env).img
};
// At 0.5 s (frame 5) offset -1 shows frame 4 and +1 shows frame 6 inside the strip.
let (prev, next) = (run(1, -1.0), run(1, 1.0));
assert!((prev.get(10, 10)[0] - 0.4).abs() < 1e-4, "{:?}", prev.get(10, 10));
assert!((next.get(10, 10)[0] - 0.6).abs() < 1e-4, "{:?}", next.get(10, 10));
// Outside the strip the current frame stays.
assert_eq!(prev.get(2, 10), img.get(2, 10));
// Displace ignores Frame Offset.
assert_eq!(run(2, -1.0).data, run(2, 1.0).data);
}
#[test]
fn inner_outer_key_more_masks_and_cleanup_strokes() {
let sq = |x0: f64, x1: f64| MaskShape { name: String::new(), points: vec![[x0, 2.0], [x1, 2.0], [x1, 8.0], [x0, 8.0]], closed: true, inverted: false };

View File

@ -26,7 +26,6 @@ mod distort2;
mod distort3;
pub mod distort4;
pub mod face_track;
pub mod fidelity;
mod generate;
mod generate2;
mod generate3;
@ -78,7 +77,7 @@ pub use color2::{Curve, auto_correct_settings, color_link_mode, color_link_sampl
pub use color3::OffsetCurve;
pub use distort::transform_shutter;
pub use distort2::{MAGNIFY_MODES, bezier_grid, parse_mesh};
pub use distort3::{RESHAPE_POINTS, ReshapeSetup, SmearSetup, cc_smear_pad, reshape_setup, smear_setup, warp_inverse_map};
pub use distort3::{RESHAPE_POINTS, ReshapeSetup, SmearSetup, reshape_setup, smear_setup, warp_inverse_map};
pub use distort4::liquify_mesh;
use effectcraft_keyframe::Value;
use effectcraft_project::build::Ids;
@ -105,7 +104,7 @@ pub use noise::remove_grain_level;
pub use noise3::{FractalGpu, fractal_gpu};
pub use stylize3::{BumpLight, bump_layer_id};
pub use textfx::{TextLayer, text_layer};
pub use time_fx::{PixelMotion, TimewarpPlan, TwStep, pmb_plan, posterized_time, time_frames, timewarp_crop, timewarp_plan};
pub use time_fx::{PixelMotion, TimewarpPlan, TwStep, posterized_time, time_frames, timewarp_crop, timewarp_plan};
// effectcraft-gpu fx_tone.
pub use ocio::color_stabilizer_maps;
// GPU effects, part B (3D Channel, Immersive Video, colour management, simulation render passes).
@ -239,20 +238,7 @@ pub fn flatten_params(g: &PropGroup, eval: &mut dyn FnMut(&Property) -> Value) -
Params { values }
}
/// The warning shown with effect instance `g` (Effect Controls, `effect.warning`), its
/// parameters evaluated by `eval`: settings it can't render as asked, such as an OCIO config it
/// can't read or transforms it passes through. `None` when it renders as asked.
pub fn warning(g: &PropGroup, eval: &mut dyn FnMut(&Property) -> Value) -> Option<String> {
let GroupKind::Effect { effect } = &g.kind else { return None };
// Only the effects that can warn evaluate their parameters.
if !ocio::CUSTOM_CONFIG_FX.contains(&effect.as_str()) {
return None;
}
ocio::warning(effect, &flatten_params(g, eval))
}
/// A layer image in flight: point `p` sits at pixel `p * scale + offset`. Points are in layer
/// space, or in effect space while the layer's effects run (see [`Buf::rebase`]).
/// A layer image in flight: layer-space point `p` sits at pixel `p * scale + offset`.
#[derive(Clone, Debug, Default)]
pub struct Buf {
pub img: Image,
@ -264,27 +250,6 @@ impl Buf {
pub fn to_px(&self, p: [f64; 2]) -> (f64, f64) {
(p[0] * self.scale + self.offset[0], p[1] * self.scale + self.offset[1])
}
/// Grow the buffer so it covers a `size`-layer-pixel region starting at its offset (see
/// [`cover_plan`]): the whole layer rectangle a sized source's buffer already is. Content
/// keeps its place; the new area is transparent. No-op when the buffer already covers it.
pub fn cover(&mut self, size: [f64; 2]) {
let Some((dx, dy, w, h)) = cover_plan(self.offset, self.scale, size, self.img.width, self.img.height) else { return };
let src = std::mem::replace(&mut self.img, Image::new(w, h));
let (sw, sh) = (src.width as usize, src.height as usize);
for y in 0..sh {
let s = y * sw;
let d = (y + dy as usize) * w as usize + dx as usize;
self.img.data[d..d + sw].copy_from_slice(&src.data[s..s + sw]);
}
self.offset[0] += dx as f64;
self.offset[1] += dy as f64;
}
/// Measure from `origin` (in the current coordinates): a point `p` becomes `p − origin`.
/// Moves between layer space and effect space (the renderer's `effect_bounds`).
pub fn rebase(&mut self, origin: [f64; 2]) {
self.offset[0] += origin[0] * self.scale;
self.offset[1] += origin[1] * self.scale;
}
/// Grow by `pad` transparent pixels on every side.
pub fn pad(&mut self, pad: u32) {
if pad == 0 {
@ -296,27 +261,6 @@ impl Buf {
}
}
/// Where [`Buf::cover`] would take a buffer: shift its pixels by `(dx, dy)` and resize it to
/// `width` × `height` so it spans the `size`-layer-pixel region that starts at its offset — the
/// whole layer rectangle, which a sized source's buffer already is. `None` when the buffer
/// already covers that region unchanged (footage, solids, the comp below an adjustment).
pub fn cover_plan(offset: [f64; 2], scale: f64, size: [f64; 2], width: u32, height: u32) -> Option<(u32, u32, u32, u32)> {
if scale <= 0.0 || !scale.is_finite() || !offset[0].is_finite() || !offset[1].is_finite() || !size[0].is_finite() || !size[1].is_finite() {
return None;
}
// Region and content, both as pixel ranges in the buffer's own coordinates.
let (rw, rh) = (offset[0] + (size[0] * scale).ceil(), offset[1] + (size[1] * scale).ceil());
let (lo_x, lo_y) = (offset[0].min(0.0).floor(), offset[1].min(0.0).floor());
let (hi_x, hi_y) = (rw.max(width as f64).ceil(), rh.max(height as f64).ceil());
let (nw_f, nh_f) = (hi_x - lo_x, hi_y - lo_y);
if !(nw_f <= 16384.0 && nh_f <= 16384.0) {
return None;
}
let (dx, dy) = ((-lo_x) as u32, (-lo_y) as u32);
let (nw, nh) = (nw_f.round().max(1.0) as u32, nh_f.round().max(1.0) as u32);
(dx != 0 || dy != 0 || nw != width || nh != height).then_some((dx, dy, nw, nh))
}
/// A layer mask at the current time, flattened to a polyline in layer coordinates.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct MaskShape {
@ -336,14 +280,8 @@ pub struct LayerPixels {
/// Services the renderer offers effects that look beyond their own pixels.
pub trait EffectHost: Sync {
/// Pixels entering the effect stack, in effect space, before any effects. For an
/// adjustment layer this is the composition below it, in the effect working space.
fn original(&self) -> Option<&Buf> {
None
}
/// Layer `id` of the same composition at the current comp time. `masks_and_effects`
/// selects "Effects & Masks" over "Source". `None` for missing/recursive references; own Source reads are supported.
/// selects "Effects & Masks" over "Source". `None` for missing/self/recursive references.
fn layer(&self, id: u64, masks_and_effects: bool) -> Option<LayerPixels>;
/// `frames` stereo sample frames (interleaved L R …) of layer `id`'s audio starting at comp
/// time `start` seconds, at `rate` Hz. `None` when the layer has no audio.
@ -466,10 +404,6 @@ pub(crate) fn p(id: &'static str, name: &'static str, default: Value, ui: ParamU
pub(crate) fn slider(min: f64, max: f64, smin: f64, smax: f64, decimals: u8) -> ParamUi {
ParamUi::Slider { min, max, slider_min: smin, slider_max: smax, decimals }
}
/// A file parameter offering files with these extensions (Effect Controls' Choose…).
pub(crate) fn file_ui(exts: &[&str]) -> ParamUi {
ParamUi::File { filters: exts.iter().map(|s| s.to_string()).collect() }
}
pub(crate) fn popup(opts: &[&str]) -> ParamUi {
ParamUi::Popup { options: opts.iter().map(|s| s.to_string()).collect() }
}
@ -728,7 +662,6 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[
("sourceCrops", "Source Crops"),
// Simulation.
("extras", "Extras"),
("effectCamera", "Effect Camera"),
("wiggle", "Wiggle"),
("light", "Light"),
("shading", "Shading"),
@ -1156,11 +1089,6 @@ pub const GPU_EFFECTS: &[&str] = &[
"ec.obsolete.pathtext",
];
/// Effects that can read [`EffectHost::comp_scene`] (the comp camera or the first comp light):
/// their output changes when the camera, the lights or the layer's own transform move, so the
/// renderer's layer cache folds the comp scene into the key for these.
pub const READS_COMP_SCENE: &[&str] = &["ec.sim.carddance", "ec.sim.shatter", "ec.transition.cardwipe", "ec.sim.caustics", "ec.sim.ccparticleworld"];
/// Effects whose output depends on [`EffectCtx::time`] directly (not only through animated
/// parameters). The renderer's layer cache folds the layer time into the key for these.
/// The `time_dependence_is_declared` test checks the list against every registered effect.
@ -1179,9 +1107,6 @@ pub const TIME_DEPENDENT: &[&str] = &[
"ec.obsolete.lightning",
"ec.text.timecode",
"ec.text.numbers",
// The audio visualisers read the Audio Layer's samples around the frame time.
"ec.generate.audiospectrum",
"ec.generate.audiowaveform",
// Path Text's jitter changes every frame.
"ec.obsolete.pathtext",
// Time effects read neighbouring frames of the layer.

View File

@ -236,9 +236,6 @@ fn refresh(pr: &mut Property, ps: &ParamSpec, spec: &EffectSpec) {
}
// A parameter that became internal (superseded by a new one) is hidden.
(_, ParamUi::Hidden) => pr.ui = ParamUi::Hidden,
// A path typed as text, or set only by commands, that got a Choose… button (Apply Color
// LUT's LUT, the OCIO files, #514).
(ParamUi::Hidden | ParamUi::Text, ParamUi::File { .. }) => pr.ui = ps.ui.clone(),
// Same kind of value: take the spec's hints (units, ranges).
(a, b) if std::mem::discriminant(a) == std::mem::discriminant(b) || is_numeric_ui(a) && is_numeric_ui(b) => pr.ui = ps.ui.clone(),
_ => {}
@ -342,120 +339,6 @@ pub fn upgrade_instance(spec: &EffectSpec, g: &mut PropGroup, ids: &mut Ids, lay
*g != before
}
/// Bring an effect instance saved before project schema 2 into effect space (#227).
///
/// Layers without a source rectangle (shape, text) have comp-sized effect bounds centred on
/// their origin. Effects used to measure positions from the origin, so the default "layer
/// centre" sat at the comp's bottom-right corner; they now measure from the bounds' top-left,
/// as After Effects does. `d` is the bounds' half size: positions the instance holds move by it
/// so they keep their place in the comp (point parameters with their keyframes, Paint stroke
/// paths and clone positions, Puppet mesh seeds and pin rest points, Liquify and Roto Brush
/// strokes). A point parameter still at its default (`layer_size`) and not animated stays: it
/// meant the layer centre and now is. Expression controls keep their values (what they mean is
/// up to the expressions reading them), and expressions are not rewritten.
///
/// `clone_d(source)`: the shift of a Paint clone stroke's source layer (`None` = this layer),
/// whose effect space its Clone Position is in.
pub fn to_effect_space(spec: &EffectSpec, g: &mut PropGroup, layer_size: [f64; 2], d: [f64; 2], clone_d: &dyn Fn(Option<u64>) -> [f64; 2]) {
if spec.id.starts_with("ec.control.") {
return;
}
let defaults: Vec<u64> = spec
.params
.iter()
.filter(|ps| matches!(ps.ui, ParamUi::Point | ParamUi::Point3))
.filter_map(|ps| g.prop(ps.id).filter(|pr| pr.keys.is_empty() && same_point(&pr.value, &default_value(ps, layer_size))).map(|pr| pr.uid))
.collect();
let at = Shift { d, clone_d, defaults: &defaults, effect: spec.id };
at.group(g);
}
/// What [`to_effect_space`] moves, and by how much.
struct Shift<'a> {
d: [f64; 2],
clone_d: &'a dyn Fn(Option<u64>) -> [f64; 2],
/// Point parameters left at their defaults.
defaults: &'a [u64],
effect: &'a str,
}
impl Shift<'_> {
fn group(&self, g: &mut PropGroup) {
// A Paint clone stroke's Clone Position is in its source layer's effect space.
let clone_d = g.get("clone_source").map(|s| (self.clone_d)(s.value.as_layer())).unwrap_or(self.d);
for n in &mut g.children {
match n {
Node::Group(sg) => self.group(sg),
Node::Prop(pr) => {
let d = if pr.match_id == "clone_position" { clone_d } else { self.d };
let kind = match (&pr.ui, pr.match_id.as_str()) {
(ParamUi::Point | ParamUi::Point3, _) if !self.defaults.contains(&pr.uid) => Some(Data::Point),
(ParamUi::Path, _) => Some(Data::Point),
(ParamUi::Hidden, "seed" | "rest") if self.effect == crate::puppet::ID => Some(Data::Point),
(ParamUi::Hidden, "distortionMesh") if self.effect == "ec.distort.liquify" => Some(Data::Liquify),
(ParamUi::Hidden, crate::roto::STROKES) if self.effect == crate::roto::ID => Some(Data::Roto),
_ => None,
};
if let Some(kind) = kind.filter(|_| d != [0.0; 2]) {
shift_value(&mut pr.value, kind, d);
for k in &mut pr.keys {
shift_value(&mut k.value, kind, d);
}
}
}
}
}
}
}
/// How a value holds positions.
#[derive(Clone, Copy)]
enum Data {
/// A point (2D or 3D: x and y move) or a path (its vertices; tangents are relative).
Point,
/// Liquify's Distortion Mesh text (`distort4::LiquifyStroke` lines).
Liquify,
/// Roto Brush's Strokes JSON ([`effectcraft_track::roto::RotoData`]).
Roto,
}
fn shift_value(v: &mut Value, kind: Data, d: [f64; 2]) {
let add = |p: &mut [f64; 2]| *p = [p[0] + d[0], p[1] + d[1]];
match (kind, v) {
(Data::Point, Value::Vec2(p)) => add(p),
(Data::Point, Value::Vec3(p)) => *p = [p[0] + d[0], p[1] + d[1], p[2]],
(Data::Point, Value::Path(sp)) => sp.vertices.iter_mut().for_each(add),
(Data::Liquify, Value::Str(s)) => {
// Lines that don't read as strokes are kept as they are.
let lines: Vec<String> = s
.lines()
.map(|l| match crate::distort4::LiquifyStroke::parse(l) {
Some(mut st) => {
st.points.iter_mut().for_each(add);
st.to_line()
}
None => l.to_string(),
})
.collect();
*s = lines.join("\n");
}
(Data::Roto, Value::Str(s)) => {
// Unreadable data is kept as it is.
if let Ok(mut r) = serde_json::from_str::<effectcraft_track::roto::RotoData>(s) {
r.strokes.iter_mut().flat_map(|st| st.points.iter_mut()).for_each(add);
*s = r.to_json();
}
}
_ => {}
}
}
/// Two point values equal up to float noise.
fn same_point(a: &Value, b: &Value) -> bool {
let (a, b) = (a.components(), b.components());
a.len() == b.len() && a.iter().zip(&b).all(|(x, y)| (x - y).abs() < 1e-6)
}
#[cfg(test)]
mod tests {
use super::*;
@ -497,23 +380,6 @@ mod tests {
assert_eq!(pr.ui, popup.ui);
}
/// #514: Apply Color LUT's LUT was hidden (set only by commands) and the OCIO files were
/// plain text; old projects get the Choose… file parameter, keeping the path.
#[test]
fn hidden_and_text_paths_become_file_parameters() {
for (fx, pid, old) in [("ec.utility.applylut", "lut", ParamUi::Hidden), ("ec.color.ociofile", "file", ParamUi::Text)] {
let mut next = 1;
let (spec, mut g) = instance(fx, &mut next);
let pr = g.get_mut(pid).unwrap();
pr.ui = old;
pr.value = Value::Str("/luts/look.cube".into());
assert!(upgrade_instance(spec, &mut g, &mut Ids(&mut next), [100.0, 100.0]), "{fx}");
let pr = g.get(pid).unwrap();
assert!(matches!(&pr.ui, ParamUi::File { filters } if filters.iter().any(|f| f == "cube")), "{fx}: {:?}", pr.ui);
assert_eq!(pr.value, Value::Str("/luts/look.cube".into()));
}
}
#[test]
fn upgrade_moves_params_into_twirl_down_groups() {
let spec = find(crate::warp_stab::ID).unwrap();
@ -601,122 +467,4 @@ mod tests {
assert!(upgrade_instance(spec, &mut g, &mut Ids(&mut next), [100.0, 50.0]));
assert_eq!(g.get("viewingMode").unwrap().value, Value::Enum(4), "Final Output");
}
/// The shift a 200 × 100 shape layer's effect positions get (#227): half its bounds.
const D: [f64; 2] = [100.0, 50.0];
const SIZE: [f64; 2] = [200.0, 100.0];
fn instance(id: &str, next: &mut u64) -> (&'static EffectSpec, PropGroup) {
let spec = find(id).unwrap();
(spec, instantiate(spec, &mut Ids(next), spec.name, SIZE))
}
fn own(_: Option<u64>) -> [f64; 2] {
D
}
#[test]
fn effect_points_move_into_effect_space_unless_left_at_their_default() {
let mut next = 1;
// Twirl: a set centre (with keyframes) moves; Bulge's default centre stays.
let (spec, mut twirl) = instance("ec.distort.twirl", &mut next);
let c = twirl.get_mut("center").unwrap();
c.value = Value::Vec2([-30.0, -10.0]);
let key = |t: i64, v: Value| {
let mut k = effectcraft_keyframe::Keyframe::new(Default::default(), v);
k.time.0 = t;
k
};
c.keys = vec![key(0, Value::Vec2([0.0, 0.0])), key(1000, Value::Vec2([5.0, -5.0]))];
to_effect_space(spec, &mut twirl, SIZE, D, &own);
let c = twirl.get("center").unwrap();
assert_eq!(c.value, Value::Vec2([70.0, 40.0]));
assert_eq!(c.keys.iter().map(|k| k.value.clone()).collect::<Vec<_>>(), vec![Value::Vec2([100.0, 50.0]), Value::Vec2([105.0, 45.0])]);
let (spec, mut bulge) = instance("ec.distort.bulge", &mut next);
let before = bulge.clone();
to_effect_space(spec, &mut bulge, SIZE, D, &own);
assert_eq!(bulge, before, "the default centre now means the layer centre");
// Expression controls keep their values.
let (spec, mut ctl) = instance("ec.control.point", &mut next);
let pr = ctl.props().find(|p| p.ui == ParamUi::Point).unwrap().uid;
ctl.find_mut(pr).unwrap().value = Value::Vec2([5.0, 5.0]);
to_effect_space(spec, &mut ctl, SIZE, D, &own);
assert_eq!(ctl.find(pr).unwrap().value, Value::Vec2([5.0, 5.0]));
}
#[test]
fn puppet_paint_liquify_and_roto_data_move_into_effect_space() {
let mut next = 1;
// Puppet: mesh seed, pin rest and Position.
let (spec, mut puppet) = instance(crate::puppet::ID, &mut next);
let mut mesh = crate::puppet::mesh_group(&mut Ids(&mut next), "Mesh 1", [1.0, 2.0], &crate::puppet::MeshOpts::default());
let pin = crate::puppet::pin_group(&mut Ids(&mut next), "Puppet Pin 1", crate::puppet::PinKind::Position, [3.0, 4.0]);
mesh.sub_mut("deform").unwrap().children.push(pin.into());
puppet.children.push(mesh.into());
to_effect_space(spec, &mut puppet, SIZE, D, &own);
let mesh = puppet.sub("mesh").unwrap();
assert_eq!(mesh.get("seed").unwrap().value, Value::Vec2([101.0, 52.0]));
let pin = mesh.sub("deform").unwrap().sub("pin").unwrap();
assert_eq!(pin.get("rest").unwrap().value, Value::Vec2([103.0, 54.0]));
assert_eq!(pin.get("position").unwrap().value, Value::Vec2([103.0, 54.0]));
// Paint: the stroke path, its transform, and a clone position in its source layer's space.
let (spec, mut paint) = instance(crate::paint::ID, &mut next);
let s = crate::paint::StrokeSpec {
kind: crate::paint::StrokeKind::Clone,
points: vec![[0.0, 0.0], [10.0, 0.0]],
clone_source: Some(7),
clone_position: [20.0, 20.0],
..Default::default()
};
paint.children.push(crate::paint::stroke_group(&mut Ids(&mut next), "Clone 1", &s).into());
to_effect_space(spec, &mut paint, SIZE, D, &|src| if src == Some(7) { [0.0; 2] } else { D });
let st = paint.sub("clone").unwrap();
let Value::Path(path) = &st.get("path").unwrap().value else { panic!("no path") };
assert_eq!(path.vertices, vec![[100.0, 50.0], [110.0, 50.0]]);
assert_eq!(st.sub("transform").unwrap().get("position").unwrap().value, Value::Vec2([100.0, 50.0]));
assert_eq!(st.sub("stroke_options").unwrap().get("clone_position").unwrap().value, Value::Vec2([20.0, 20.0]), "a footage source");
// Liquify: stroke points move, the clone offset (relative) doesn't; unknown lines stay.
let (spec, mut liquify) = instance("ec.distort.liquify", &mut next);
let line =
crate::distort4::LiquifyStroke { tool: 0, size: 64.0, pressure: 50.0, jitter: 0.0, clone_offset: [3.0, 3.0], points: vec![[1.0, 1.0]] }.to_line();
liquify.get_mut("distortionMesh").unwrap().value = Value::Str(format!(
"{line}
not a stroke"
));
to_effect_space(spec, &mut liquify, SIZE, D, &own);
let Value::Str(text) = &liquify.get("distortionMesh").unwrap().value else { panic!("no mesh") };
let moved = crate::distort4::LiquifyStroke { points: vec![[101.0, 51.0]], ..crate::distort4::LiquifyStroke::parse(&line).unwrap() };
assert_eq!(
text,
&format!(
"{}
not a stroke",
moved.to_line()
)
);
// Roto Brush strokes.
let (spec, mut roto) = instance(crate::roto::ID, &mut next);
let data = effectcraft_track::roto::RotoData {
base: Some(0),
span: [0, 10],
strokes: vec![effectcraft_track::roto::Stroke { kind: effectcraft_track::roto::StrokeKind::Fg, frame: 0, radius: 5.0, points: vec![[2.0, 2.0]] }],
};
roto.get_mut(crate::roto::STROKES).unwrap().value = Value::Str(data.to_json());
to_effect_space(spec, &mut roto, SIZE, D, &own);
let Value::Str(text) = &roto.get(crate::roto::STROKES).unwrap().value else { panic!("no strokes") };
assert_eq!(effectcraft_track::roto::RotoData::from_json(text).strokes[0].points, vec![[102.0, 52.0]]);
}
#[test]
fn layers_with_a_source_rectangle_keep_their_effect_positions() {
let mut next = 1;
let (spec, mut twirl) = instance("ec.distort.twirl", &mut next);
twirl.get_mut("center").unwrap().value = Value::Vec2([3.0, 4.0]);
let before = twirl.clone();
to_effect_space(spec, &mut twirl, SIZE, [0.0; 2], &|_| [0.0; 2]);
assert_eq!(twirl, before);
}
}

View File

@ -4,7 +4,7 @@
//!
//! Each "noise layer" (octave) is 3D value noise (x, y, evolution) on an integer lattice. The
//! Noise Type picks the interpolation between lattice values (Block = nearest, Linear,
//! Soft Linear = quadratic B-spline, Spline = Catmull-Rom). Successive layers are scaled by Sub
//! Soft Linear = quintic fade, Spline = Catmull-Rom). Successive layers are scaled by Sub
//! Scaling, weighted by Sub Influence, rotated by Sub Rotation and shifted by Sub Offset, and the
//! Fractal Type shapes each layer (turbulent types fold the signed noise; dynamic types warp the
//! domain; Max keeps the strongest layer; Strings keeps thin lines). Contrast / Brightness /
@ -17,7 +17,7 @@ use effectcraft_keyframe::Value;
use effectcraft_project::ParamUi;
use rayon::prelude::*;
use crate::generate::{fade, lattice};
use crate::generate::value_noise;
use crate::{Buf, EffectCtx, EffectSpec, ParamSpec, num, p, popup, slider};
/// Fractal Type options (shared by both effects).
@ -120,36 +120,36 @@ pub fn specs() -> Vec<EffectSpec> {
// ---------------------------------------------------------------- noise kernels
/// Soft Linear's gain about mid-grey. Averaging 3 × 3 lattice values leaves the B-spline 0.55² of
/// their variance, against 0.784² (181/231 per axis) for the quintic fade Soft Linear used before
/// (#259); the gain keeps the noise's contrast.
const SOFT_LINEAR_GAIN: f32 = (181.0 / 231.0) / 0.55;
fn lattice(ix: i32, iy: i32, iz: i32, seed: u32) -> f32 {
// The same hash as `generate::value_noise`, so Soft Linear matches it bit for bit.
let mut h =
(ix as u32).wrapping_mul(0x27d4_eb2d) ^ (iy as u32).wrapping_mul(0x1656_67b1) ^ (iz as u32).wrapping_mul(0x9e37_79b9) ^ seed.wrapping_mul(0x85eb_ca6b);
h ^= h >> 15;
h = h.wrapping_mul(0x2c1b_3c6d);
h ^= h >> 12;
h = h.wrapping_mul(0x297a_2d39);
h ^= h >> 15;
(h & 0xffff) as f32 / 65535.0
}
#[inline]
fn fade(t: f32) -> f32 {
t * t * t * (t * (t * 6.0 - 15.0) + 10.0)
}
#[inline]
fn catmull(p0: f32, p1: f32, p2: f32, p3: f32, t: f32) -> f32 {
0.5 * (2.0 * p1 + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t * t + (3.0 * p1 - p0 - 3.0 * p2 + p3) * t * t * t)
}
/// Uniform quadratic B-spline weights of the lattice values before, at and after the nearest one,
/// for `t` = position − nearest + 0.5 in 0..1.
#[inline]
fn bspline(t: f32) -> [f32; 3] {
let s = 1.0 - t;
[0.5 * s * s, 0.5 + t * s, 0.5 * t * t]
}
/// 3D value noise in about 0..1 with the given [`NOISE_TYPES`] interpolation in x and y (the
/// evolution axis z always fades smoothly).
///
/// Soft Linear is linear interpolation softened by a one-cell box filter, which is the quadratic
/// B-spline: smooth like Spline from 9 lattice values instead of 16. A fade between two values
/// has zero slope across every lattice line, which the turbulent types and high contrast turned
/// into a visible grid (#259); the B-spline's slope there is half the neighbours' difference.
pub fn typed_noise(x: f32, y: f32, z: f32, seed: u32, noise_type: u32) -> f32 {
// Soft Linear's taps are centred on the nearest lattice point, the others start at the cell's.
let h = if noise_type == NOISE_SOFT_LINEAR { 0.5 } else { 0.0 };
let (x0, y0, z0) = ((x + h).floor(), (y + h).floor(), z.floor());
let (tx, ty, fz) = (x + h - x0, y + h - y0, fade(z - z0));
if noise_type == NOISE_SOFT_LINEAR {
return value_noise(x, y, z, seed);
}
let (x0, y0, z0) = (x.floor(), y.floor(), z.floor());
let (tx, ty, fz) = (x - x0, y - y0, fade(z - z0));
let (ix, iy, iz) = (x0 as i32, y0 as i32, z0 as i32);
let l = |a: f32, b: f32, t: f32| a + (b - a) * t;
let slice = |dz: i32| -> f32 {
@ -157,19 +157,13 @@ pub fn typed_noise(x: f32, y: f32, z: f32, seed: u32, noise_type: u32) -> f32 {
match noise_type {
0 => c(0, 0),
1 => l(l(c(0, 0), c(1, 0), tx), l(c(0, 1), c(1, 1), tx), ty),
NOISE_SOFT_LINEAR => {
let (wx, wy) = (bspline(tx), bspline(ty));
let row = |dy: i32| wx[0] * c(-1, dy) + wx[1] * c(0, dy) + wx[2] * c(1, dy);
wy[0] * row(-1) + wy[1] * row(0) + wy[2] * row(1)
}
_ => {
let row = |dy: i32| catmull(c(-1, dy), c(0, dy), c(1, dy), c(2, dy), tx);
catmull(row(-1), row(0), row(1), row(2), ty)
}
}
};
let n = l(slice(0), slice(1), fz);
if noise_type == NOISE_SOFT_LINEAR { 0.5 + (n - 0.5) * SOFT_LINEAR_GAIN } else { n }
l(slice(0), slice(1), fz)
}
/// Everything the per-pixel evaluation needs, resolved from the parameters.
@ -429,8 +423,7 @@ pub fn overflow(v: f32, mode: u32) -> f32 {
}
/// Composite grey noise `val` over the original pixel by Blending Mode, Opacity and (ours) Blend
/// With Original. The noise stays inside the layer's alpha, so a masked solid shows it only within
/// its masks (#939).
/// With Original.
pub(crate) fn composite(orig: [f32; 4], val: f32, opacity: f32, mode: u32, blend_orig: f32) -> [f32; 4] {
let src = [val * opacity, val * opacity, val * opacity, opacity];
let out = match mode {
@ -441,7 +434,7 @@ pub(crate) fn composite(orig: [f32; 4], val: f32, opacity: f32, mode: u32, blend
}
};
let k = 1.0 - blend_orig;
[0, 1, 2, 3].map(|c| orig[c] * blend_orig + out[c] * orig[3] * k)
[0, 1, 2, 3].map(|c| orig[c] * blend_orig + out[c] * k)
}
fn render(ctx: &EffectCtx, mut b: Buf, turbulent: bool) -> Buf {
@ -575,28 +568,6 @@ mod tests {
assert!(mul.data.iter().any(|p| p[2] > p[0] + 0.01));
}
#[test]
fn noise_stays_inside_the_layer_alpha() {
// #939: a masked solid (transparent outside the mask, a soft edge) filled the whole layer.
let mut img = Image::filled(40, 24, [0.2, 0.4, 0.6, 1.0]);
for (i, p) in img.data.iter_mut().enumerate() {
let a = match i % 40 {
0..10 => 0.0,
10..20 => 0.5,
_ => 1.0,
};
*p = p.map(|c| c * a);
}
for mode in [BLEND_NONE, BLEND_NORMAL, 3] {
let out = run_fx("ec.noise.fractal", &[("blendingMode", Value::Enum(mode))], img.clone(), 0.0, EffectEnv::default()).img;
for (o, p) in img.data.iter().zip(&out.data) {
assert!((o[3] - p[3]).abs() < 1e-6, "mode {mode}: alpha {} became {}", o[3], p[3]);
assert!(p[..3].iter().all(|&c| c <= p[3] + 1e-6), "mode {mode}: premultiplied colour above alpha");
}
assert!(out.data.iter().any(|p| p[3] == 1.0 && (p[0] - 0.2).abs() > 0.01), "mode {mode}: noise drawn inside the mask");
}
}
#[test]
fn sub_settings_and_fractal_types_change_the_result() {
let base = run("ec.noise.fractal", &[]);
@ -616,53 +587,4 @@ mod tests {
let t1 = run("ec.noise.turbulent", &[("evolution", num(200.0)), ("evolutionOptions/turbulenceFactor", num(2.0))]);
assert_ne!(t0, t1);
}
/// Turbulent Basic Fractal Noise drawn alone at `w` × `h`.
fn turbulent_basic(w: u32, h: u32, vals: &[(&str, Value)]) -> Image {
let mut all = vec![("fractalType", Value::Enum(2)), ("blendingMode", Value::Enum(BLEND_NONE))];
all.extend_from_slice(vals);
run_fx("ec.noise.fractal", &all, Image::filled(w, h, [0.0, 0.0, 0.0, 1.0]), 0.0, EffectEnv::default()).img
}
/// (x, y, ∂/∂x, ∂/∂y) of the grey value at every inner pixel (central differences).
fn gradients(img: &Image) -> Vec<(i64, i64, f64, f64)> {
let v = |x: i64, y: i64| img.get(x, y)[0] as f64;
let (w, h) = (img.width as i64, img.height as i64);
(1..h - 1).flat_map(|y| (1..w - 1).map(move |x| (x, y, (v(x + 1, y) - v(x - 1, y)) / 2.0, (v(x, y + 1) - v(x, y - 1)) / 2.0))).collect()
}
/// #259: Soft Linear faded between two lattice values with zero slope across every lattice
/// line, which Turbulent Basic and high contrast showed as a square grid.
#[test]
fn soft_linear_has_no_lattice_creases() {
// One 16 px layer whose lattice lines run through the pixel centres x, y = 16k: the slope
// across them is about the average slope (with the fade it was 2% of it).
let one = turbulent_basic(160, 96, &[("complexity", num(1.0)), ("transform/scale", num(16.0)), ("transform/offset", Value::Vec2([0.5, 0.5]))]);
let (mut on, mut n_on, mut all, mut n_all) = (0.0, 0.0, 0.0, 0.0);
for (x, y, gx, gy) in gradients(&one) {
for (k, g) in [(x, gx), (y, gy)] {
all += g.abs();
n_all += 1.0;
if k % 16 == 0 {
on += g.abs();
n_on += 1.0;
}
}
}
let across = (on / n_on) / (all / n_all);
assert!(across > 0.5, "slope across lattice lines / average slope: {across}");
// The issue's settings at a quarter size: gradients run along the lattice axes about as
// strongly as along the diagonals (with the fade, 1.2× as strongly).
let img = turbulent_basic(480, 270, &[("contrast", num(140.0)), ("brightness", num(-35.0)), ("transform/scale", num(105.0))]);
let (mut axis, mut diag) = (0.0, 0.0);
for (_, _, gx, gy) in gradients(&img) {
let a = gy.atan2(gx).to_degrees().rem_euclid(90.0);
if !(15.0..=75.0).contains(&a) {
axis += gx.hypot(gy);
} else if (30.0..=60.0).contains(&a) {
diag += gx.hypot(gy);
}
}
assert!(axis / diag < 1.15, "gradient along the axes / along the diagonals: {}", axis / diag);
}
}

View File

@ -17,12 +17,9 @@
//! * CDL maths follows the ASC CDL v1.2 specification (slope, offset, power, then saturation with
//! Rec. 709 luma weights); the "No Clamp" style mirrors power around zero;
//! * File Transform reads `.cube` (Adobe/Resolve), `.3dl` (Autodesk Lustre/Flame integer
//! lattices), `.csp` (cineSpace, with its per-channel pre-LUT shaper), Sony Imageworks
//! `.spi1d` / `.spi3d` / `.spimtx` and ASC `.cc` / `.ccc` / `.cdl` XML, with nearest, trilinear
//! or tetrahedral interpolation and an inverse direction (exact for 1D/CDL/matrices, iterative
//! for 3D lattices);
//! * Configuration ▸ Custom reads a `.ocio` file instead ([`crate::ocio_config`]); what it can't
//! apply is reported by [`warning`].
//! lattices), `.csp` (cineSpace, with its per-channel pre-LUT shaper) and ASC `.cc` / `.ccc` /
//! `.cdl` XML, with nearest, trilinear or tetrahedral interpolation and an inverse direction
//! (exact for 1D/CDL, iterative for 3D lattices).
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
@ -39,13 +36,13 @@ use crate::{Buf, EffectCtx, EffectSpec, num, p, popup, slider};
type M3 = [[f64; 3]; 3];
pub(crate) const D65: [f64; 2] = [0.3127, 0.3290];
const D65: [f64; 2] = [0.3127, 0.3290];
const ACES_WHITE: [f64; 2] = [0.32168, 0.33767];
const AP0: [[f64; 2]; 3] = [[0.7347, 0.2653], [0.0, 1.0], [0.0001, -0.0770]];
const AP1: [[f64; 2]; 3] = [[0.713, 0.293], [0.165, 0.830], [0.128, 0.044]];
pub(crate) const REC709: [[f64; 2]; 3] = [[0.640, 0.330], [0.300, 0.600], [0.150, 0.060]];
pub(crate) const REC2020: [[f64; 2]; 3] = [[0.708, 0.292], [0.170, 0.797], [0.131, 0.046]];
pub(crate) const P3: [[f64; 2]; 3] = [[0.680, 0.320], [0.265, 0.690], [0.150, 0.060]];
const REC709: [[f64; 2]; 3] = [[0.640, 0.330], [0.300, 0.600], [0.150, 0.060]];
const REC2020: [[f64; 2]; 3] = [[0.708, 0.292], [0.170, 0.797], [0.131, 0.046]];
const P3: [[f64; 2]; 3] = [[0.680, 0.320], [0.265, 0.690], [0.150, 0.060]];
const IDENTITY: M3 = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
/// Transfer functions (encoded ↔ linear).
@ -55,16 +52,10 @@ pub enum Tf {
Srgb,
Gamma(f64),
AcesCct,
/// ACEScc (Academy S-2014-003), the pure-log grading encoding.
AcesCc,
/// SMPTE ST 2084 with 1.0 = 100 cd/m².
Pq,
}
/// ACEScc: the lowest code value (linear 0 and below) and the end of its toe (linear 2⁻¹⁵).
const ACESCC_MIN: f64 = (-16.0 + 9.72) / 17.52;
const ACESCC_TOE: f64 = (-15.0 + 9.72) / 17.52;
impl Tf {
pub fn decode(self, v: f64) -> f64 {
match self {
@ -84,15 +75,6 @@ impl Tf {
65504.0
}
}
Tf::AcesCc => {
if v < ACESCC_TOE {
(2f64.powf(v * 17.52 - 9.72) - 2f64.powi(-16)) * 2.0
} else if v < (65504f64.log2() + 9.72) / 17.52 {
2f64.powf(v * 17.52 - 9.72)
} else {
65504.0
}
}
Tf::Pq => {
let (m1, m2, c1, c2, c3) = pq_consts();
let e = v.max(0.0).powf(1.0 / m2);
@ -117,15 +99,6 @@ impl Tf {
(v.log2() + 9.72) / 17.52
}
}
Tf::AcesCc => {
if v <= 0.0 {
ACESCC_MIN
} else if v < 2f64.powi(-15) {
((2f64.powi(-16) + v * 0.5).log2() + 9.72) / 17.52
} else {
(v.log2() + 9.72) / 17.52
}
}
Tf::Pq => {
let (m1, m2, c1, c2, c3) = pq_consts();
let y = (v / 100.0).max(0.0).powf(m1);
@ -150,13 +123,13 @@ pub struct Space {
pub raw: bool,
}
pub(crate) const fn sp(name: &'static str, prims: [[f64; 2]; 3], white: [f64; 2], tf: Tf) -> Space {
const fn sp(name: &'static str, prims: [[f64; 2]; 3], white: [f64; 2], tf: Tf) -> Space {
Space { name, prims: Some((prims, white)), tf, raw: false }
}
/// The built-in configuration's colour spaces, in popup order.
pub(crate) const ACES2065_1: Space = sp("ACES2065-1", AP0, ACES_WHITE, Tf::Linear);
pub(crate) const ACES_CG: Space = sp("ACEScg", AP1, ACES_WHITE, Tf::Linear);
const ACES2065_1: Space = sp("ACES2065-1", AP0, ACES_WHITE, Tf::Linear);
const ACES_CG: Space = sp("ACEScg", AP1, ACES_WHITE, Tf::Linear);
const ACES_CCT: Space = sp("ACEScct", AP1, ACES_WHITE, Tf::AcesCct);
pub const SPACES: &[Space] = &[
@ -359,92 +332,6 @@ pub enum FileXform {
Lut { lut: Lut, shaper: Option<[Vec<(f32, f32)>; 3]> },
/// CDL corrections by id (in file order).
Cdl(Vec<(String, Cdl)>),
/// A matrix with offset (Sony Imageworks `.spimtx`), as a MatrixTransform's.
Matrix { m: [f64; 16], offset: [f64; 4] },
}
/// Parse a Sony Imageworks `.spimtx` matrix: three rows of four numbers, three matrix columns
/// and an offset in 16-bit code values (÷ 65535).
pub fn parse_spimtx(text: &str) -> Option<FileXform> {
let v: Vec<f64> = text.split_whitespace().map(|t| t.parse().ok()).collect::<Option<_>>()?;
if v.len() != 12 || v.iter().any(|x| !x.is_finite()) {
return None;
}
let mut m = [0.0; 16];
let mut offset = [0.0; 4];
for (r, row) in v.as_chunks::<4>().0.iter().enumerate() {
m[r * 4..r * 4 + 3].copy_from_slice(&row[..3]);
offset[r] = row[3] / 65535.0;
}
m[15] = 1.0;
Some(FileXform::Matrix { m, offset })
}
/// Parse a Sony Imageworks `.spi1d` 1D LUT: `From <min> <max>`, `Length <n>`, `Components
/// <1 | 3>`, then `{` the values `}`.
pub fn parse_spi1d(text: &str) -> Option<Lut> {
let (mut from, mut len, mut comps) = ([0.0f32, 1.0], 0usize, 1usize);
let mut values: Vec<f32> = vec![];
let mut body = false;
for line in text.lines().map(str::trim).filter(|l| !l.is_empty()) {
if body {
if line.starts_with('}') {
break;
}
for t in line.split_whitespace() {
values.push(t.parse().ok()?);
}
continue;
}
let mut t = line.split_whitespace();
match t.next()? {
"{" => body = true,
"From" => from = [t.next()?.parse().ok()?, t.next()?.parse().ok()?],
"Length" => len = t.next()?.parse().ok()?,
"Components" => comps = t.next()?.parse().ok()?,
_ => {}
}
}
// A 1D table is interpolated by index, so cap it like the lattices (2²⁴ entries).
if !(2..=1 << 24).contains(&len)
|| !matches!(comps, 1 | 3)
|| values.len() != len.checked_mul(comps)?
|| from[1] <= from[0]
|| from.iter().any(|v| !v.is_finite())
{
return None;
}
let d1 = values.chunks_exact(comps).map(|c| if comps == 3 { [c[0], c[1], c[2]] } else { [c[0]; 3] }).collect();
Some(Lut { n1: len, d1, n3: 0, d3: vec![], dmin: [from[0]; 3], dmax: [from[1]; 3] })
}
/// Parse a Sony Imageworks `.spi3d` lattice: `SPILUT 1.0`, `3 3`, the sizes, then one
/// `r g b R G B` line per lattice point (indices, then the colour).
pub fn parse_spi3d(text: &str) -> Option<Lut> {
let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty());
if !lines.next()?.starts_with("SPILUT") {
return None;
}
lines.next()?;
let size: Vec<usize> = lines.next()?.split_whitespace().map(|t| t.parse().ok()).collect::<Option<_>>()?;
let n = *size.first()?;
if size.len() != 3 || size.iter().any(|s| *s != n) || !(2..=129).contains(&n) {
return None;
}
let mut d3 = vec![[0.0f32; 3]; n * n * n];
let mut seen = 0usize;
for line in lines {
let v: Vec<&str> = line.split_whitespace().collect();
let [r, g, b, cr, cg, cb] = v.as_slice() else { return None };
let (r, g, b): (usize, usize, usize) = (r.parse().ok()?, g.parse().ok()?, b.parse().ok()?);
if r >= n || g >= n || b >= n {
return None;
}
// Our lattice is red fastest.
*d3.get_mut(r + g * n + b * n * n)? = [cr.parse().ok()?, cg.parse().ok()?, cb.parse().ok()?];
seen += 1;
}
(seen == n * n * n).then_some(Lut { n1: 0, d1: vec![], n3: n, d3, dmin: [0.0; 3], dmax: [1.0; 3] })
}
/// Parse an Autodesk `.3dl` lattice (integers, blue fastest; an optional input-mesh line).
@ -583,15 +470,6 @@ pub fn parse_file(text: &str, ext: &str) -> Option<FileXform> {
if ext == "csp" || text.trim_start().starts_with("CSPLUTV100") {
return parse_csp(text).map(|(lut, s)| FileXform::Lut { lut, shaper: Some(s) });
}
if ext == "spimtx" {
return parse_spimtx(text);
}
if ext == "spi1d" || text.trim_start().starts_with("Version") {
return parse_spi1d(text).map(|lut| FileXform::Lut { lut, shaper: None });
}
if ext == "spi3d" || text.trim_start().starts_with("SPILUT") {
return parse_spi3d(text).map(|lut| FileXform::Lut { lut, shaper: None });
}
if ext == "cube" || text.contains("LUT_3D_SIZE") || text.contains("LUT_1D_SIZE") {
return parse_cube(text).map(|lut| FileXform::Lut { lut, shaper: None });
}
@ -636,7 +514,6 @@ impl FileXform {
let o = if inverse { cdl.invert(c64, true) } else { cdl.apply(c64, true) };
o.map(|v| v as f32)
}
FileXform::Matrix { m, offset } => crate::ocio_config::matrix_apply(m, offset, c.map(|v| v as f64), inverse).map(|v| v as f32),
FileXform::Lut { lut, shaper } => {
let fwd = |c: [f32; 3]| {
let c = match shaper {
@ -779,86 +656,33 @@ fn space_at(i: u32) -> &'static Space {
&SPACES[(i as usize).min(SPACES.len() - 1)]
}
/// The effects that take Configuration ▸ Custom (a `.ocio` file).
pub(crate) const CUSTOM_CONFIG_FX: [&str; 2] = ["ec.color.ociocolorspace", "ec.color.ociodisplay"];
/// Effect `id`'s transform under Configuration ▸ Custom: the config's conversion from the source
/// space to the destination space (Color Space Transform) or through a display's view (Display
/// Transform), see [`crate::ocio_config`]. `Ok(None)` with the built-in configuration; an error
/// (the pixels pass through) when the config can't be read or has no such colour space.
fn custom_xf(id: &str, pr: &crate::Params) -> Result<Option<crate::ocio_config::Xf>, String> {
use crate::ocio_config::Xf;
if !CUSTOM_CONFIG_FX.contains(&id) || pr.e("config") != 1 {
return Ok(None);
/// The custom config chosen by an effect (Configuration ▸ Custom with a readable file).
fn custom_config(ctx: &EffectCtx) -> Option<std::sync::Arc<crate::ocio_config::Config>> {
if ctx.params.e("config") != 1 {
return None;
}
let cfg = crate::ocio_config::load_config(pr.s("configFile")).map_err(|e| format!("Can't read the OCIO config: {e}"))?;
// A space by its typed name, else by the built-in popup's name.
let space = |name: &str, popup: u32| {
let name = if name.trim().is_empty() { space_at(popup).name } else { name.trim() };
cfg.space(name).ok_or_else(|| format!("The OCIO config has no color space `{name}`"))
};
let a = space(pr.s("sourceName"), pr.e("source"))?;
let inverse = pr.e("direction") == 1;
if id == "ec.color.ociodisplay" {
let (dn, vn) = display_view_names(pr);
let xf = cfg.display_path(a, dn, vn);
return Ok(Some(if inverse { Xf::Inverse(Box::new(xf)) } else { xf }));
}
let z = space(pr.s("destinationName"), pr.e("destination"))?;
// Inverse: the conversion the other way (OCIO's inverse ColorSpaceTransform).
Ok(Some(if inverse { cfg.path(z, a) } else { cfg.path(a, z) }))
crate::ocio_config::load_config(ctx.params.s("configFile"))
}
/// The display and view a Display Transform names: the typed names, else the popups'.
fn display_view_names(pr: &crate::Params) -> (&str, &str) {
let display = DISPLAYS.get(pr.e("display") as usize).map_or("", |d| d.0);
let view = VIEWS.get(pr.e("view") as usize).copied().unwrap_or("");
let typed = |k: &str, popup| if pr.s(k).trim().is_empty() { popup } else { pr.s(k).trim() };
(typed("displayName", display), typed("viewName", view))
}
/// Effect Controls' warning for effect `id`: a custom OCIO configuration that can't be read, has
/// no such colour space, display or view, or uses transforms EffectCraft can't apply (colours
/// pass through those steps, #410). `None` when the effect renders as asked.
pub fn warning(id: &str, pr: &crate::Params) -> Option<String> {
let xf = match custom_xf(id, pr) {
Ok(x) => x?,
Err(e) => return Some(format!("{e}: the colors pass through.")),
};
let mut notes = vec![];
if id == "ec.color.ociodisplay"
&& let Ok(cfg) = crate::ocio_config::load_config(pr.s("configFile"))
{
let (dn, vn) = display_view_names(pr);
if let Some((d, v)) = cfg.view(dn, vn)
&& (!d.eq_ignore_ascii_case(dn) || !v.name.eq_ignore_ascii_case(vn))
{
notes.push(format!("The OCIO config has no display `{dn}` with a view `{vn}`: showing {d} / {}.", v.name));
}
}
let missing = xf.unsupported_list();
if !missing.is_empty() {
notes.push(format!("Not supported, the colors pass through: {}.", missing.join(", ")));
}
(!notes.is_empty()).then(|| notes.join(" "))
}
/// Configuration ▸ Custom: render through the config's transform. `false` with the built-in
/// configuration (the caller renders it).
fn custom_render(id: &str, ctx: &EffectCtx, b: &mut Buf) -> bool {
match custom_xf(id, ctx.params) {
Ok(None) => false,
Ok(Some(xf)) => {
map_px(b, |c| xf.apply(c.map(|v| v as f64), false).map(|v| v as f32));
true
}
// A config that can't be read, or an unknown space: the pixels pass through.
Err(_) => true,
}
/// A space of a custom config by its typed name, else by the built-in popup's name.
fn custom_space<'c>(cfg: &'c crate::ocio_config::Config, name: &str, popup: u32) -> Option<&'c crate::ocio_config::ConfigSpace> {
cfg.space(name).or_else(|| cfg.space(space_at(popup).name))
}
fn ocio_cst(ctx: &EffectCtx, mut b: Buf) -> Buf {
if custom_render("ec.color.ociocolorspace", ctx, &mut b) {
// Custom configuration that can't be read: pixels pass through.
if ctx.params.e("config") == 1 && custom_config(ctx).is_none() {
return b;
}
if let Some(cfg) = custom_config(ctx) {
let (Some(a), Some(z)) = (
custom_space(&cfg, ctx.params.s("sourceName"), ctx.params.e("source")),
custom_space(&cfg, ctx.params.s("destinationName"), ctx.params.e("destination")),
) else {
return b;
};
let (a, z) = if ctx.params.e("direction") == 1 { (z, a) } else { (a, z) };
map_px(&mut b, |c| cfg.convert(a, z, c.map(|v| v as f64)).map(|v| v as f32));
return b;
}
let (a, z) = (space_at(ctx.params.e("source")), space_at(ctx.params.e("destination")));
@ -896,7 +720,19 @@ fn ocio_cdl(ctx: &EffectCtx, mut b: Buf) -> Buf {
}
fn ocio_display(ctx: &EffectCtx, mut b: Buf) -> Buf {
if custom_render("ec.color.ociodisplay", ctx, &mut b) {
// Custom configuration that can't be read: pixels pass through.
if ctx.params.e("config") == 1 && custom_config(ctx).is_none() {
return b;
}
if let Some(cfg) = custom_config(ctx) {
// The display's view names a colour space of the config.
let display = DISPLAYS.get(ctx.params.e("display") as usize).map_or("", |d| d.0);
let view = VIEWS.get(ctx.params.e("view") as usize).copied().unwrap_or("");
let dn = if ctx.params.s("displayName").is_empty() { display } else { ctx.params.s("displayName") };
let vn = if ctx.params.s("viewName").is_empty() { view } else { ctx.params.s("viewName") };
let (Some(a), Some(z)) = (custom_space(&cfg, ctx.params.s("sourceName"), ctx.params.e("source")), cfg.view_space(dn, vn)) else { return b };
let (a, z) = if ctx.params.e("direction") == 1 { (z, a) } else { (a, z) };
map_px(&mut b, |c| cfg.convert(a, z, c.map(|v| v as f64)).map(|v| v as f32));
return b;
}
let input = space_at(ctx.params.e("source"));
@ -1063,26 +899,9 @@ pub enum ColorOp {
Pow([f32; 3]),
/// LogTransform / LogAffineTransform, forward or inverse (as `ocio_config::Xf::apply`).
LogAffine { base: f32, log_slope: [f32; 3], log_offset: [f32; 3], lin_slope: [f32; 3], lin_offset: [f32; 3], inverse: bool },
/// ExponentWithLinearTransform, forward (decode) or inverse (as
/// [`crate::ocio_config::moncurve`]).
MonCurve { gamma: [f32; 3], offset: [f32; 3], inverse: bool, mirror: bool },
/// Clamp each channel to `lo..=hi` (a clamping RangeTransform; ±`f32::MAX` = unbounded).
Clamp { lo: [f32; 3], hi: [f32; 3] },
}
/// The ops of a matrix-and-offset transform (MatrixTransform, `.spimtx`), forward or inverse
/// (as [`crate::ocio_config::matrix_apply`]).
fn matrix_ops(m: &[f64; 16], offset: &[f64; 4], inverse: bool, ops: &mut Vec<ColorOp>) {
if !inverse {
let m = [0, 1, 2].map(|r| [m[r * 4] as f32, m[r * 4 + 1] as f32, m[r * 4 + 2] as f32]);
ops.push(ColorOp::Affine { m, pre: [0.0; 3], post: [offset[0] as f32, offset[1] as f32, offset[2] as f32] });
} else if let Some(inv) = crate::ocio_config::mat3_inverse(m) {
let m = inv.map(|r| r.map(|v| v as f32));
ops.push(ColorOp::Affine { m, pre: [-offset[0] as f32, -offset[1] as f32, -offset[2] as f32], post: [0.0; 3] });
}
}
/// The ops of a LUT / CDL / matrix file transform (as [`FileXform::apply`]).
/// The ops of a LUT / CDL file transform (as [`FileXform::apply`]).
fn file_ops(x: &FileXform, interp: u32, inverse: bool, ccc_id: &str, ops: &mut Vec<ColorOp>) {
match x {
FileXform::Cdl(list) => {
@ -1092,7 +911,6 @@ fn file_ops(x: &FileXform, interp: u32, inverse: bool, ccc_id: &str, ops: &mut V
FileXform::Lut { lut, shaper } => {
ops.push(ColorOp::Lut { lut: Arc::new(lut.clone()), shaper: shaper.clone().map(Arc::new), interp, inverse });
}
FileXform::Matrix { m, offset } => matrix_ops(m, offset, inverse, ops),
}
}
@ -1102,12 +920,16 @@ fn config_xf_ops(x: &crate::ocio_config::Xf, inverse: bool, ops: &mut Vec<ColorO
let f3 = |v: &[f64; 3]| v.map(|x| x as f32);
match x {
Xf::Inverse(x) => config_xf_ops(x, !inverse, ops),
Xf::Matrix { m, offset } => matrix_ops(m, offset, inverse, ops),
Xf::Matrix { m, offset } => {
if !inverse {
let m = [0, 1, 2].map(|r| [m[r * 4] as f32, m[r * 4 + 1] as f32, m[r * 4 + 2] as f32]);
ops.push(ColorOp::Affine { m, pre: [0.0; 3], post: [offset[0] as f32, offset[1] as f32, offset[2] as f32] });
} else if let Some(inv) = crate::ocio_config::mat3_inverse(m) {
let m = inv.map(|r| r.map(|v| v as f32));
ops.push(ColorOp::Affine { m, pre: [-offset[0] as f32, -offset[1] as f32, -offset[2] as f32], post: [0.0; 3] });
}
}
Xf::File { xf: Some(x), interp, ccc, .. } => file_ops(x, *interp, inverse, ccc, ops),
Xf::ExponentLinear { gamma, offset, mirror } => ops.push(ColorOp::MonCurve { gamma: f3(gamma), offset: f3(offset), inverse, mirror: *mirror }),
// A BuiltinTransform's curve: forward decodes.
Xf::Curve(tf) => ops.push(if inverse { ColorOp::Encode(*tf) } else { ColorOp::Decode(*tf) }),
Xf::Named(_) => {}
Xf::File { xf: None, .. } | Xf::Unsupported(_) => {}
Xf::Exponent(e) => ops.push(ColorOp::Pow([0, 1, 2].map(|k| (if inverse { 1.0 / e[k].max(1e-9) } else { e[k] }) as f32))),
Xf::Log { base } => {
@ -1122,14 +944,11 @@ fn config_xf_ops(x: &crate::ocio_config::Xf, inverse: bool, ops: &mut Vec<ColorO
inverse,
}),
Xf::Cdl(cdl) => ops.push(ColorOp::Cdl { cdl: *cdl, clamp: true, inverse }),
Xf::Range(r) => {
let (k, off, lo, hi) = r.parts(inverse);
Xf::Range { min_in, max_in, min_out, max_out } => {
let (a0, a1, b0, b1) = if inverse { (*min_out, *max_out, *min_in, *max_in) } else { (*min_in, *max_in, *min_out, *max_out) };
let k = if (a1 - a0).abs() > 1e-12 { (b1 - b0) / (a1 - a0) } else { 0.0 };
let k = k as f32;
ops.push(ColorOp::Affine { m: [[k, 0.0, 0.0], [0.0, k, 0.0], [0.0, 0.0, k]], pre: [0.0; 3], post: [off as f32; 3] });
if lo.is_finite() || hi.is_finite() {
let bound = |v: f64| (v as f32).clamp(-f32::MAX, f32::MAX);
ops.push(ColorOp::Clamp { lo: [bound(lo); 3], hi: [bound(hi); 3] });
}
ops.push(ColorOp::Affine { m: [[k, 0.0, 0.0], [0.0, k, 0.0], [0.0, 0.0, k]], pre: [-a0 as f32; 3], post: [b0 as f32; 3] });
}
Xf::Group(list) => {
if inverse {
@ -1141,6 +960,23 @@ fn config_xf_ops(x: &crate::ocio_config::Xf, inverse: bool, ops: &mut Vec<ColorO
}
}
/// The ops of a custom config's conversion from space `a` to space `z` (as `Config::convert`).
fn config_ops(a: &crate::ocio_config::ConfigSpace, z: &crate::ocio_config::ConfigSpace, ops: &mut Vec<ColorOp>) {
if a.is_data || z.is_data || a.name == z.name {
return;
}
match (&a.to_ref, &a.from_ref) {
(Some(x), _) => config_xf_ops(x, false, ops),
(None, Some(x)) => config_xf_ops(x, true, ops),
_ => {}
}
match (&z.from_ref, &z.to_ref) {
(Some(x), _) => config_xf_ops(x, false, ops),
(None, Some(x)) => config_xf_ops(x, true, ops),
_ => {}
}
}
/// How a colour program meets premultiplied pixels.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Straight {
@ -1168,16 +1004,6 @@ fn xform_ops(x: &Xform, ops: &mut Vec<ColorOp>) {
pub fn color_program(id: &str, ctx: &EffectCtx) -> Option<(Vec<ColorOp>, Straight)> {
let pr = ctx.params;
let mut ops = vec![];
// Configuration ▸ Custom compiles the config's transforms (a config that can't be read, or
// names unknown spaces, passes the pixels through).
match custom_xf(id, pr) {
Ok(None) => {}
Ok(Some(xf)) => {
config_xf_ops(&xf, false, &mut ops);
return Some((ops, Straight::Unpremul));
}
Err(_) => return Some((ops, Straight::Unpremul)),
}
match id {
"ec.utility.applylut" => {
if let Some(lut) = crate::utility::load_lut(pr.s("lut")) {
@ -1186,6 +1012,18 @@ pub fn color_program(id: &str, ctx: &EffectCtx) -> Option<(Vec<ColorOp>, Straigh
return Some((ops, Straight::Divide));
}
"ec.color.ociocolorspace" => {
if pr.e("config") == 1 {
// A custom config (one that can't be read, or names unknown spaces: the pixels
// pass through).
if let Some(cfg) = custom_config(ctx)
&& let (Some(a), Some(z)) =
(custom_space(&cfg, pr.s("sourceName"), pr.e("source")), custom_space(&cfg, pr.s("destinationName"), pr.e("destination")))
{
let (a, z) = if pr.e("direction") == 1 { (z, a) } else { (a, z) };
config_ops(a, z, &mut ops);
}
return Some((ops, Straight::Unpremul));
}
let (a, z) = (space_at(pr.e("source")), space_at(pr.e("destination")));
let (a, z) = if pr.e("direction") == 1 { (z, a) } else { (a, z) };
if a.name != z.name {
@ -1199,6 +1037,19 @@ pub fn color_program(id: &str, ctx: &EffectCtx) -> Option<(Vec<ColorOp>, Straigh
}
}
"ec.color.ociodisplay" => {
if pr.e("config") == 1 {
if let Some(cfg) = custom_config(ctx) {
let display = DISPLAYS.get(pr.e("display") as usize).map_or("", |d| d.0);
let view = VIEWS.get(pr.e("view") as usize).copied().unwrap_or("");
let dn = if pr.s("displayName").is_empty() { display } else { pr.s("displayName") };
let vn = if pr.s("viewName").is_empty() { view } else { pr.s("viewName") };
if let (Some(a), Some(z)) = (custom_space(&cfg, pr.s("sourceName"), pr.e("source")), cfg.view_space(dn, vn)) {
let (a, z) = if pr.e("direction") == 1 { (z, a) } else { (a, z) };
config_ops(a, z, &mut ops);
}
}
return Some((ops, Straight::Unpremul));
}
let input = space_at(pr.e("source"));
let (display, view, inverse) = (pr.e("display") as usize, pr.e("view") as usize, pr.e("direction") == 1);
let (_, prims, tf) = DISPLAYS[display.min(DISPLAYS.len() - 1)];
@ -1427,7 +1278,7 @@ pub fn specs() -> Vec<EffectSpec> {
cc,
vec![
p("config", "Configuration", Value::Enum(0), popup(CONFIGS)),
p("configFile", "Config File (.ocio)", Value::Str(String::new()), crate::file_ui(&["ocio"])),
p("configFile", "Config File (.ocio)", Value::Str(String::new()), ParamUi::Text),
space_param("source", "Source", lin709),
p("sourceName", "Source (config name)", Value::Str(String::new()), ParamUi::Text),
space_param("destination", "Destination", srgb),
@ -1442,7 +1293,7 @@ pub fn specs() -> Vec<EffectSpec> {
cc,
vec![
p("config", "Configuration", Value::Enum(0), popup(CONFIGS)),
p("configFile", "Config File (.ocio)", Value::Str(String::new()), crate::file_ui(&["ocio"])),
p("configFile", "Config File (.ocio)", Value::Str(String::new()), ParamUi::Text),
space_param("source", "Input Color Space", lin709),
p("sourceName", "Source (config name)", Value::Str(String::new()), ParamUi::Text),
p("display", "Display Device", Value::Enum(0), popup(&DISPLAYS.iter().map(|d| d.0).collect::<Vec<_>>())),
@ -1459,7 +1310,7 @@ pub fn specs() -> Vec<EffectSpec> {
"OCIO File Transform",
cc,
vec![
p("file", "File", Value::Str(String::new()), crate::file_ui(&["cube", "3dl", "csp", "cc", "ccc", "cdl", "spi1d", "spi3d", "spimtx"])),
p("file", "File", Value::Str(String::new()), ParamUi::Text),
p("cccId", "CCC ID", Value::Str(String::new()), ParamUi::Text),
p("interpolation", "Interpolation", Value::Enum(3), popup(&["Nearest", "Linear", "Tetrahedral", "Best"])),
direction(),
@ -1623,44 +1474,6 @@ mod tests {
v
}
/// Sony Imageworks `.spi1d`, `.spi3d` and `.spimtx` (Blender's configs use them, #410), and
/// malformed ones rejected without panicking.
#[test]
fn spi_files_parse() {
let spi1d = "Version 1\nFrom 0.0 2.0\nLength 3\nComponents 1\n{\n0.0\n0.25\n1.0\n}\n";
let x = parse_file(spi1d, "spi1d").unwrap();
let o = x.apply([1.0, 0.5, 2.0], 1, false, "");
assert!((o[0] - 0.25).abs() < 1e-6 && (o[1] - 0.125).abs() < 1e-6 && (o[2] - 1.0).abs() < 1e-6, "{o:?}");
let back = x.apply(o, 1, true, "");
assert!((back[0] - 1.0).abs() < 1e-5 && (back[2] - 2.0).abs() < 1e-5, "{back:?}");
let three = parse_spi1d("Version 1\nFrom 0 1\nLength 2\nComponents 3\n{\n0 0 0\n1 0.5 2\n}\n").unwrap();
assert_eq!(three.d1, vec![[0.0; 3], [1.0, 0.5, 2.0]]);
let mut spi3d = String::from("SPILUT 1.0\n3 3\n2 2 2\n");
for (r, g, b) in (0..8).map(|i| (i & 1, (i >> 1) & 1, i >> 2)) {
spi3d += &format!("{r} {g} {b} {} {} {}\n", r as f32 * 0.5, g, b);
}
let x = parse_file(&spi3d, "spi3d").unwrap();
let o = x.apply([1.0, 0.5, 0.25], 1, false, "");
assert!((o[0] - 0.5).abs() < 1e-6 && (o[1] - 0.5).abs() < 1e-6 && (o[2] - 0.25).abs() < 1e-6, "{o:?}");
let m = parse_file("2 0 0 6553.5\n0 1 0 0\n0 0 1 0\n", "spimtx").unwrap();
let o = m.apply([0.25, 0.5, 1.0], 1, false, "");
assert!((o[0] - 0.6).abs() < 1e-6 && o[1] == 0.5 && o[2] == 1.0, "{o:?}");
assert!((m.apply(o, 1, true, "")[0] - 0.25).abs() < 1e-6);
for bad in [
"Version 1\nFrom 0 1\nLength 99999999999\nComponents 1\n{\n0\n}\n",
"Version 1\nFrom 1 0\nLength 2\nComponents 1\n{\n0\n1\n}\n",
"Version 1\nFrom 0 1\nLength 3\nComponents 1\n{\n0\n1\n}\n",
"Version 1\nFrom 0 1\nLength 2\nComponents 2\n{\n0 0\n1 1\n}\n",
] {
assert!(parse_spi1d(bad).is_none(), "{bad}");
}
assert!(parse_spi3d("SPILUT 1.0\n3 3\n4000 4000 4000\n").is_none());
assert!(parse_spi3d("SPILUT 1.0\n3 3\n2 2 2\n9 0 0 1 1 1\n").is_none());
assert!(parse_spi3d("SPILUT 1.0\n3 3\n2 2 2\n0 0 0 1 1 1\n").is_none(), "incomplete lattice");
assert!(parse_spimtx("1 0 0\n0 1 0\n").is_none());
assert!(parse_spimtx("NaN 0 0 0 0 1 0 0 0 0 1 0").is_none());
}
#[test]
fn lut_files_parse_and_interpolate() {
// .cube with a non-linear lattice: trilinear between lattice points.

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,7 @@
//! ```text
//! Paint (effect `ec.paint.paint`; Paint on Transparent)
//! Brush 1 (match `brush` | `clone` | `eraser`)
//! Path (the recorded stroke, effect space, animatable)
//! Path (the recorded stroke, layer space, animatable)
//! Stroke Options (Start, End, Color, Diameter, Angle, Hardness, Roundness, Spacing,
//! Channels, Opacity, Flow; clone: Clone Source, Clone Position,
//! Clone Time / Clone Time Shift)
@ -162,7 +162,7 @@ pub fn specs() -> Vec<EffectSpec> {
#[derive(Clone, Debug, PartialEq)]
pub struct StrokeSpec {
pub kind: StrokeKind,
/// Effect-space points.
/// Layer-space points.
pub points: Vec<[f64; 2]>,
/// Pen pressure per point (0–1); empty = full pressure.
pub pressure: Vec<f64>,

View File

@ -219,63 +219,28 @@ fn cylinder(ctx: &EffectCtx, mut b: Buf) -> Buf {
let (_, _, w, h) = rect;
let r = (ctx.params.f("radius") / 100.0 * w / std::f64::consts::TAU).max(0.5);
let c = b.to_px(ctx.params.v2("position"));
// Keep the pre-existing Rotation control as an additional texture spin.
let spin = ctx.params.f("rotation").to_radians();
let (rx, ry, rz) = (ctx.params.f("rotationX"), ctx.params.f("rotationY"), ctx.params.f("rotationZ"));
let tilted = rx != 0.0 || ry != 0.0 || rz != 0.0;
let m = rot_xyz(rx, ry, rz);
// The cylinder's local Y axis after rotation, in viewer coordinates.
let axis = [m[0][1], m[1][1], m[2][1]];
let a = 1.0 - axis[2] * axis[2];
let mode = ctx.params.e("render");
let sh = Shading::from(ctx);
let src = b.img.clone();
b.img.rows_mut().for_each(|(y, row)| {
let dy = y as f64 + 0.5 - c.1;
let v = (y as f64 + 0.5 - c.1) / h + 0.5;
for (x, px) in row.iter_mut().enumerate() {
let dx = x as f64 + 0.5 - c.0;
if !tilted {
// Preserve exactly the mapping of projects saved before axis rotations.
let v = dy / h + 0.5;
let s = dx / r;
if s.abs() >= 1.0 {
*px = [0.0; 4];
continue;
}
let th = s.asin();
let cz = th.cos();
let front = || sh.shade(texel(&src, rect, (th + spin) / std::f64::consts::TAU + 0.5, v), [s, 0.0, cz]);
let back = || sh.shade(texel(&src, rect, (std::f64::consts::PI - th + spin) / std::f64::consts::TAU + 0.5, v), [-s, 0.0, cz]);
*px = match mode {
1 => front(),
2 => back(),
_ => over(front(), back()),
};
continue;
}
// Intersect the viewing ray (dx,dy,z) with the rotated cylindrical
// side wall: |q - dot(q,axis)*axis|^2 = radius^2.
let da = dx * axis[0] + dy * axis[1];
let bq = -da * axis[2];
let discriminant = bq * bq - a * (dx * dx + dy * dy - da * da - r * r);
if a <= 1e-10 || !discriminant.is_finite() || discriminant <= 0.0 {
let s = (x as f64 + 0.5 - c.0) / r;
if s.abs() >= 1.0 {
*px = [0.0; 4];
continue;
}
let root = discriminant.sqrt();
let face = |z: f64, back: bool| {
// Rᵀ turns the intersection into cylinder-local coordinates.
let local = [m[0][0] * dx + m[1][0] * dy + m[2][0] * z, m[0][1] * dx + m[1][1] * dy + m[2][1] * z, m[0][2] * dx + m[1][2] * dy + m[2][2] * z];
let u = (local[0].atan2(local[2]) + spin) / std::f64::consts::TAU + 0.5;
let v = local[1] / h + 0.5;
let sign = if back { -1.0 } else { 1.0 };
let nx = local[0] / r * sign;
let nz = local[2] / r * sign;
let normal = [m[0][0] * nx + m[0][2] * nz, m[1][0] * nx + m[1][2] * nz, m[2][0] * nx + m[2][2] * nz];
sh.shade(texel(&src, rect, u, v), normal)
let th = s.asin();
let cz = th.cos();
let front = || {
let u = (th + spin) / std::f64::consts::TAU + 0.5;
sh.shade(texel(&src, rect, u, v), [s, 0.0, cz])
};
let back = || {
let u = (std::f64::consts::PI - th + spin) / std::f64::consts::TAU + 0.5;
sh.shade(texel(&src, rect, u, v), [-s, 0.0, cz])
};
let front = || face((-bq + root) / a, false);
let back = || face((-bq - root) / a, true);
*px = match mode {
1 => front(),
2 => back(),
@ -397,9 +362,6 @@ pub fn specs() -> Vec<EffectSpec> {
p("radius", "Radius", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)),
p("position", "Position", pt(0.5, 0.5), ParamUi::Point),
p("rotation", "Rotation", num(0.0), ParamUi::Angle),
p("rotationX", "Rotation X", num(0.0), ParamUi::Angle),
p("rotationY", "Rotation Y", num(0.0), ParamUi::Angle),
p("rotationZ", "Rotation Z", num(0.0), ParamUi::Angle),
p("render", "Render", Value::Enum(0), render()),
];
cyl.extend(shading());
@ -565,31 +527,6 @@ mod tests {
assert_eq!(out.get(50 + 18, 10)[3], 0.0);
}
#[test]
fn cylinder_axis_rotations_affect_geometry_and_texture() {
let img = Image::filled(100, 100, [0.5, 0.5, 0.5, 1.0]);
let default = run("ec.perspective.cccylinder", &img, &[]).img;
let x_tilt = run("ec.perspective.cccylinder", &img, &[("rotationX", num(70.0))]).img;
// Tilting the finite cylinder shortens its vertical projection.
assert!(default.get(50, 10)[3] > 0.99);
assert_eq!(x_tilt.get(50, 10)[3], 0.0);
assert!(x_tilt.get(50, 50)[3] > 0.99);
let z_roll = run("ec.perspective.cccylinder", &img, &[("rotationZ", num(90.0))]).img;
// A quarter turn moves the side wall from vertical to horizontal.
assert_eq!(default.get(5, 50)[3], 0.0);
assert!(z_roll.get(5, 50)[3] > 0.99);
let mut striped = Image::filled(100, 100, [0.0, 0.0, 0.0, 1.0]);
for y in 0..100 {
for x in 0..100 {
striped.set(x, y, [x as f32 / 100.0, 0.2, 0.3, 1.0]);
}
}
let baseline = run("ec.perspective.cccylinder", &striped, &[]).img;
let spun = run("ec.perspective.cccylinder", &striped, &[("rotationY", num(90.0))]).img;
assert!((baseline.get(50, 50)[0] - spun.get(50, 50)[0]).abs() > 0.05);
}
#[test]
fn spotlight_darkens_outside_cone() {
let img = Image::filled(80, 80, [1.0, 1.0, 1.0, 1.0]);

View File

@ -164,12 +164,6 @@ pub fn deform(rest: &[[f64; 2]], tris: &[[usize; 3]], weights: &[f64], h: &Handl
t[1] /= h.fixed.len() as f64;
}
let x0: Vec<[f64; 2]> = rest.iter().map(|p| [p[0] + t[0], p[1] + t[1]]).collect();
// One pin and no edge targets: every rotation and scale about the pin costs nothing, and
// the regulariser that picks the translation is too weak to stop CG turning round-off
// into a visible rotation (#313). The translation is the exact solution.
if h.fixed.len() == 1 && h.edges.is_empty() {
return x0;
}
let mut fixed = vec![false; n];
for (i, _) in &h.fixed {
fixed[*i] = true;

View File

@ -387,14 +387,6 @@ pub fn unmap(mesh: &Mesh, deformed: &[[f64; 2]], p: [f64; 2]) -> [f64; 2] {
}
}
/// Whether `p` (layer space) is on the mesh as `deformed` places its vertices.
pub fn contains(mesh: &Mesh, deformed: &[[f64; 2]], p: [f64; 2]) -> bool {
mesh.tris.iter().any(|t| match (deformed.get(t[0]), deformed.get(t[1]), deformed.get(t[2])) {
(Some(a), Some(b), Some(c)) => bary(*a, *b, *c, p).is_some_and(|l| l.iter().all(|x| *x >= -1e-9)),
_ => false,
})
}
#[inline]
fn bary(a: [f64; 2], b: [f64; 2], c: [f64; 2], p: [f64; 2]) -> Option<[f64; 3]> {
let d = (b[1] - c[1]) * (a[0] - c[0]) + (c[0] - b[0]) * (a[1] - c[1]);

View File

@ -306,20 +306,3 @@ fn groups_round_trip_serde() {
assert_eq!(m[0].pins[1].kind, PinKind::Starch);
assert_eq!(m[0].pins[0].position, [3.0, 4.0]);
}
#[test]
fn single_pin_on_a_traced_solid_does_not_rotate_it() {
// #313: an 800×600 solid traced from its alpha, as the Puppet Pin tool builds it. One
// pin is not enough to rotate or scale the mesh, so it may only translate it.
let (w, h) = (800, 600);
let o = outline(&vec![1.0; w * h], w, h, 1.0, [0.0; 2], [400.0, 300.0], 3.0);
let m = triangulate(&o, &MeshOpts::default());
check_valid(&m);
for at in [[40.0, 40.0], [760.0, 30.0], [20.0, 580.0], [790.0, 590.0], [400.0, 300.0], [123.0, 456.0]] {
for t in [[0.0, 0.0], [35.0, -20.0]] {
let d = solve(&m, &[pin(PinKind::Position, at, [at[0] + t[0], at[1] + t[1]])], 2);
let err = d.iter().zip(&m.verts).map(|(a, b)| (a[0] - b[0] - t[0]).hypot(a[1] - b[1] - t[1])).fold(0.0, f64::max);
assert!(err < 1e-6, "pin at {at:?} moved by {t:?}: vertices off by up to {err} px");
}
}
}

View File

@ -16,19 +16,15 @@
//! decontamination of the edge colours. Outside the segmentation span the layer is transparent;
//! without strokes the effect passes its input through.
//!
//! *Version*: 1.0 is the classic graph-cut engine; 2.0 and 3.0 also use the trained model chosen
//! in Settings ▸ Roto Brush ([`set_model`], an `effectcraft_segment::MaskModel` such as
//! MobileSAM), falling back to the classic engine when none is installed. The model's id is part
//! of the chain seed, so choosing another recomputes the mattes. *Quality ▸ Best* segments at a
//! higher working resolution with more iterations.
//! *Version* is stored but all versions use the same (classic, graph-cut) engine; *Quality ▸
//! Best* segments at a higher working resolution with more iterations.
use std::collections::{BTreeMap, HashMap, VecDeque};
use std::sync::{Arc, Mutex, OnceLock, RwLock};
use std::sync::{Arc, Mutex, OnceLock};
use effectcraft_keyframe::Value;
use effectcraft_project::ParamUi;
use effectcraft_raster::Image;
use effectcraft_segment::MaskModel;
use effectcraft_track::roto::refine::{self, MatteParams};
use effectcraft_track::roto::{self as rb, FrameSeg, RotoData, SegOpts, rle};
use rayon::prelude::*;
@ -177,29 +173,8 @@ pub fn matte_params(params: &Params) -> MatteParams {
}
}
fn model_slot() -> &'static RwLock<Option<Arc<dyn MaskModel>>> {
static M: OnceLock<RwLock<Option<Arc<dyn MaskModel>>>> = OnceLock::new();
M.get_or_init(|| RwLock::new(None))
}
/// The trained model Roto Brush 2.0 / 3.0 use (Settings ▸ Roto Brush; `None`: the classic
/// engine for every version).
pub fn set_model(m: Option<Arc<dyn MaskModel>>) {
if let Ok(mut slot) = model_slot().write() {
*slot = m;
}
}
/// The model an instance segments with: its version's choice of the installed one.
pub fn model_for(params: &Params) -> Option<Arc<dyn MaskModel>> {
if e(params, "version") == 0 {
return None;
}
model_slot().read().ok().and_then(|m| m.clone())
}
/// Seed of the chain keys: what the frames are (the engine-maintained Input Key), the layer
/// size, the frame rate, the segmentation settings and the model.
/// size, the frame rate and the segmentation settings.
pub fn seed(params: &Params, layer_size: [f64; 2], fps: f64) -> u64 {
let mut h = 0xcbf2_9ce4_8422_2325;
fnv(&mut h, s(params, INPUT_KEY).as_bytes());
@ -214,7 +189,6 @@ pub fn seed(params: &Params, layer_size: [f64; 2], fps: f64) -> u64 {
fnv(&mut h, &v.to_bits().to_le_bytes());
}
fnv(&mut h, &[e(params, "quality") as u8, e(params, "version") as u8]);
fnv(&mut h, model_for(params).map_or(effectcraft_segment::CLASSICAL, |m| m.info().id).as_bytes());
h
}
@ -301,15 +275,13 @@ pub struct Chain {
pub keys: BTreeMap<i64, u64>,
pub opts: SegOpts,
pub refine_radius: f64,
/// The trained model (Version 2.0 / 3.0 with one installed).
pub model: Option<Arc<dyn MaskModel>>,
}
impl Chain {
pub fn new(params: &Params, layer_size: [f64; 2], fps: f64, scale: f64) -> Chain {
let data = data(params);
let keys = data.chain_keys(seed(params, layer_size, fps));
Chain { refine_radius: data.refine_radius(), data, keys, opts: seg_opts(params, scale), model: model_for(params) }
Chain { refine_radius: data.refine_radius(), data, keys, opts: seg_opts(params, scale) }
}
/// Frame `f`'s segmentation at this chain's scale: cached, or computed from the nearest
@ -359,7 +331,7 @@ impl Chain {
let img = frame(g)?;
let strokes = self.data.strokes_at(g);
let seg = match (&cur, g == base) {
(_, true) | (None, _) => rb::segment_with(&img, &strokes, None, &self.opts, self.refine_radius, self.model.as_deref()),
(_, true) | (None, _) => rb::segment(&img, &strokes, None, &self.opts, self.refine_radius),
(Some(p), false) => {
let src = self.data.source_of(g)?;
let pimg = match prev_img.take() {
@ -369,7 +341,7 @@ impl Chain {
if pimg.width != img.width || pimg.height != img.height {
return None;
}
rb::propagate_with(&pimg, p, &img, &strokes, &self.opts, self.refine_radius, self.model.as_deref())
rb::propagate(&pimg, p, &img, &strokes, &self.opts, self.refine_radius)
}
};
let seg = Arc::new(seg);

View File

@ -15,7 +15,7 @@ use effectcraft_raster::{Image, Px};
use rayon::prelude::*;
use crate::generate::value_noise;
use crate::util::{SimCache, hash1, params_key, params_key_except, unpremul};
use crate::util::{SimCache, hash1, params_key, unpremul};
use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider};
pub(crate) fn spec(id: &'static str, name: &'static str, params: Vec<crate::ParamSpec>, render: crate::RenderFn) -> EffectSpec {
@ -989,18 +989,11 @@ pub fn particle_state(id: &str, ctx: &EffectCtx) -> Option<Vec<crate::psim::SimP
"ec.sim.ccparticlesystems2" => (ps2_phys(ctx), &PS2_CACHE, 2),
_ => return None,
};
let key = sim_key(ctx, salt);
let key = params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, salt);
let st = accelerated(ctx, key, &phys).unwrap_or_else(|| simulate(cache, key, ctx.time, &phys));
Some(st.parts.iter().map(|q| crate::psim::SimParticle { p: q.p, v: q.v, age: q.age, life: q.life, rnd: q.rnd, id: q.id }).collect())
}
/// The simulation cache key of a stepped particle effect: every parameter but CC Particle World's
/// Effect Camera, which only changes how the particles are seen (animating it doesn't restart
/// the simulation).
fn sim_key(ctx: &EffectCtx, salt: u64) -> u64 {
params_key_except(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, salt, &[PW_EFFECT_CAMERA])
}
/// Run a particle system to layer time `t` through `cache`.
pub(crate) fn simulate(cache: &SimCache<PState>, key: u64, t: f64, phys: &Phys) -> std::sync::Arc<PState> {
cache.run(key, steps_at(t), PState::default, |st, i| phys.step(st, i))
@ -1075,90 +1068,6 @@ fn particle_world(ctx: &EffectCtx, b: Buf) -> Buf {
}
}
/// CC Particle World's Extras ▸ Effect Camera parameters (id prefix).
const PW_EFFECT_CAMERA: &str = "extras/effectCamera/";
/// Effect Camera ▸ FOV's default (degrees).
const PW_FOV: f64 = 50.0;
/// What CC Particle World sees its particles through.
enum PwCamera {
/// Extras ▸ Effect Camera, in world units (layer widths): `dist` from the world origin (the
/// layer centre) along −z, the world turned by `rot` (`None` = not turned) about the origin,
/// `focal` the scale at unit depth; particles nearer than `near` aren't drawn.
Effect { rot: Option<[[f32; 3]; 3]>, dist: f32, focal: f32, near: f32 },
/// The comp's active camera layer, on buffer-world points (After Effects then ignores the
/// Effect Camera).
Comp(crate::card3d::Proj),
}
/// A particle as the camera sees it: depth (larger is farther), buffer pixel, size scale `k`
/// (buffer pixels per world unit = `k · layer width · buffer scale`) and velocity in world units
/// on screen (× the same factor for buffer pixels).
struct PwSeen {
depth: f32,
x: f32,
y: f32,
k: f32,
v: [f32; 2],
}
impl PwCamera {
fn new(ctx: &EffectCtx, b: &Buf) -> PwCamera {
let [lw, lh] = ctx.layer_size;
if let Some(cam) = ctx.env.host.and_then(|h| h.comp_scene()).filter(|sc| sc.camera_layer).and_then(|sc| sc.camera) {
let c = [b.offset[0] + lw * 0.5 * b.scale, b.offset[1] + lh * 0.5 * b.scale];
return PwCamera::Comp(crate::card3d::comp_proj(&cam, c, b));
}
let f = |id: &str, d: f64| ctx.params.get(&format!("{PW_EFFECT_CAMERA}{id}")).map(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(d);
// The camera keeps the origin's plane at the scale Distance gives it as FOV changes
// (moving in for a wider view), so FOV sets how strong the perspective is. Distance 2
// shows the origin's plane at the layer's own scale.
let half = |fov: f64| (fov.clamp(1.0, 179.0).to_radians() * 0.5).tan();
let zoom = half(PW_FOV) / half(f("fov", PW_FOV));
let dist = f("distance", 2.0).max(0.01) * zoom;
let a = [f("rotationX", 0.0), f("rotationY", 0.0), f("rotationZ", 0.0)];
let rot = (a != [0.0; 3]).then(|| {
let r = crate::card3d::rot_ordered(a[0].to_radians(), a[1].to_radians(), a[2].to_radians(), 0);
r.map(|row| row.map(|v| v as f32))
});
PwCamera::Effect { rot, dist: dist as f32, focal: (2.0 * zoom) as f32, near: (0.025 * dist) as f32 }
}
/// How the camera sees world point `p` moving at `v`; `None` behind or too close to it.
fn see(&self, p: [f32; 3], v: [f32; 3], lw: f32, lh: f32, b: &Buf) -> Option<PwSeen> {
match self {
PwCamera::Effect { rot, dist, focal, near } => {
let turn = |r: &[[f32; 3]; 3], q: [f32; 3]| r.map(|row| row[0] * q[0] + row[1] * q[1] + row[2] * q[2]);
let (p, v) = match rot {
Some(r) => (turn(r, p), turn(r, v)),
None => (p, v),
};
let zc = dist + p[2];
if zc.is_nan() || zc <= *near {
return None;
}
let k = focal / zc;
let (x, y) = b.to_px([(lw * 0.5 + p[0] * lw * k) as f64, (lh * 0.5 + p[1] * lw * k) as f64]);
Some(PwSeen { depth: zc, x: x as f32, y: y as f32, k, v: [v[0], v[1]] })
}
PwCamera::Comp(proj) => {
let (s, lw, lh) = (b.scale, lw as f64, lh as f64);
let u = lw * s;
let at = [b.offset[0] + lw * 0.5 * s + p[0] as f64 * u, b.offset[1] + lh * 0.5 * s + p[1] as f64 * u, p[2] as f64 * u];
let seen = proj.view(at, [v[0] as f64, v[1] as f64, v[2] as f64])?;
let k = seen.scale;
Some(PwSeen {
depth: seen.depth as f32,
x: seen.at[0] as f32,
y: seen.at[1] as f32,
k: k as f32,
v: [(seen.vel[0] / k) as f32, (seen.vel[1] / k) as f32],
})
}
}
}
}
/// CC Particle World's sprites (far to near), `None` before anything is born.
fn particle_world_plan(ctx: &EffectCtx, b: &Buf) -> Option<SpritePlan> {
let pr = ctx.params;
@ -1167,7 +1076,7 @@ fn particle_world_plan(ctx: &EffectCtx, b: &Buf) -> Option<SpritePlan> {
if phys.rate <= 0.0 && ctx.time <= 0.0 {
return None;
}
let key = sim_key(ctx, 1);
let key = params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 1);
let st = accelerated(ctx, key, &phys).unwrap_or_else(|| simulate(&PW_CACHE, key, ctx.time, &phys));
let kind = pr.e("particle/particleType");
let birth = pr.color("particle/birthColor");
@ -1176,16 +1085,24 @@ fn particle_world_plan(ctx: &EffectCtx, b: &Buf) -> Option<SpritePlan> {
let svar = pr.f("particle/sizeVariation") as f32 / 100.0;
let max_op = pr.f("particle/maxOpacity") as f32 / 100.0;
let s = b.scale as f32;
let cam = PwCamera::new(ctx, b);
// Camera at distance 2 (world units = layer width) looking at the origin (layer centre).
let cam = 2.0f32;
let mut list: Vec<(f32, Sprite)> = st
.parts
.par_iter()
.filter_map(|q| {
let PwSeen { depth, x, y, k, v } = cam.see(q.p, q.v, lw, lh, b)?;
let zc = cam + q.p[2];
if zc <= 0.05 {
return None;
}
let k = cam / zc;
let x = lw * 0.5 + q.p[0] * lw * k;
let y = lh * 0.5 + q.p[1] * lw * k;
let (bx, by) = b.to_px([x as f64, y as f64]);
let (c, size) = particle_look(q, birth, death, bs, ds, svar, max_op);
let r = size * 0.5 * lw * k * s * 0.25;
let sp = type_sprite(kind, x, y, r, c, v[0] * lw * k * s, v[1] * lw * k * s, (q.id % 628) as f32 * 0.01 + q.age * 2.0);
Some((depth, sp))
let sp = type_sprite(kind, bx as f32, by as f32, r, c, q.v[0] * lw * k * s, q.v[1] * lw * k * s, (q.id % 628) as f32 * 0.01 + q.age * 2.0);
Some((zc, sp))
})
.collect();
list.sort_by(|a, b| b.0.total_cmp(&a.0));
@ -1250,7 +1167,7 @@ fn particle_systems2_plan(ctx: &EffectCtx, b: &Buf) -> SpritePlan {
let lh = ctx.layer_size[1] as f32;
let unit = lh.max(1.0);
let phys = ps2_phys(ctx);
let key = sim_key(ctx, 2);
let key = params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 2);
let st = accelerated(ctx, key, &phys).unwrap_or_else(|| simulate(&PS2_CACHE, key, ctx.time, &phys));
let kind = pr.e("particle/particleType");
let birth = pr.color("particle/birthColor");
@ -1523,11 +1440,6 @@ pub fn specs() -> Vec<EffectSpec> {
p("particle/birthColor", "Birth Color", col(1.0, 1.0, 0.0), ParamUi::Color),
p("particle/deathColor", "Death Color", col(0.6, 0.0, 0.0), ParamUi::Color),
p("particle/transferMode", "Transfer Mode", Value::Enum(0), popup(&["Composite", "Screen", "Add", "Black Matte"])),
p("extras/effectCamera/distance", "Distance", num(2.0), slider(0.0, 100.0, 0.0, 10.0, 2)),
p("extras/effectCamera/rotationX", "Rotation X", num(0.0), ParamUi::Angle),
p("extras/effectCamera/rotationY", "Rotation Y", num(0.0), ParamUi::Angle),
p("extras/effectCamera/rotationZ", "Rotation Z", num(0.0), ParamUi::Angle),
p("extras/effectCamera/fov", "FOV", num(PW_FOV), slider(1.0, 179.0, 10.0, 120.0, 1)),
p("extras/randomSeed", "Random Seed", num(0.0), slider(0.0, 10_000.0, 0.0, 1000.0, 0)),
],
particle_world,
@ -1620,24 +1532,18 @@ mod tests {
a.data.iter().zip(&b.data).map(|(p, q)| (0..4).map(|k| (p[k] - q[k]).abs()).sum::<f32>()).sum()
}
/// Centre of mass (luminance-weighted difference from `base`), `[-1, -1]` when equal.
fn mass(img: &Image, base: &Image) -> [f32; 2] {
let (mut m, mut sx, mut sy) = (0.0, 0.0, 0.0);
/// Centre of mass (luminance-weighted difference from `base`) in y.
fn mass_y(img: &Image, base: &Image) -> f32 {
let (mut m, mut sy) = (0.0, 0.0);
for y in 0..img.height {
for x in 0..img.width {
let i = img.idx(x, y);
let d: f32 = (0..3).map(|k| (img.data[i][k] - base.data[i][k]).abs()).sum();
m += d;
sx += d * x as f32;
sy += d * y as f32;
}
}
if m > 0.0 { [sx / m, sy / m] } else { [-1.0; 2] }
}
/// [`mass`] in y.
fn mass_y(img: &Image, base: &Image) -> f32 {
mass(img, base)[1]
if m > 0.0 { sy / m } else { -1.0 }
}
#[test]
@ -1789,119 +1695,6 @@ mod tests {
assert!(down > up + 2.0, "{up} {down}");
}
/// A comp whose camera is `camera` ([`crate::CompScene::camera`]); `layer`: a camera layer
/// (else the comp's default view).
struct Comp {
camera: [[f64; 4]; 3],
layer: bool,
}
impl crate::EffectHost for Comp {
fn layer(&self, _: u64, _: bool) -> Option<crate::LayerPixels> {
None
}
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
None
}
fn comp_scene(&self) -> Option<crate::CompScene> {
Some(crate::CompScene { camera: Some(self.camera), camera_layer: self.layer, light: None })
}
}
/// A camera `d` px in front of layer point `c` looking at it, moved right by `dx` px and
/// zoomed by `zoom` (1 = identity on the layer plane): `c + zoom·d·(p − c − dx) / (z + d)`.
fn cam(c: [f64; 2], d: f64, dx: f64, zoom: f64) -> [[f64; 4]; 3] {
let k = zoom * d;
[[k, 0.0, c[0], d * c[0] - k * (c[0] + dx)], [0.0, k, c[1], d * c[1] - k * c[1]], [0.0, 0.0, 1.0, d]]
}
fn pw_in(comp: &Comp, vals: &[(&str, Value)]) -> Image {
run_fx("ec.sim.ccparticleworld", vals, Image::new(48, 48), 1.0, EffectEnv { host: Some(comp), ..Default::default() }).img
}
fn finite(img: &Image) -> bool {
img.data.iter().all(|p| p.iter().all(|c| c.is_finite()))
}
/// #397: with a camera layer in the comp, the particles are seen through it.
#[test]
fn particle_world_follows_the_comp_camera() {
let v = pw_vals(3.0);
let blank = Image::new(48, 48);
let here = Comp { camera: cam([24.0, 24.0], 96.0, 0.0, 1.0), layer: true };
let base = pw_in(&here, &v);
assert!(base.data.iter().any(|p| p[3] > 0.0), "particles alive");
// Moving the camera right moves the particles left.
let moved = pw_in(&Comp { camera: cam([24.0, 24.0], 96.0, 8.0, 1.0), ..here }, &v);
let (a, b) = (mass(&base, &blank), mass(&moved, &blank));
assert!(b[0] < a[0] - 4.0 && (b[1] - a[1]).abs() < 2.0, "{a:?} -> {b:?}");
// Zooming in spreads them out.
let zoomed = pw_in(&Comp { camera: cam([24.0, 24.0], 96.0, 0.0, 2.0), ..here }, &v);
let cover = |i: &Image| i.data.iter().filter(|p| p[3] > 0.01).count();
assert!(cover(&zoomed) > cover(&base), "{} {}", cover(&zoomed), cover(&base));
// The Effect Camera is ignored then.
let mut turned = v.clone();
turned.push(("extras/effectCamera/rotationY", num(60.0)));
assert_eq!(pw_in(&here, &turned), base);
// The comp's default view (no camera layer) leaves it to the Effect Camera.
let default_view = Comp { layer: false, ..here };
assert_eq!(pw_in(&default_view, &v), run("ec.sim.ccparticleworld", &v, blank.clone(), 1.0));
// A camera facing away, or a degenerate one, draws nothing and never NaN.
for camera in [[[96.0, 0.0, 24.0, 0.0], [0.0, 96.0, 24.0, 0.0], [0.0, 0.0, -1.0, -96.0]], [[0.0; 4]; 3], [[f64::NAN; 4]; 3]] {
assert_eq!(pw_in(&Comp { camera, layer: true }, &v), blank);
}
// A camera among the particles draws the ones in front of it.
let inside = pw_in(&Comp { camera: cam([24.0, 24.0], 1.0, 0.0, 1.0), ..here }, &v);
assert!(finite(&inside));
}
/// Extras ▸ Effect Camera: the defaults are the fixed camera Particle World always had.
#[test]
fn particle_world_effect_camera() {
let v = pw_vals(5.0);
let base = run("ec.sim.ccparticleworld", &v, Image::new(48, 48), 1.0);
let with = |extra: &[(&'static str, Value)]| {
let mut w = v.clone();
w.extend_from_slice(extra);
run("ec.sim.ccparticleworld", &w, Image::new(48, 48), 1.0)
};
// Missing parameters (projects saved before) read as the defaults.
let spec = crate::find("ec.sim.ccparticleworld").unwrap();
let mut params = crate::Params { values: spec.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
params.values.retain(|k, _| !k.starts_with(PW_EFFECT_CAMERA));
for (k, val) in &v {
params.values.insert(k.to_string(), val.clone());
}
let ctx = EffectCtx { params: &params, time: 1.0, layer_size: [48.0, 48.0], seed: 1, adjustment: false, env: EffectEnv::default() };
assert_eq!(crate::apply(spec, &ctx, Buf { img: Image::new(48, 48), offset: [0.0; 2], scale: 1.0 }).img, base);
assert_eq!(with(&[("extras/effectCamera/fov", num(f64::NAN))]), base);
// Turning or pulling back the camera changes the view.
let turned = with(&[("extras/effectCamera/rotationY", num(60.0))]);
assert!(diff(&turned, &base) > 1.0);
let cover = |i: &Image| i.data.iter().filter(|p| p[3] > 0.01).count();
let far = with(&[("extras/effectCamera/distance", num(4.0))]);
assert!(cover(&far) < cover(&base), "{} {}", cover(&far), cover(&base));
assert!(diff(&with(&[("extras/effectCamera/fov", num(120.0))]), &base) > 0.1);
// Extremes never panic or produce NaN.
for extra in [
("extras/effectCamera/distance", num(0.0)),
("extras/effectCamera/distance", num(-5.0)),
("extras/effectCamera/fov", num(0.0)),
("extras/effectCamera/fov", num(180.0)),
("extras/effectCamera/rotationX", num(1e12)),
] {
assert!(finite(&with(std::slice::from_ref(&extra))), "{extra:?}");
}
// Moving the camera doesn't restart the simulation.
let key = |pr: &crate::Params| {
sim_key(&EffectCtx { params: pr, time: 1.0, layer_size: [48.0, 48.0], seed: 1, adjustment: false, env: EffectEnv::default() }, 1)
};
let mut moved = params.clone();
moved.values.insert("extras/effectCamera/rotationZ".into(), num(30.0));
assert_eq!(key(&params), key(&moved));
moved.values.insert("extras/randomSeed".into(), num(9.0));
assert_ne!(key(&params), key(&moved));
}
#[test]
fn particle_systems2_seek_and_direction() {
let v = vec![

View File

@ -2468,7 +2468,7 @@ mod tests {
None
}
fn comp_scene(&self) -> Option<crate::CompScene> {
Some(crate::CompScene { camera: self.camera, camera_layer: true, light: self.light })
Some(crate::CompScene { camera: self.camera, light: self.light })
}
}

View File

@ -583,13 +583,6 @@ fn smooth_flow(flow: &mut Flow, pm: &PixelMotion) {
/// The motion field from frame `a` to frame `b` (or from the warp layer's frames).
pub fn timewarp_flow(a: &Image, b: &Image, pm: &PixelMotion) -> Flow {
timewarp_flow_search(a, b, pm, 4)
}
/// [`timewarp_flow`] with an explicit coarse search radius: Timewarp's own resampling needs
/// little (motion between adjacent source frames at near-100% speed), but Pixel Motion Blur
/// must find fast full-frame displacements (#535).
fn timewarp_flow_search(a: &Image, b: &Image, pm: &PixelMotion, radius: i32) -> Flow {
let ga = motion_grey(&crop_edges(a, pm.crops), pm.weights, 1.0);
let gain = if pm.correct_luminance {
let (ma, mb) = (mean_grey(&ga), mean_grey(&motion_grey(b, pm.weights, 1.0)));
@ -598,7 +591,7 @@ fn timewarp_flow_search(a: &Image, b: &Image, pm: &PixelMotion, radius: i32) ->
1.0
};
let gb = motion_grey(&crop_edges(b, pm.crops), pm.weights, gain);
let mut flow = effectcraft_raster::flow::block_flow_window(&ga, &gb, pm.tile, pm.window, radius);
let mut flow = effectcraft_raster::flow::block_flow_window(&ga, &gb, pm.tile, pm.window, 4);
smooth_flow(&mut flow, pm);
flow
}
@ -930,71 +923,12 @@ fn wide_time(ctx: &EffectCtx, b: Buf) -> Buf {
mean_of(ctx, b, &times)
}
fn shutter_times(ctx: &EffectCtx) -> Vec<f64> {
fn pixel_motion_blur(ctx: &EffectCtx, b: Buf) -> Buf {
let angle = if ctx.params.e("shutterControl") == 1 { ctx.params.f("shutterAngle") } else { 180.0 };
let n = ctx.params.f("shutterSamples").round().clamp(1.0, 64.0) as usize;
let span = angle.clamp(0.0, 720.0) / 360.0 / ctx.fps();
(0..n).map(|i| ctx.time - span * 0.5 + if n > 1 { span * i as f64 / (n - 1) as f64 } else { span * 0.5 }).collect()
}
fn pixel_motion_blur(ctx: &EffectCtx, b: Buf) -> Buf {
match pmb_plan(ctx).as_ref().map(TimewarpPlan::render_cpu) {
Some(blur) => blur,
None => mean_of(ctx, b, &shutter_times(ctx)),
}
}
/// Pixel Motion Blur as a [`TimewarpPlan`]: each shutter instant is decoded between the two
/// source frames it falls between along a measured motion field (the same synthesis Timewarp
/// uses), and the instants average into the blur. Vector Detail sets the spacing of the motion
/// vectors, so a coarse grid follows one big motion and a fine grid separates neighbouring
/// ones (#535). `None` without a host — the caller then falls back. Shared by the CPU effect
/// and the GPU compositor (`fx_time`), which renders the plan like Timewarp's.
pub fn pmb_plan(ctx: &EffectCtx) -> Option<TimewarpPlan> {
ctx.env.host?;
let fps = ctx.fps();
let samples = shutter_times(ctx);
let n = samples.len();
let mut times = Vec::new();
for &s in &samples {
let (f0, _) = tw_split(s, fps);
times.push(f0 / fps);
times.push((f0 + 1.0) / fps);
}
let fr = fetch(ctx, &times)?;
let idx = |t: f64| times.iter().position(|q| (q - t).abs() < 1e-9).unwrap_or(0);
let mut pm = PixelMotion::from(ctx, fr.grid.scale);
// PMB's own Vector Detail: 0 → one vector per 32 px, 100 → one per 2 px.
let detail = (ctx.params.f("vectorDetail") / 100.0).clamp(0.0, 1.0);
pm.tile = (32.0 * (1.0f64 / 16.0).powf(detail) * fr.grid.scale).round().max(2.0) as u32;
let mut flows: Vec<FlowEntry> = vec![];
let mut steps = vec![];
let w = 1.0 / n as f32;
for &s in &samples {
let (f0, frac) = tw_split(s, fps);
let (i0, i1) = (idx(f0 / fps), idx((f0 + 1.0) / fps));
let step = if frac < 1e-4 {
TwStep::Whole(i0)
} else {
let flow = match flows.iter().position(|fl| fl.key == f0 as i64) {
Some(p) => p,
None => {
let reach = (fr.grid.img.width.max(fr.grid.img.height) / 4).clamp(8, 64);
flows.push(FlowEntry { key: f0 as i64, flow: timewarp_flow_search(&fr.imgs[i0], &fr.imgs[i1], &pm, reach as i32) });
flows.len() - 1
}
};
TwStep::Motion { a: i0, b: i1, w: frac, flow }
};
steps.push((step, w));
}
Some(TimewarpPlan { grid: fr.grid, frames: fr.imgs, steps, flows: flows.into_iter().map(|f| f.flow).collect(), pm })
}
/// A measured flow with the source frame pair it came from (plans cache one per pair).
struct FlowEntry {
key: i64,
flow: Flow,
let times: Vec<f64> = (0..n).map(|i| ctx.time - span * 0.5 + if n > 1 { span * i as f64 / (n - 1) as f64 } else { span * 0.5 }).collect();
mean_of(ctx, b, &times)
}
#[cfg(test)]

View File

@ -424,24 +424,7 @@ pub fn remap(src: &Image, clamp: bool, f: impl Fn(f64, f64) -> Option<(f64, f64)
/// Layer rectangle in buffer pixels: (x0, y0, width, height).
pub fn layer_rect(ctx: &crate::EffectCtx, b: &crate::Buf) -> (f64, f64, f64, f64) {
layer_rect_at(ctx, b.offset, b.scale)
}
/// [`layer_rect`] in the pixels of a buffer with `offset` and `scale` (see [`crate::Buf`];
/// GPU buffers): the layer bounds start at effect-space (0, 0).
pub fn layer_rect_at(ctx: &crate::EffectCtx, offset: [f64; 2], scale: f64) -> (f64, f64, f64, f64) {
(offset[0], offset[1], ctx.layer_size[0] * scale, ctx.layer_size[1] * scale)
}
/// Transparent pixels to add on every side ([`crate::Buf::pad`]) of a buffer `size` pixels big,
/// with `offset` and `scale`, so it covers the whole layer rectangle (at most 4096). A text or
/// shape layer's buffer only surrounds its content, but its layer bounds are comp-sized (see
/// the renderer's `effect_bounds`): an effect that fills the layer, such as CC Tiler, fills
/// those as in After Effects (#659).
pub fn layer_cover_pad(ctx: &crate::EffectCtx, size: [u32; 2], offset: [f64; 2], scale: f64) -> u32 {
let (x0, y0, w, h) = layer_rect_at(ctx, offset, scale);
let need = [-x0, -y0, x0 + w - f64::from(size[0]), y0 + h - f64::from(size[1])].into_iter().fold(0.0, f64::max);
need.ceil().min(4096.0) as u32
(b.offset[0], b.offset[1], ctx.layer_size[0] * b.scale, ctx.layer_size[1] * b.scale)
}
/// Resample another layer (a layer parameter) into `b`'s pixel grid. With `stretch` the other
@ -461,17 +444,6 @@ pub fn fit_layer(ctx: &crate::EffectCtx, b: &crate::Buf, other: &crate::LayerPix
})
}
/// Resample a buffer into another buffer's pixel grid without stretching its layer space.
/// Used to composite the pre-effect image after earlier effects expand or shift the bounds.
pub fn fit_buffer(source: &crate::Buf, width: u32, height: u32, offset: [f64; 2], scale: f64) -> Image {
let inv = 1.0 / scale.max(1e-9);
gen_image(width, height, |x, y| {
let sx = (x as f64 + 0.5 - offset[0]) * inv * source.scale + source.offset[0];
let sy = (y as f64 + 0.5 - offset[1]) * inv * source.scale + source.offset[1];
source.img.sample_bilinear(sx, sy)
})
}
/// The layer chosen in layer parameter `id` fitted to `b` (see [`fit_layer`]), or a copy of
/// `b`'s own pixels when none is chosen / available (After Effects' "self" behaviour).
pub fn layer_or_self(ctx: &crate::EffectCtx, b: &crate::Buf, id: &str, masks_and_effects: bool, stretch: bool) -> Image {
@ -484,15 +456,9 @@ pub fn layer_or_self(ctx: &crate::EffectCtx, b: &crate::Buf, id: &str, masks_and
/// A stable hash of an effect instance's evaluated parameters, seed, layer size and buffer
/// scale (cache key for simulations).
pub fn params_key(ctx: &crate::EffectCtx, b: &crate::Buf, salt: u64) -> u64 {
params_key_except(ctx, b, salt, &[])
}
/// [`params_key`] without the parameters whose ids start with one of `skip` (ones that only
/// change how the simulation is viewed, so animating them doesn't restart it).
pub fn params_key_except(ctx: &crate::EffectCtx, b: &crate::Buf, salt: u64, skip: &[&str]) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
let mut keys: Vec<&String> = ctx.params.values.keys().filter(|k| !skip.iter().any(|s| k.starts_with(s))).collect();
let mut keys: Vec<&String> = ctx.params.values.keys().collect();
keys.sort();
for k in keys {
k.hash(&mut h);

View File

@ -385,9 +385,8 @@ pub fn specs() -> Vec<EffectSpec> {
],
cineon,
),
// `lut`: a path to a `.cube` file (Effect Controls' Choose…, asked for when the effect is
// applied, as in After Effects), or the `.cube` text itself (commands/automation).
spec("ec.utility.applylut", "Apply Color LUT", vec![p("lut", "LUT", Value::Str(String::new()), crate::file_ui(&["cube"]))], apply_lut),
// `lut`: a path to a `.cube` file, or the `.cube` text itself (set via commands/automation).
spec("ec.utility.applylut", "Apply Color LUT", vec![p("lut", "LUT", Value::Str(String::new()), ParamUi::Hidden)], apply_lut),
]
}

View File

@ -2,9 +2,8 @@
//!
//! The GPU compositor rasterises these effects from geometry the CPU computes (bolts, strokes,
//! glyphs, audio marks, wave lists, flood-fill regions, sampled colours); the CPU effects were
//! refactored to build the same plans. The hashes guard against unintended output changes;
//! intended behavior changes require a focused regression test before re-pinning. Each effect
//! renders on a generated layer with masks, another layer and an audio track, at default
//! refactored to build the same plans, and must render exactly what they did before. Each
//! effect renders on a generated layer with masks, another layer and an audio track, at default
//! and non-default parameters and two layer times, in an 8 bpc pipeline (input and output
//! clamped and quantised) and a 32 bpc one, at full and at half resolution. Each frame's pixels
//! are hashed (FNV-1a over the `f32` bits) and compared with the pinned hashes below.
@ -324,70 +323,6 @@ fn hashes() -> Vec<(String, u64)> {
v
}
/// Pin the intended linear spectrum behavior independently of the pixel hash table.
#[test]
fn audio_spectrum_uses_linear_frequency_and_amplitude() {
struct Tone {
frequency: f64,
amplitude: f64,
}
impl EffectHost for Tone {
fn layer(&self, _: u64, _: bool) -> Option<LayerPixels> {
None
}
fn audio(&self, _: u64, start: f64, frames: usize, rate: u32) -> Option<Vec<f32>> {
Some(
(0..frames)
.flat_map(|i| {
let t = start + i as f64 / rate as f64;
let sample = (self.amplitude * (t * self.frequency * std::f64::consts::TAU).sin()) as f32;
[sample, sample]
})
.collect(),
)
}
}
let id = "ec.generate.audiospectrum";
let spec = find(id).unwrap();
let size = [320.0, 100.0];
let mut params = Params { values: spec.params.iter().map(|p| (p.id.to_string(), default_value(p, size))).collect() };
for (key, value) in [
("audioLayer", Value::Layer(Some(MAP))),
("startPoint", pt(0.0, 50.0)),
("endPoint", pt(320.0, 50.0)),
("startFrequency", n(0.0)),
("endFrequency", n(1500.0)),
("frequencyBands", n(33.0)),
("maximumHeight", n(40.0)),
("audioDuration", n(256.0)),
] {
params.values.insert(key.to_string(), value);
}
let buf = Buf { img: Image::new(320, 100), offset: [0.0; 2], scale: 1.0 };
for (frequency, expected_band) in [(375.0, 8), (750.0, 16), (1125.0, 24)] {
let mut peaks = Vec::new();
for amplitude in [0.0, 0.125, 0.25] {
let host = Tone { frequency, amplitude };
let env = EffectEnv { host: Some(&host), comp_time: 1.0, ..Default::default() };
let ctx = EffectCtx { params: &params, time: 1.0, layer_size: size, seed: 1, adjustment: false, env };
let plan = effectcraft_effects::marks_plan(id, &ctx, &buf).unwrap();
assert_eq!(plan.segs.len(), 33);
let heights: Vec<f64> = plan.segs.iter().map(|seg| (seg.b[1] - seg.a[1]).abs()).collect();
assert!(heights.iter().all(|height| height.is_finite()));
if amplitude == 0.0 {
assert!(heights.iter().all(|height| *height == 0.0), "silence has no bar height");
continue;
}
let (band, height) = heights.iter().enumerate().max_by(|(_, a), (_, b)| a.total_cmp(b)).unwrap();
assert_eq!(band, expected_band, "{frequency} Hz uses its linear frequency position");
// A windowed pure tone retains its amplitude, allowing for the finite Hann window.
assert!((height - amplitude * 40.0).abs() < 0.01, "{frequency} Hz at {amplitude}: height {height}");
peaks.push(*height);
}
assert!((peaks[1] - 2.0 * peaks[0]).abs() < 1e-6, "doubling the signal doubles the height: {peaks:?}");
}
}
#[test]
fn plan_effects_golden_hashes() {
let got = hashes();
@ -585,25 +520,24 @@ const GOLDEN: &[(&str, u64)] = &[
("ec.generate.eyedropperfill/2/32bpc/t1.3", 0x500e27795935722a),
("ec.generate.eyedropperfill/2/32bpc/half/t0.4", 0x480fd6a293c6597c),
("ec.generate.eyedropperfill/2/32bpc/half/t1.3", 0x480fd6a293c6597c),
// Linear frequency bands and amplitude from 581326ec; all other effects retain their hashes.
("ec.generate.audiospectrum/0/8bpc/t0.4", 0x07be49f76890493c),
("ec.generate.audiospectrum/0/8bpc/t1.3", 0xc0109e19c3958517),
("ec.generate.audiospectrum/0/32bpc/t0.4", 0x24fe2e48cc432c1f),
("ec.generate.audiospectrum/0/32bpc/t1.3", 0x9bdbf835e7d42f78),
("ec.generate.audiospectrum/0/32bpc/half/t0.4", 0x445e16a81e313a40),
("ec.generate.audiospectrum/0/32bpc/half/t1.3", 0x88849fde30e6b048),
("ec.generate.audiospectrum/1/8bpc/t0.4", 0xf95a769e610688cc),
("ec.generate.audiospectrum/1/8bpc/t1.3", 0x7df3a63a59a10c38),
("ec.generate.audiospectrum/1/32bpc/t0.4", 0x53f3e442e240599b),
("ec.generate.audiospectrum/1/32bpc/t1.3", 0x213066492a32f6a8),
("ec.generate.audiospectrum/1/32bpc/half/t0.4", 0x38ceb79d523c7402),
("ec.generate.audiospectrum/1/32bpc/half/t1.3", 0x7d63c37d260eb112),
("ec.generate.audiospectrum/2/8bpc/t0.4", 0x73c15c41ad557cec),
("ec.generate.audiospectrum/2/8bpc/t1.3", 0x0bb38170a3fbb6fd),
("ec.generate.audiospectrum/2/32bpc/t0.4", 0x2ac65e1ca97db691),
("ec.generate.audiospectrum/2/32bpc/t1.3", 0xa8be6d0571415ab0),
("ec.generate.audiospectrum/2/32bpc/half/t0.4", 0x318b82f955d58300),
("ec.generate.audiospectrum/2/32bpc/half/t1.3", 0x4775b23a8cc7c6c6),
("ec.generate.audiospectrum/0/8bpc/t0.4", 0xb33f32432e79920c),
("ec.generate.audiospectrum/0/8bpc/t1.3", 0xb8256632165ce18a),
("ec.generate.audiospectrum/0/32bpc/t0.4", 0xf98f14d21a3c442e),
("ec.generate.audiospectrum/0/32bpc/t1.3", 0x18211baf9f0d4d41),
("ec.generate.audiospectrum/0/32bpc/half/t0.4", 0xa8d8b213c0f7d53e),
("ec.generate.audiospectrum/0/32bpc/half/t1.3", 0xc4fe7346c0da92b8),
("ec.generate.audiospectrum/1/8bpc/t0.4", 0xf37ca291072917fa),
("ec.generate.audiospectrum/1/8bpc/t1.3", 0xb59f0c33274846b9),
("ec.generate.audiospectrum/1/32bpc/t0.4", 0xaf6d6567f830fc1e),
("ec.generate.audiospectrum/1/32bpc/t1.3", 0x48ba15c0c33741c5),
("ec.generate.audiospectrum/1/32bpc/half/t0.4", 0xaceadbc6ccabd6dc),
("ec.generate.audiospectrum/1/32bpc/half/t1.3", 0xc62932f485afd259),
("ec.generate.audiospectrum/2/8bpc/t0.4", 0x20267f8c48b2a0c4),
("ec.generate.audiospectrum/2/8bpc/t1.3", 0xaf6447ee94fe284c),
("ec.generate.audiospectrum/2/32bpc/t0.4", 0x6850f20c7cfea994),
("ec.generate.audiospectrum/2/32bpc/t1.3", 0x7fc36e46f726f3f4),
("ec.generate.audiospectrum/2/32bpc/half/t0.4", 0x96e53e6547ea4822),
("ec.generate.audiospectrum/2/32bpc/half/t1.3", 0x69643e75820123ef),
("ec.generate.audiowaveform/0/8bpc/t0.4", 0x68a7733a023bc9f1),
("ec.generate.audiowaveform/0/8bpc/t1.3", 0x57e13a1aaa458b14),
("ec.generate.audiowaveform/0/32bpc/t0.4", 0xb78e946d794b5ade),

View File

@ -10,16 +10,12 @@
//! `f32` bits) and compared with the pinned hashes below: any change to the CPU output of these
//! effects, however small, fails the test.
//!
//! The hashes are exact `f32` bit patterns, so they depend on the platform's libm (`sin`, `cos`,
//! `powf`…); they are pinned for aarch64 macOS (where the gates run). Elsewhere the test only
//! checks that every case renders deterministically. A case whose frame differs between macOS
//! releases only through libm also accepts that release's hash ([`LIBM_VARIANTS`]).
//! The hashes are exact `f32` bit patterns, so they depend on the platform's libm; they are
//! pinned for aarch64 macOS (where the gates run). Elsewhere the test only checks that every
//! case renders deterministically.
//!
//! `SIM_GOLDEN_PRINT=1` prints the table (to re-pin after an intended change);
//! `SIM_GOLDEN_OUT=<dir>` also writes every frame's raw pixels (`f32` little-endian RGBA) there.
//! To tell a libm difference from a change in the code, render the cases with `SIM_GOLDEN_OUT`
//! here and at the commit that pinned the hash, and compare the frames: identical frames mean
//! the code didn't change, and the new hash goes into [`LIBM_VARIANTS`].
use effectcraft_effects::{Buf, EffectCtx, EffectEnv, EffectHost, LayerPixels, Params, apply, default_value, find};
use effectcraft_keyframe::Value;
@ -320,41 +316,17 @@ fn sim_effects_golden_hashes() {
// Deterministic: a second pass (warm simulation caches) renders the same frames.
assert_eq!(got, hashes(), "simulation effects render deterministically");
if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
let bad: Vec<String> = got
.iter()
.zip(GOLDEN)
.filter(|((n, h), (gn, gh))| n != gn || h != gh)
.map(|((n, h), (_, gh))| format!("{n}: 0x{h:016x} (pinned 0x{gh:016x})"))
.collect();
assert_eq!(got.len(), GOLDEN.len(), "case count");
let bad = mismatches(&got, GOLDEN, LIBM_VARIANTS);
assert!(bad.is_empty(), "CPU output changed (if it may be libm, see the module docs):\n{}", bad.join("\n"));
assert!(bad.is_empty(), "CPU output changed:\n{}", bad.join("\n"));
}
}
/// The cases whose hash is neither the pinned one nor one of the case's [`LIBM_VARIANTS`].
fn mismatches(got: &[(String, u64)], golden: &[(&str, u64)], variants: &[(&str, u64, &str)]) -> Vec<String> {
got.iter()
.zip(golden)
.filter(|((n, h), (gn, gh))| n != gn || (h != gh && !variants.iter().any(|(vn, vh, _)| vn == n && vh == h)))
.map(|((n, h), (_, gh))| format!("{n}: 0x{h:016x} (pinned 0x{gh:016x})"))
.collect()
}
/// A libm variant passes for its own case only (#232).
#[test]
fn libm_variants_are_accepted_for_their_case_only() {
let golden = [("a", 1), ("b", 2)];
let variants = [("a", 9, "another libm")];
let got = |a: u64, b: u64| vec![("a".to_string(), a), ("b".to_string(), b)];
assert!(mismatches(&got(1, 2), &golden, &variants).is_empty());
assert!(mismatches(&got(9, 2), &golden, &variants).is_empty(), "the variant");
assert_eq!(mismatches(&got(9, 9), &golden, &variants), ["b: 0x0000000000000009 (pinned 0x0000000000000002)"], "not another case's");
assert_eq!(mismatches(&got(3, 2), &golden, &variants).len(), 1);
assert!(LIBM_VARIANTS.iter().all(|(n, ..)| GOLDEN.iter().any(|(g, _)| g == n)), "every variant names a pinned case");
}
/// Hashes also accepted for a case: the same frame rendered by the same code with another macOS
/// release's libm (case, hash, where it was seen).
const LIBM_VARIANTS: &[(&str, u64, &str)] = &[
// #232: the pinning commit (ce6ece4) renders byte-identical frames there.
("ec.sim.ccmrmercury/1/32bpc/t2.2", 0x3823bd73a3cdab0d, "macOS 27.2 (26B5091g), rustc 1.98.0"),
];
/// Pinned hashes (aarch64 macOS).
const GOLDEN: &[(&str, u64)] = &[
("ec.sim.ccrainfall/0/8bpc/t0.5", 0xc9f1eb02dd83841c),

View File

@ -20,7 +20,6 @@ effectcraft-path = { workspace = true }
effectcraft-text = { workspace = true }
effectcraft-effects = { workspace = true }
effectcraft-render = { workspace = true }
effectcraft-segment = { workspace = true }
effectcraft-track = { workspace = true }
effectcraft-model = { workspace = true }
effectcraft-lottie = { workspace = true }

View File

@ -1,255 +0,0 @@
{
"format": "effectcraft.animationPresets/1",
"notes": "Original EffectCraft transition presets (MIT OR Apache-2.0), written from scratch on EffectCraft's own Transition effects. Key times are seconds from the current time (layer time); effect paths are the effect's parameter ids; layer paths are property paths such as transform/opacity.",
"presets": [
{
"id": "fadeIn",
"name": "Fade In",
"category": "Transitions",
"direction": "in",
"description": "The layer fades up from transparent over half a second.",
"layer": { "keys": { "transform/opacity": [[0, 0], [0.5, 100]] }, "ease": true }
},
{
"id": "fadeOut",
"name": "Fade Out",
"category": "Transitions",
"direction": "out",
"description": "The layer fades down to transparent over half a second.",
"layer": { "keys": { "transform/opacity": [[0, 100], [0.5, 0]] }, "ease": true }
},
{
"id": "wipeInFromLeft",
"name": "Wipe In from Left",
"category": "Transitions",
"direction": "in",
"description": "A soft edge sweeps across from the left and reveals the layer.",
"effects": [
{ "effect": "ec.transition.linearwipe", "set": { "angle": 270, "feather": 40 }, "keys": { "completion": [[0, 100], [1, 0]] }, "ease": true }
]
},
{
"id": "wipeInFromRight",
"name": "Wipe In from Right",
"category": "Transitions",
"direction": "in",
"description": "A soft edge sweeps across from the right and reveals the layer.",
"effects": [
{ "effect": "ec.transition.linearwipe", "set": { "angle": 90, "feather": 40 }, "keys": { "completion": [[0, 100], [1, 0]] }, "ease": true }
]
},
{
"id": "wipeInFromTop",
"name": "Wipe In from Top",
"category": "Transitions",
"direction": "in",
"description": "The layer is revealed from the top edge downwards.",
"effects": [
{ "effect": "ec.transition.linearwipe", "set": { "angle": 0, "feather": 30 }, "keys": { "completion": [[0, 100], [0.9, 0]] }, "ease": true }
]
},
{
"id": "wipeInFromBottom",
"name": "Wipe In from Bottom",
"category": "Transitions",
"direction": "in",
"description": "The layer is revealed from the bottom edge upwards.",
"effects": [
{ "effect": "ec.transition.linearwipe", "set": { "angle": 180, "feather": 30 }, "keys": { "completion": [[0, 100], [0.9, 0]] }, "ease": true }
]
},
{
"id": "diagonalSweepIn",
"name": "Diagonal Sweep In",
"category": "Transitions",
"direction": "in",
"description": "A wide, very soft edge sweeps in from the bottom-left corner.",
"effects": [
{ "effect": "ec.transition.linearwipe", "set": { "angle": 45, "feather": 160 }, "keys": { "completion": [[0, 100], [1.2, 0]] }, "ease": true }
]
},
{
"id": "wipeOutToRight",
"name": "Wipe Out to Right",
"category": "Transitions",
"direction": "out",
"description": "A soft edge travels left to right and takes the layer away.",
"effects": [
{ "effect": "ec.transition.linearwipe", "set": { "angle": 90, "feather": 40 }, "keys": { "completion": [[0, 0], [1, 100]] }, "ease": true }
]
},
{
"id": "clockReveal",
"name": "Clock Reveal",
"category": "Transitions",
"direction": "in",
"description": "A clock hand sweeps once around the centre and reveals the layer.",
"effects": [
{ "effect": "ec.transition.radialwipe", "set": { "wipe": 1, "feather": 6 }, "keys": { "completion": [[0, 100], [1.2, 0]] }, "ease": true }
]
},
{
"id": "fanOpen",
"name": "Fan Open",
"category": "Transitions",
"direction": "in",
"description": "The layer opens like a fan, from the top down both sides.",
"effects": [
{ "effect": "ec.transition.radialwipe", "set": { "wipe": 2, "startAngle": 180, "feather": 10 }, "keys": { "completion": [[0, 100], [1, 0]] }, "ease": true }
]
},
{
"id": "clockWipeOut",
"name": "Clock Wipe Out",
"category": "Transitions",
"direction": "out",
"description": "A clock hand sweeps once around the centre and removes the layer.",
"effects": [
{ "effect": "ec.transition.radialwipe", "set": { "feather": 6 }, "keys": { "completion": [[0, 0], [1.2, 100]] }, "ease": true }
]
},
{
"id": "blindsOpen",
"name": "Blinds Open",
"category": "Transitions",
"direction": "in",
"description": "Vertical slats widen until the layer is whole.",
"effects": [
{ "effect": "ec.transition.venetian", "set": { "direction": 0, "width": 60, "feather": 4 }, "keys": { "completion": [[0, 100], [0.9, 0]] }, "ease": true }
]
},
{
"id": "slatsDown",
"name": "Slats Down",
"category": "Transitions",
"direction": "in",
"description": "Horizontal slats thicken until the layer is whole.",
"effects": [
{ "effect": "ec.transition.venetian", "set": { "direction": 90, "width": 36, "feather": 3 }, "keys": { "completion": [[0, 100], [0.8, 0]] } }
]
},
{
"id": "blindsClose",
"name": "Blinds Close",
"category": "Transitions",
"direction": "out",
"description": "Vertical slats narrow until the layer is gone.",
"effects": [
{ "effect": "ec.transition.venetian", "set": { "direction": 0, "width": 60, "feather": 4 }, "keys": { "completion": [[0, 0], [0.9, 100]] }, "ease": true }
]
},
{
"id": "pixelDissolveIn",
"name": "Pixel Dissolve In",
"category": "Transitions",
"direction": "in",
"description": "Small hard-edged blocks pop in at random until the layer is whole.",
"effects": [
{
"effect": "ec.transition.blockdissolve",
"set": { "blockWidth": 6, "blockHeight": 6, "softEdges": false },
"keys": { "completion": [[0, 100], [1.2, 0]] }
}
]
},
{
"id": "mosaicFadeIn",
"name": "Mosaic Fade In",
"category": "Transitions",
"direction": "in",
"description": "Large soft tiles fade in at random.",
"effects": [
{
"effect": "ec.transition.blockdissolve",
"set": { "blockWidth": 48, "blockHeight": 48, "feather": 16 },
"keys": { "completion": [[0, 100], [1.2, 0]] },
"ease": true
}
]
},
{
"id": "pixelDissolveOut",
"name": "Pixel Dissolve Out",
"category": "Transitions",
"direction": "out",
"description": "Small hard-edged blocks drop out at random until the layer is gone.",
"effects": [
{
"effect": "ec.transition.blockdissolve",
"set": { "blockWidth": 6, "blockHeight": 6, "softEdges": false },
"keys": { "completion": [[0, 0], [1.2, 100]] }
}
]
},
{
"id": "cardFlipIn",
"name": "Card Flip In",
"category": "Transitions",
"direction": "in",
"description": "A grid of cards turns over, left to right, to show the layer.",
"effects": [
{
"effect": "ec.transition.cardwipe",
"set": { "rows": 4, "columns": 8, "flipAxis": 1, "flipOrder": 1, "transitionWidth": 70 },
"keys": { "completion": [[0, 100], [1.4, 0]] },
"ease": true
}
]
},
{
"id": "cardFlipOut",
"name": "Card Flip Out",
"category": "Transitions",
"direction": "out",
"description": "A grid of cards turns over, left to right, and the layer is gone.",
"effects": [
{
"effect": "ec.transition.cardwipe",
"set": { "rows": 4, "columns": 8, "flipAxis": 1, "transitionWidth": 70 },
"keys": { "completion": [[0, 0], [1.4, 100]] },
"ease": true
}
]
},
{
"id": "gridDoorsIn",
"name": "Grid Doors In",
"category": "Transitions",
"direction": "in",
"description": "Tiles open like small doors from the centre outwards.",
"effects": [
{ "effect": "ec.transition.ccgridwipe", "set": { "shape": 0, "tiles": 12, "reverse": true }, "keys": { "completion": [[0, 100], [1, 0]] }, "ease": true }
]
},
{
"id": "lensPopIn",
"name": "Lens Pop In",
"category": "Transitions",
"direction": "in",
"description": "The layer grows out of a circle that stretches the picture outwards.",
"effects": [
{ "effect": "ec.transition.ccradialscalewipe", "set": { "reverse": true }, "keys": { "completion": [[0, 100], [1, 0]] }, "ease": true }
]
},
{
"id": "lineSweepIn",
"name": "Line Sweep In",
"category": "Transitions",
"direction": "in",
"description": "A stepped, slanted edge sweeps in from the lower left.",
"effects": [
{ "effect": "ec.transition.cclinesweep", "set": { "direction": 90, "thickness": 30, "slant": 30 }, "keys": { "completion": [[0, 100], [1, 0]] } }
]
},
{
"id": "jawsCloseIn",
"name": "Jaws Close In",
"category": "Transitions",
"direction": "in",
"description": "Toothed edges close in from the top and bottom until the layer is whole.",
"effects": [
{ "effect": "ec.transition.ccjaws", "set": { "shape": 2, "height": 30, "width": 40 }, "keys": { "completion": [[0, 100], [1, 0]] }, "ease": true }
]
}
]
}

View File

@ -1,7 +0,0 @@
asset: transitions.json
title: EffectCraft Transitions animation presets (Fade In/Out, Wipe In from Left/Right/Top/Bottom, Diagonal Sweep In, Wipe Out to Right, Clock Reveal, Fan Open, Clock Wipe Out, Blinds Open/Close, Slats Down, Pixel Dissolve In/Out, Mosaic Fade In, Card Flip In/Out, Grid Doors In, Lens Pop In, Line Sweep In, Jaws Close In)
author: EffectCraft contributors
source: original work
license: MIT OR Apache-2.0
added: 2026-10-11 by vangel76 (Claude Code agent, #757)
notes: Effect choices, values and key timings written from scratch for EffectCraft on its own Transition effects; not derived from any Adobe preset (.ffx) or its values.

View File

@ -320,7 +320,8 @@ impl Session {
if times.len() < 2 {
return Err("the layer must be at least two frames long".into());
}
let size = effectcraft_render::effect_bounds(&self.project, c, l).0;
let (w, h) = effectcraft_render::source_size(&self.project, l);
let size = if w == 0 { [c.width as f64, c.height as f64] } else { [w as f64, h as f64] };
let params = static_params(g);
let key = tracks_key(&self.project, comp, l, g);
let settings = ct::settings(&params);

View File

@ -1,5 +1,4 @@
//! Align panel: `layer.align {edge, to}` and `layer.distribute {mode}`; the arrow keys'
//! `layer.nudge {x, y}`.
//! Align panel: `layer.align {edge, to}` and `layer.distribute {mode}`.
//!
//! Layers are aligned by their content bounds in comp space (source size, text glyphs, shape
//! contents through the layer's transform and its parents'). Each layer moves by a comp-space
@ -9,11 +8,11 @@
use effectcraft_geom::{Mat4, Vec3};
use effectcraft_keyframe::Value as KValue;
use effectcraft_project::{ItemId, Layer, LayerId};
use effectcraft_project::{ItemId, LayerId};
use effectcraft_render::EvalCtx;
use serde_json::{Value, json};
use super::{CommandSpec, bad, f_p, has_layers, layer_mut, layers_p, merge_p, str_p};
use super::{CommandSpec, bad, has_layers, layer_mut, layers_p, str_p};
use crate::{EngineError, Result, Session, cmd};
/// A layer's comp-space bounds and how a comp-space offset maps into its Position.
@ -44,32 +43,27 @@ fn placed(s: &Session, cid: ItemId, ids: &[LayerId]) -> Result<Vec<Placed>> {
x1 = x1.max(p.x);
y1 = y1.max(p.y);
}
out.push(Placed { id, b: [x0, y0, x1, y1], to_parent: to_parent(&ctx, l) });
// Parent chain: world(layer) = local(pn) ⋯ local(p1) · local(layer).
let mut par = Mat4::IDENTITY;
let mut cur = l.parent;
let mut guard = 0;
while let Some(pid) = cur {
guard += 1;
let Some(p) = comp.layer(pid).filter(|_| guard < 64) else { break };
par = ctx.local_matrix(p) * par;
cur = p.parent;
}
let to_parent = par.inverse().unwrap_or(Mat4::IDENTITY);
out.push(Placed { id, b: [x0, y0, x1, y1], to_parent });
}
Ok(out)
}
/// Comp space → the space of `l`'s Position (its parent's).
fn to_parent(ctx: &EvalCtx, l: &Layer) -> Mat4 {
// Parent chain: world(layer) = local(pn) ⋯ local(p1) · local(layer).
let mut par = Mat4::IDENTITY;
let mut cur = l.parent;
let mut guard = 0;
while let Some(pid) = cur {
guard += 1;
let Some(p) = ctx.comp.layer(pid).filter(|_| guard < 64) else { break };
par = ctx.local_matrix(p) * par;
cur = p.parent;
}
par.inverse().unwrap_or(Mat4::IDENTITY)
}
/// Move layers by comp-space offsets, as one undo step (merged into the last one with the same
/// `merge` key).
fn apply_moves(s: &mut Session, cid: ItemId, label: &str, moves: Vec<(LayerId, Mat4, [f64; 2])>, merge: Option<&str>) -> Result<Value> {
/// Move layers by comp-space offsets, as one undo step.
fn apply_moves(s: &mut Session, cid: ItemId, label: &str, moves: Vec<(LayerId, Mat4, [f64; 2])>) -> Result<Value> {
let t = s.time();
let moved: Vec<u64> = moves.iter().filter(|m| m.2[0].abs() > 1e-9 || m.2[1].abs() > 1e-9).map(|m| m.0.0).collect();
s.edit(label, merge, |proj, _| {
s.edit(label, None, |proj, _| {
for (lid, to_parent, d) in &moves {
if d[0].abs() <= 1e-9 && d[1].abs() <= 1e-9 {
continue;
@ -150,7 +144,7 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
(it.id, it.to_parent, d)
})
.collect();
apply_moves(s, cid, "Align Layers", moves, None)
apply_moves(s, cid, "Align Layers", moves)
}
fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
@ -176,29 +170,11 @@ fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
(it.id, it.to_parent, if horiz { [dv, 0.0] } else { [0.0, dv] })
})
.collect();
apply_moves(s, cid, "Distribute Layers", moves, None)
}
/// The arrow keys in the Composition panel: move the selected (unlocked) layers by `x`, `y` comp
/// pixels; After Effects nudges 1 pixel at the panel's magnification, 10 with Shift. Each call
/// is one undo step.
fn nudge(s: &mut Session, p: &Value) -> Result<Value> {
let c = "layer.nudge";
let d = [f_p(p, "x").unwrap_or(0.0), f_p(p, "y").unwrap_or(0.0)];
if !d.iter().all(|v| v.is_finite()) {
return Err(bad(c, "`x` and `y` must be numbers"));
}
let (cid, ids) = layers_p(s, p)?;
let ids = super::unlocked(s, cid, ids, c)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let ctx = EvalCtx { footage: Some(s.footage.as_ref()), expr: s.expr.as_deref(), ..EvalCtx::new(&s.project, cid, comp, s.time()) };
let moves = ids.iter().filter_map(|id| comp.layer(*id)).filter(|l| l.transform().is_some()).map(|l| (l.id, to_parent(&ctx, l), d)).collect();
apply_moves(s, cid, "Nudge", moves, merge_p(p))
apply_moves(s, cid, "Distribute Layers", moves)
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("layer.nudge", "Nudge Layers", [], None, "{x?, y? (comp pixels), layers?, merge?}", has_layers, nudge),
cmd!(
"layer.align",
"Align Layers",
@ -265,27 +241,6 @@ mod tests {
assert!(s.execute("layer.align", json!({"edge": "diagonal", "layers": [a]})).is_err());
}
/// #352: shape layers align and snap by their visual edges (half the stroke, no
/// anti-aliasing padding), so two rectangles lined up edge to edge touch with no gap.
#[test]
fn shape_layers_align_by_their_visual_edges() {
let mut s = session();
let shape = |s: &mut Session, extra: Value| {
let mut p = json!({"kind": "rect", "size": [100, 100], "fill": "#ff0000"});
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
s.execute("layer.newShape", p).unwrap()["layer"].as_u64().unwrap()
};
let a = shape(&mut s, json!({}));
let b = shape(&mut s, json!({"stroke": "#ffffff", "strokeWidth": 10}));
let width = |s: &Session, id| {
let r = bounds(s, id);
r[2] - r[0]
};
assert!((width(&s, a) - 100.0).abs() < 1e-6 && (width(&s, b) - 110.0).abs() < 1e-6, "{} {}", width(&s, a), width(&s, b));
s.execute("layer.align", json!({"edge": "left", "layers": [a, b]})).unwrap();
assert!(bounds(&s, a)[0].abs() < 1e-6 && bounds(&s, b)[0].abs() < 1e-6);
}
#[test]
fn align_honours_parent_scale_and_rotation() {
let mut s = session();
@ -327,38 +282,4 @@ mod tests {
s.execute("layer.align", json!({"edge": "right", "layers": [a]})).unwrap();
assert!((bounds(&s, a)[2] - 400.0).abs() < 1e-6);
}
/// The arrow keys' nudge (#290): comp pixels through a parent's scale, sub-pixel steps, one
/// undo step each, a key at the current time when Position is animated; locked layers stay.
#[test]
fn nudge_moves_layers_by_comp_pixels() {
let mut s = session();
let pos = |s: &Session, id: u64| s.active_comp().unwrap().layer(LayerId(id)).unwrap().props.prop("transform/position").unwrap().value.as_vec3();
let a = solid(&mut s, "A", 40, 40, [100.0, 100.0]);
let parent = solid(&mut s, "P", 10, 10, [0.0, 0.0]);
s.execute("prop.set", json!({"layer": parent, "path": "transform/scale", "value": [200.0, 200.0, 100.0]})).unwrap();
let child = solid(&mut s, "C", 10, 10, [10.0, 10.0]);
s.execute("layer.setParent", json!({"layers": [child], "parent": parent})).unwrap();
let undo = s.history.undo.len();
s.execute("layer.nudge", json!({"layers": [a, child], "x": 1.0})).unwrap();
s.execute("layer.nudge", json!({"layers": [a, child], "y": -0.25})).unwrap();
assert_eq!(pos(&s, a), [101.0, 99.75, 0.0]);
assert_eq!(pos(&s, child), [10.5, 9.875, 0.0], "half as far under a 200 % parent");
assert_eq!(s.history.undo.len(), undo + 2, "one undo step per nudge");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(pos(&s, a), [101.0, 100.0, 0.0]);
// Animated Position gets a key at the current time.
s.execute("prop.addKey", json!({"layer": a, "path": "transform/position", "time": 0.0, "value": [0, 0, 0]})).unwrap();
s.execute("time.set", json!({"time": 1.0})).unwrap();
s.execute("layer.nudge", json!({"layers": [a], "x": 10.0})).unwrap();
let keys = s.active_comp().unwrap().layer(LayerId(a)).unwrap().props.prop("transform/position").unwrap().keys.clone();
assert_eq!(
keys.iter().map(|k| (k.time, k.value.as_vec3())).collect::<Vec<_>>(),
vec![(effectcraft_time::Tick::ZERO, [0.0; 3]), (s.time(), [10.0, 0.0, 0.0])]
);
// A locked layer doesn't move.
s.execute("layer.setSwitch", json!({"layers": [child], "switch": "lock", "value": true})).unwrap();
assert!(s.execute("layer.nudge", json!({"layers": [child], "x": 1.0})).is_err());
assert_eq!(pos(&s, child), [10.5, 10.0, 0.0]);
}
}

View File

@ -6,7 +6,7 @@ use effectcraft_project::{LayerId, Property, Uid};
use effectcraft_time::Tick;
use serde_json::{Value, json};
use super::prop::{KeyTimeBase, key_time, key_timing, prop_ref};
use super::prop::prop_ref;
use super::{CommandSpec, bad, comp_id, f_p, has_comp, has_keys, layers_p, merge_p, str_p, time_p};
use crate::{EngineError, KeyClip, KeyRef, Result, Session, cmd, query};
@ -105,7 +105,7 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
if plan.is_empty() {
return Err(bad("keys.paste", "no compatible property to paste into (select a property of the same type)"));
}
s.edit("Paste Keyframes", merge_p(p), |proj, st| {
s.edit("Paste Keyframes", None, |proj, st| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let mut sel = vec![];
for (lid, uid, ci) in &plan {
@ -206,13 +206,11 @@ fn nudge(s: &mut Session, p: &Value) -> Result<Value> {
/// Edit one key directly: move it in time and/or set its value (Graph Editor drags).
fn set_key(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid, uid) = prop_ref(s, p, "keys.set")?;
let base = KeyTimeBase::from_params(p, "keys.set")?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
let t = key_time(layer, comp.frame_rate, p, "time", base, "keys.set", false)?.ok_or_else(|| bad("keys.set", "missing `time` (layer time by default)"))?;
let new_t = key_time(layer, comp.frame_rate, p, "newTime", base, "keys.set", true)?;
let t = Tick::from_seconds_f64(f_p(p, "time").ok_or_else(|| bad("keys.set", "missing `time` (layer time of the key)"))?);
let new_t = f_p(p, "newTime").map(Tick::from_seconds_f64);
let value = p.get("value").cloned();
let nt = s.edit("Edit Keyframe", merge_p(p), |proj, st| {
let fr = s.project.comp(cid).map(|c| c.frame_rate);
s.edit("Edit Keyframe", merge_p(p), |proj, st| {
let l = super::layer_mut(proj, cid, lid)?;
let pr = l.props.find_mut(uid).ok_or_else(|| bad("keys.set", "no property"))?;
let i = nearest_key(&pr.keys, t).ok_or_else(|| bad("keys.set", "the property has no keyframes"))?;
@ -223,10 +221,10 @@ fn set_key(s: &mut Session, p: &Value) -> Result<Value> {
}
let mut nt = old_t;
if let Some(n) = new_t {
nt = n;
nt = fr.map(|r| r.snap_nearest(n)).unwrap_or(n);
// Keys can't pass their neighbours or share a time.
let lo = if i > 0 { pr.keys[i - 1].time.0.saturating_add(1) } else { i64::MIN };
let hi = if i + 1 < pr.keys.len() { pr.keys[i + 1].time.0.saturating_sub(1) } else { i64::MAX };
let lo = if i > 0 { pr.keys[i - 1].time.0 + 1 } else { i64::MIN };
let hi = if i + 1 < pr.keys.len() { pr.keys[i + 1].time.0 - 1 } else { i64::MAX };
nt = Tick(nt.0.clamp(lo, hi));
pr.keys[i].time = nt;
}
@ -235,10 +233,8 @@ fn set_key(s: &mut Session, p: &Value) -> Result<Value> {
for k in st.selected_keys.iter_mut().filter(|k| k.layer == lid && k.prop == uid && k.time == old_t) {
k.time = nt;
}
Ok(nt)
})?;
let layer = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?;
Ok(key_timing(layer, nt))
Ok(json!({"time": nt.seconds()}))
})
}
fn nearest_key(keys: &[Keyframe], t: Tick) -> Option<usize> {
@ -453,15 +449,7 @@ pub fn specs() -> Vec<CommandSpec> {
s,
&json!({"frames": -10})
)),
cmd!(
"keys.set",
"Edit Keyframe",
[],
None,
"{layer?, path|prop, time (layer s by default), newTime? (same base), timeBase?: layer|comp, value?, merge?}",
has_comp,
set_key
),
cmd!("keys.set", "Edit Keyframe", [], None, "{layer?, path|prop, time (layer s), newTime?, value?, merge?}", has_comp, set_key),
cmd!("keys.selectEqual", "Select Equal Keyframes", [], None, "{}", has_keys, |s, _| select_related(s, "equal")),
cmd!("keys.selectPrevious", "Select Previous Keyframes", [], None, "{}", has_keys, |s, _| select_related(s, "previous")),
cmd!("keys.selectFollowing", "Select Following Keyframes", [], None, "{}", has_keys, |s, _| select_related(s, "following")),

View File

@ -1,177 +0,0 @@
//! EffectCraft's own animation preset library (Effects & Presets ▸ * Animation Presets): the
//! Transitions category, original presets built on our Transition effects and layer opacity.
//!
//! Each preset adds effects (with values and keyframes) and/or keys layer properties, starting
//! at the current time, on every selected layer. The library is data
//! (`presets/transitions.json`), written from scratch; nothing comes from After Effects' `.ffx`
//! presets.
use effectcraft_keyframe::Keyframe;
use effectcraft_project::build::Ids;
use effectcraft_project::{Node, Property};
use effectcraft_time::Tick;
use serde_json::{Map, Value, json};
use super::text_anim::json_value;
use super::{CommandSpec, bad, has_layers, layer_mut, layers_p, str_p};
use crate::{EngineError, Result, Session, cmd, query};
/// The original transition presets (`presets/transitions.json`).
pub const TRANSITIONS_JSON: &str = include_str!("../../presets/transitions.json");
/// Keys per animated property, so a hostile inline preset can't allocate without bound.
const MAX_KEYS: usize = 256;
fn presets() -> Vec<Value> {
serde_json::from_str::<Value>(TRANSITIONS_JSON).ok().and_then(|v| v.get("presets").and_then(Value::as_array).cloned()).unwrap_or_default()
}
fn text<'a>(v: &'a Value, key: &str) -> Option<&'a str> {
v.get(key).and_then(Value::as_str)
}
/// (id, name, category) of every built-in animation preset, in library order.
pub fn preset_names() -> Vec<(String, String, String)> {
presets()
.iter()
.filter_map(|p| Some((text(p, "id")?.to_string(), text(p, "name")?.to_string(), text(p, "category").unwrap_or("Transitions").to_string())))
.collect()
}
fn find_preset(want: &str) -> Option<Value> {
presets().into_iter().find(|x| text(x, "id") == Some(want) || text(x, "name").is_some_and(|n| n.eq_ignore_ascii_case(want)))
}
/// Set `pr` to `v` (when given) and replace its keys with `keys` (`[[seconds, value], …]`,
/// seconds from `at`). Returns false when nothing in the request fits the property.
fn set_prop(pr: &mut Property, v: Option<&Value>, keys: Option<&Value>, at: Tick, ease: bool) -> bool {
let mut ok = true;
if let Some(v) = v {
match json_value(v, &pr.value) {
Some(val) => pr.value = val,
None => ok = false,
}
}
let Some(list) = keys else { return ok };
let Some(list) = list.as_array() else { return false };
let mut ks: Vec<Keyframe> = Vec::new();
for k in list.iter().take(MAX_KEYS) {
let t = k.get(0).and_then(Value::as_f64).filter(|t| t.is_finite() && t.abs() <= 86_400.0);
let (Some(t), Some(val)) = (t, k.get(1).and_then(|x| json_value(x, &pr.value))) else { return false };
let mut key = Keyframe::new(at + Tick::from_seconds_f64(t), val);
if pr.hold_only {
key = key.hold();
} else if ease {
key = key.eased();
}
ks.push(key);
}
ks.sort_by_key(|k| k.time);
ks.dedup_by_key(|k| k.time);
let Some(first) = ks.first() else { return false };
pr.value = first.value.clone();
pr.keys = ks;
ok
}
/// Apply `set` / `keys` maps to the properties under `root`; returns the paths that don't exist
/// or don't take the given values.
fn apply_maps(root: &mut effectcraft_project::PropGroup, spec: &Value, at: Tick) -> Vec<String> {
let ease = spec.get("ease").and_then(Value::as_bool).unwrap_or(false);
let empty = Map::new();
let set = spec.get("set").and_then(Value::as_object).unwrap_or(&empty);
let keys = spec.get("keys").and_then(Value::as_object).unwrap_or(&empty);
let mut skipped = Vec::new();
for (path, v) in set {
if !root.prop_mut(path).is_some_and(|pr| set_prop(pr, Some(v), None, at, false)) {
skipped.push(path.clone());
}
}
for (path, list) in keys {
if !root.prop_mut(path).is_some_and(|pr| !pr.static_only && set_prop(pr, None, Some(list), at, ease)) {
skipped.push(path.clone());
}
}
skipped
}
/// `anim.applyBuiltinPreset`: add a built-in preset's effects and keyframes to the layers.
fn apply(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "anim.applyBuiltinPreset";
let want = str_p(p, "preset").ok_or_else(|| bad(C, "`preset` id or name required"))?;
let preset = find_preset(want).ok_or_else(|| bad(C, format!("no animation preset `{want}`")))?;
let name = text(&preset, "name").unwrap_or("Preset").to_string();
let effects: Vec<(&'static effectcraft_effects::EffectSpec, Value)> = preset
.get("effects")
.and_then(Value::as_array)
.into_iter()
.flatten()
.map(|e| {
let id = text(e, "effect").unwrap_or_default();
effectcraft_effects::lookup(id).map(|spec| (spec, e.clone())).ok_or_else(|| bad(C, format!("preset `{name}` uses unknown effect `{id}`")))
})
.collect::<Result<_>>()?;
let layer_spec = preset.get("layer").cloned();
let (cid, ids) = layers_p(s, p)?;
if ids.is_empty() {
return Err(bad(C, "select a layer"));
}
let sizes: Vec<_> = {
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
ids.iter().filter_map(|id| comp.layer(*id)).map(|l| (l.id, effectcraft_render::effect_bounds(&s.project, comp, l).0)).collect()
};
let now = s.time();
let (added, skipped) = s.edit(&format!("Apply Animation Preset {name}"), None, |proj, _| {
let mut added: Vec<u64> = Vec::new();
let mut skipped: Vec<String> = Vec::new();
for (lid, size) in &sizes {
let mut next = proj.next_id;
let l = layer_mut(proj, cid, *lid)?;
let at = l.layer_time(now);
if !effects.is_empty() {
let fx = l.props.sub_mut("effects").ok_or_else(|| bad(C, "this layer type has no effects"))?;
for (spec, ej) in &effects {
let same = fx.groups().filter(|g| g.match_id == spec.id).count();
let inst = if same == 0 { spec.name.to_string() } else { format!("{} {}", spec.name, same.saturating_add(1)) };
let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), &inst, *size);
skipped.extend(apply_maps(&mut g, ej, at).into_iter().map(|path| format!("{}/{path}", spec.id)));
added.push(g.uid);
fx.children.push(Node::Group(g));
}
}
if let Some(ls) = &layer_spec {
skipped.extend(apply_maps(&mut l.props, ls, at));
}
proj.next_id = next;
}
Ok((added, skipped))
})?;
Ok(json!({"preset": text(&preset, "id"), "effects": added, "skipped": skipped}))
}
/// `anim.builtinPresets`: the library, optionally one category.
fn list(_: &mut Session, p: &Value) -> Result<Value> {
let cat = str_p(p, "category");
Ok(Value::Array(
presets()
.iter()
.filter(|x| cat.is_none_or(|c| text(x, "category").is_some_and(|k| k.eq_ignore_ascii_case(c))))
.map(|x| {
json!({
"id": x.get("id"),
"name": x.get("name"),
"category": x.get("category"),
"direction": x.get("direction"),
"description": x.get("description"),
})
})
.collect(),
))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("anim.applyBuiltinPreset", "Apply Built-in Animation Preset", [], None, "{preset: id|name (see anim.builtinPresets), layers?}", has_layers, apply),
query!("anim.builtinPresets", "Built-in Animation Presets", "{category?}", list),
]
}

View File

@ -5,8 +5,7 @@
use std::collections::BTreeMap;
use effectcraft_keyframe::{Interp, Keyframe, Value as KV};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{GroupKind, Layer, LayerId, LayerSource, Node, Project, PropGroup, Property, Uid};
use effectcraft_project::{GroupKind, LayerId, Node, PropGroup, Property, Uid};
use effectcraft_time::Tick;
use serde_json::{Value, json};
@ -294,9 +293,6 @@ fn modified(pr: &Property, group: &str, default_anchor: [f64; 2], comp_center: [
match (group, pr.match_id.as_str()) {
("transform", "anchor") => !near(&v.as_vec2(), &default_anchor),
("transform", "position") => !near(&v.as_vec2(), &comp_center) || v.as_vec3()[2].abs() > 1e-6,
// Separate Dimensions: X and Y Position default to the comp centre.
("transform", "positionX") => (v.as_f64() - comp_center[0]).abs() > 1e-6,
("transform", "positionY") => (v.as_f64() - comp_center[1]).abs() > 1e-6,
("transform", "scale") => !near(&v.as_vec3(), &[100.0; 3]),
("transform", "opacity") => (v.as_f64() - 100.0).abs() > 1e-6,
("transform", "orientation") => !near(&v.as_vec3(), &[0.0; 3]),
@ -305,50 +301,6 @@ fn modified(pr: &Property, group: &str, default_anchor: [f64; 2], comp_center: [
}
}
/// The group a fresh layer of `l`'s kind (`fresh`) has in place of `top`, for the groups whose
/// defaults are fixed: Audio, Material / Geometry / Camera / Light Options, a text layer's Path
/// and More Options, and a camera's or light's Transform (whose defaults aren't a 2D layer's).
fn default_group(top: &PropGroup, l: &Layer, fresh: &Layer) -> Option<PropGroup> {
let mut next = 0;
let mut ids = Ids(&mut next);
let camera_or_light = matches!(l.source, LayerSource::Camera | LayerSource::Light { .. });
let mut g = match top.match_id.as_str() {
"transform" if !camera_or_light => return None,
// Added to a layer after it is made (a source with audio, an Advanced 3D comp).
"audio" => build::audio(&mut ids),
"geometryOptions" if matches!(l.source, LayerSource::Text | LayerSource::Shape) => build::extrusion_geometry_options(&mut ids),
"transform" | "cameraOptions" | "lightOptions" | "materialOptions" | "geometryOptions" | "text" => fresh.props.sub(&top.match_id)?.clone(),
_ => return None,
};
match top.match_id.as_str() {
// Source Text and the animators are the layer's content, not options with a default.
"text" => g.children.retain(|c| matches!(c.match_id(), "pathOptions" | "moreOptions")),
// A model's scale and clip come from its file.
"geometryOptions" if matches!(l.source, LayerSource::Model { .. }) => g.children.retain(|c| !matches!(c.match_id(), "unitScale" | "animation")),
_ => {}
}
Some(g)
}
/// Static properties of `g` whose value differs from the same property of `default` (matched by
/// match id; properties `default` lacks have no known default and are left out).
fn changed_from(g: &PropGroup, default: &PropGroup, out: &mut Vec<Uid>) {
for c in &g.children {
match c {
Node::Prop(pr) => {
if default.get(&pr.match_id).is_some_and(|d| d.value != pr.value) && !out.contains(&pr.uid) {
out.push(pr.uid);
}
}
Node::Group(sg) => {
if let Some(d) = default.sub(&sg.match_id) {
changed_from(sg, d, out);
}
}
}
}
}
fn reveal(s: &mut Session, p: &Value) -> Result<Value> {
let kind = str_p(p, "kind").unwrap_or("keyframes").to_string();
let cid = super::comp_id(s, p)?;
@ -360,27 +312,10 @@ fn reveal(s: &mut Session, p: &Value) -> Result<Value> {
let (w, h) = effectcraft_render::source_size(&s.project, l);
let anchor = [w as f64 / 2.0, h as f64 / 2.0];
let center = [comp.width as f64 / 2.0, comp.height as f64 / 2.0];
// Properties at the top of the layer's tree (Time Remap): enabled time remapping is a
// modification in itself.
for pr in l.props.props() {
let hit = match kind.as_str() {
"keyframes" => pr.is_animated(),
"animation" => pr.is_animated() || pr.has_expression(),
_ => pr.match_id == "timeRemap" || modified(pr, "", anchor, center),
};
if hit {
out.push(json!({"layer": lid.0, "prop": pr.uid}));
}
}
// Modified: a fresh layer of the same kind gives the defaults to compare against.
let fresh = (kind == "modified").then(|| build::layer(&mut Project::default(), comp, "", l.source.clone(), (w, h), None));
let camera_or_light = matches!(l.source, LayerSource::Camera | LayerSource::Light { .. });
for top in l.props.groups() {
let hit = |pr: &Property| match kind.as_str() {
"keyframes" => pr.is_animated(),
"animation" => pr.is_animated() || pr.has_expression(),
// A camera's or light's Transform is compared with a fresh one's below.
_ if camera_or_light && top.match_id == "transform" => pr.is_animated() || pr.expr.is_some(),
_ => modified(pr, &top.match_id, anchor, center),
};
let mut found = vec![];
@ -400,28 +335,10 @@ 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})));
}
}
let layers: Vec<u64> = layers.iter().map(|l| l.0).collect();
frontend(s, "timeline.revealProps", &json!({"props": out, "kind": kind, "layers": layers}))?;
frontend(s, "timeline.revealProps", &json!({"props": out, "kind": kind}))?;
Ok(json!({"props": out}))
}

View File

@ -128,7 +128,7 @@ fn clear_recent_presets(s: &mut Session, _: &Value) -> Result<Value> {
fn save_copy_xml(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_p(p, "path").ok_or_else(|| bad("file.saveCopyAsXml", "missing `path` (.ecprojx)"))?;
let xml = crate::xml_project::to_xml(&s.project.to_file_json_with_view(&s.saved_view())?).map_err(EngineError::Other)?;
let xml = crate::xml_project::to_xml(&s.project.to_file_json()?).map_err(EngineError::Other)?;
s.services.write_file(path, xml.as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
s.toast(format!("Saved an XML copy to {path}"));
Ok(json!({"path": path, "bytes": xml.len()}))
@ -194,7 +194,7 @@ fn execute_file(s: &mut Session, p: &Value) -> Result<Value> {
s.events.push(Event::Frontend { command: "app.confirmExecute".into(), params: json!({"path": path}) });
return Ok(json!({"needsConfirmation": path}));
}
let url = super::layer_menu::file_url(path);
let url = format!("file://{}", path.replace(' ', "%20"));
s.events.push(Event::OpenUrl(url));
Ok(json!({"opened": path}))
}
@ -288,12 +288,7 @@ fn enable_logging(s: &mut Session, p: &Value) -> Result<Value> {
}
fn reveal_log(s: &mut Session, _: &Value) -> Result<Value> {
// Enable Logging's file while it is on, else this run's session log.
let file = crate::logging::current_file()
.filter(|_| crate::logging::is_enabled())
.or_else(crate::logging::session_file)
.or_else(crate::logging::current_file)
.unwrap_or_else(|| log_file(s));
let file = crate::logging::current_file().unwrap_or_else(|| log_file(s));
let path = file.to_string_lossy().to_string();
let url = super::layer_menu::folder_url(&path);
s.events.push(Event::OpenUrl(url.clone()));
@ -321,8 +316,6 @@ pub const IMPORT_FORMATS: &[&str] = &[
"Opus",
"glTF/GLB",
"OBJ",
"STL",
"FBX",
"Lottie JSON",
];
@ -335,10 +328,7 @@ pub fn system_report(s: &Session) -> Value {
let budgets = s.prefs.cache_budgets(mem.or(s.sys_memory));
let mut issues = vec![];
if gpu.is_none() {
issues.push(match &s.accel_note {
Some(why) => format!("No GPU compositor ({why}): rendering uses the CPU (all features still work)."),
None => "No GPU compositor: rendering uses the CPU (all features still work).".to_string(),
});
issues.push("No GPU compositor: rendering uses the CPU (all features still work).".to_string());
}
if mem.is_some_and(|m| m.total < 8 << 30) {
issues.push("Less than 8 GB of memory: use lower preview resolutions for long or large comps.".to_string());
@ -354,8 +344,6 @@ pub fn system_report(s: &Session) -> Value {
"cores": crate::sysinfo::cpu_cores(),
"memory": mem.map(|m| json!({"total": gb(m.total), "available": gb(m.available)})),
"gpu": gpu,
"gpuNote": if gpu.is_none() { s.accel_note.clone() } else { None },
"windowGraphics": s.window_adapter,
"caches": {"layer": gb(budgets.layer as u64), "footage": gb(budgets.media as u64), "preview": gb(budgets.preview as u64)},
"import": IMPORT_FORMATS,
"export": export,
@ -381,14 +369,7 @@ pub fn report_text(r: &Value) -> String {
Some(m) => t.push_str(&format!("Memory: {} ({} available)\n", m["total"].as_str().unwrap_or("?"), m["available"].as_str().unwrap_or("?"))),
None => t.push_str("Memory: unknown\n"),
}
match (r.get("gpu").and_then(Value::as_str), r.get("gpuNote").and_then(Value::as_str)) {
(Some(gpu), _) => t.push_str(&format!("GPU: {gpu}\n")),
(None, Some(why)) => t.push_str(&format!("GPU: none (CPU compositing): {why}\n")),
(None, None) => t.push_str("GPU: none (CPU compositing)\n"),
}
if let Some(w) = r.get("windowGraphics").and_then(Value::as_str) {
t.push_str(&format!("Window graphics: {w}\n"));
}
t.push_str(&format!("GPU: {}\n", r.get("gpu").and_then(Value::as_str).unwrap_or("none (CPU compositing)")));
let c = &r["caches"];
t.push_str(&format!(
"Cache budgets: layer {}, footage {}, preview {}\n",
@ -621,25 +602,3 @@ pub fn specs() -> Vec<CommandSpec> {
query!("help.systemInfo", "System Information", "{}", |s, _| Ok(system_report(s))),
]
}
#[cfg(test)]
mod tests {
use serde_json::json;
/// `File.execute()` opens the file as a well-formed `file://` URL (spaces, `#`, `%`).
#[test]
fn execute_file_opens_an_encoded_file_url() {
let dir = std::env::temp_dir().join(format!("ec-exec-url-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let f = dir.join("run #1 100%.txt");
std::fs::write(&f, "x").unwrap();
let mut s = crate::Session::default();
s.execute("prefs.set", json!({"key": "scripting.allowScriptsWriteFiles", "value": true})).unwrap();
s.drain_events();
s.execute("file.executeFile", json!({"path": f.to_string_lossy(), "confirmed": true})).unwrap();
let expected = super::super::layer_menu::file_url(&f.to_string_lossy());
assert!(expected.ends_with("/run%20%231%20100%25.txt"), "{expected}");
assert!(s.drain_events().iter().any(|e| matches!(e, crate::Event::OpenUrl(u) if *u == expected)));
let _ = std::fs::remove_dir_all(&dir);
}
}

View File

@ -109,7 +109,6 @@ pub fn specs() -> Vec<CommandSpec> {
label: "Run Commands (Batch)",
menu: &[],
shortcut: None,
alt_shortcuts: &[],
params: "{steps: [{command, params?}], label? (undo step name, default Batch), atomic?: bool (default true: a failing step rolls the whole batch back)} → {steps, results: [each step's result]}; a string param \"$N\" or \"$N.key.0\" is step N's result (1-based), e.g. {\"layer\": \"$1.layer\"}",
enabled: always,
run: batch,

View File

@ -1,7 +1,7 @@
//! Composition menu.
use effectcraft_color::Label;
use effectcraft_project::{Comp, ItemId, ItemKind, Marker, limits};
use effectcraft_project::{Comp, ItemId, ItemKind, Marker};
use effectcraft_time::{FrameRate, Tick};
use serde_json::{Value, json};
@ -15,7 +15,7 @@ pub(crate) fn rate_p(p: &Value) -> Option<FrameRate> {
/// A zero, negative or tiny rate would make a zero-length frame (and divide by zero later).
fn check_rate(p: &Value, cmd: &str) -> Result<()> {
match f_p(p, "frameRate").or(f_p(p, "fps")) {
Some(f) if !(limits::MIN_FPS..=limits::MAX_FPS).contains(&f) => Err(bad(cmd, "frameRate: fps from 0.001 to 1000")),
Some(f) if !(0.001..=1000.0).contains(&f) => Err(bad(cmd, "frameRate: fps from 0.001 to 1000")),
_ => Ok(()),
}
}
@ -23,10 +23,10 @@ fn check_rate(p: &Value, cmd: &str) -> Result<()> {
fn apply_settings(c: &mut Comp, p: &Value) {
let (ow, oh) = (c.width, c.height);
if let Some(w) = p.get("width").and_then(Value::as_u64) {
c.width = limits::size(w).max(4);
c.width = (w as u32).clamp(4, 30000);
}
if let Some(h) = p.get("height").and_then(Value::as_u64) {
c.height = limits::size(h).max(4);
c.height = (h as u32).clamp(4, 30000);
}
// Advanced › Anchor: where the old frame sits in the resized one (0 = top left … 4 = center …
// 8 = bottom right). Unparented layers (and all their position keys) move by that offset.
@ -36,8 +36,19 @@ fn apply_settings(c: &mut Comp, p: &Value) {
let dy = (c.height as f64 - oh as f64) * (a / 3) as f64 / 2.0;
if dx != 0.0 || dy != 0.0 {
for l in c.layers.iter_mut().filter(|l| l.parent.is_none()) {
if let Some(tr) = l.transform_mut() {
super::layer::offset_position(tr, dx, dy);
let Some(pr) = l.props.prop_mut("transform/position") else { continue };
let shift = |v: &mut effectcraft_keyframe::Value| {
if let effectcraft_keyframe::Value::Vec2(x) = v {
x[0] += dx;
x[1] += dy;
} else if let effectcraft_keyframe::Value::Vec3(x) = v {
x[0] += dx;
x[1] += dy;
}
};
shift(&mut pr.value);
for k in &mut pr.keys {
shift(&mut k.value);
}
}
}
@ -65,11 +76,9 @@ fn apply_settings(c: &mut Comp, p: &Value) {
c.work_area.1 = c.duration;
}
c.work_area.1 = c.work_area.1.min(c.duration);
// A shorter composition must not leave the start past the end.
keep_work_area_ordered(c);
}
if let Some(pa) = f_p(p, "pixelAspect") {
c.pixel_aspect = limits::pixel_aspect(pa.max(limits::MIN_PIXEL_ASPECT));
c.pixel_aspect = pa.max(0.1);
}
if let Some(Value::Array(bg)) = p.get("background") {
let g = |i: usize| bg.get(i).and_then(Value::as_f64).unwrap_or(0.0) as f32;
@ -84,10 +93,10 @@ fn apply_settings(c: &mut Comp, p: &Value) {
c.shutter_phase = a.clamp(-360.0, 360.0);
}
if let Some(n) = p.get("motionBlurSamples").and_then(Value::as_u64) {
c.motion_blur_samples = n.clamp(limits::MOTION_BLUR_SAMPLES.0.into(), limits::MOTION_BLUR_SAMPLES.1.into()) as u32;
c.motion_blur_samples = (n as u32).clamp(2, 64);
}
if let Some(n) = p.get("adaptiveSampleLimit").and_then(Value::as_u64) {
c.motion_blur_adaptive_limit = n.clamp(limits::ADAPTIVE_SAMPLE_LIMIT.0.into(), limits::ADAPTIVE_SAMPLE_LIMIT.1.into()) as u32;
c.motion_blur_adaptive_limit = (n as u32).clamp(16, 256);
}
if let Some(b) = p.get("preserveFrameRate").and_then(Value::as_bool) {
c.preserve_frame_rate = b;
@ -182,46 +191,22 @@ fn work_area(s: &mut Session, p: &Value) -> Result<Value> {
_ => {}
}
if let Some(b) = begin {
// min/max, not clamp: the bounds cross on a comp shorter than a frame or an inverted area.
c.work_area.0 = b.min(c.duration - fd).max(Tick::ZERO);
c.work_area.0 = b.clamp(Tick::ZERO, c.duration - fd);
}
if let Some(e) = end {
c.work_area.1 = e.max(c.work_area.0 + fd).min(c.duration);
c.work_area.1 = e.clamp(c.work_area.0 + fd, c.duration);
}
// A start on its own can land after the current end. Pull it back so the interval
// still covers one frame, the same rule as `set: "begin"`.
keep_work_area_ordered(c);
Ok(())
})?;
let c = s.project.comp(cid).ok_or(EngineError::NoComp)?;
Ok(json!({"start": c.work_area.0.seconds(), "end": c.work_area.1.seconds()}))
}
/// Keep `work_area` inside the composition and at least one frame long.
fn keep_work_area_ordered(c: &mut Comp) {
let fd = c.frame_duration();
c.work_area.1 = c.work_area.1.min(c.duration);
if c.work_area.0 < Tick::ZERO {
c.work_area.0 = Tick::ZERO;
}
if c.work_area.0 + fd > c.work_area.1 {
c.work_area.0 = (c.work_area.1 - fd).max(Tick::ZERO);
}
if c.work_area.0 + fd > c.work_area.1 {
c.work_area.1 = (c.work_area.0 + fd).min(c.duration);
}
}
fn trim_to_wa(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
s.edit("Trim Comp to Work Area", None, |proj, _| {
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let (a, b) = c.work_area;
let fd = c.frame_duration();
// An interval that is already inverted must not become a negative duration.
if a < Tick::ZERO || b > c.duration || a + fd > b {
return Err(bad("comp.trimToWorkArea", "the work area must cover at least one frame inside the composition"));
}
for l in &mut c.layers {
l.start_time -= a;
l.in_point = (l.in_point - a).max(Tick::ZERO);
@ -283,7 +268,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New Composition...",
["Composition"],
Some("Cmd+N"),
"{name?, width?, height?, frameRate?, duration? (s), startTime? (s) | startTimecode?, background? [r,g,b]|#hex, pixelAspect?, shutterAngle?, shutterPhase?, motionBlurSamples?, adaptiveSampleLimit? (16–256), preserveFrameRate?: bool, preserveResolution?: bool, renderer?: classic3D|advanced3D, anchor?, open?}",
"{name?, width?, height?, frameRate?, duration? (s), startTime? (s) | startTimecode?, background? [r,g,b]|#hex, pixelAspect?, shutterAngle?, shutterPhase?, motionBlurSamples?, adaptiveSampleLimit? (16–256), preserveFrameRate?: bool, preserveResolution?: bool, renderer? classic3D|advanced3D, anchor?, open?}",
always,
new_comp
),
@ -292,7 +277,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Composition Settings...",
["Composition"],
Some("Cmd+K"),
"{comp?, name?, width?, height?, anchor? 0-8 (resize anchor, 4 = center), frameRate?, duration?, startTime? (s) | startTimecode?, background?, shutterAngle?, shutterPhase?, motionBlurSamples?, adaptiveSampleLimit? (16–256), preserveFrameRate?: bool (nested or in the render queue it shows only its own frames), preserveResolution?: bool (nested, it renders at full size), pixelAspect?, renderer?: classic3D|advanced3D}",
"{comp?, name?, width?, height?, anchor? 0-8 (resize anchor, 4 = center), frameRate?, duration?, startTime? (s) | startTimecode?, background?, shutterAngle?, shutterPhase?, motionBlurSamples?, adaptiveSampleLimit? (16–256), preserveFrameRate?: bool (nested or in the render queue it shows only its own frames), preserveResolution?: bool (nested, it renders at full size), pixelAspect?, renderer? classic3D|advanced3D}",
has_comp,
settings
),

View File

@ -1,13 +1,13 @@
//! Composition menu, part two: crop to region of interest / layer bounds, Save Frame As ▸ File,
//! Copy Frame to Clipboard, Responsive Design — Time.
//! Responsive Design — Time.
use effectcraft_keyframe::Value as KV;
use effectcraft_project::Marker;
use effectcraft_time::Tick;
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, comp_id, f_p, has_comp, has_layers, layers_p, str_p};
use crate::prefs::CompPreset;
use crate::{EngineError, Result, Session, cmd, query};
use super::{CommandSpec, bad, comp_id, f_p, has_comp, has_layers, layers_p, str_p};
use crate::{EngineError, Result, Session, cmd};
fn has_roi(s: &Session) -> std::result::Result<(), String> {
has_comp(s)?;
@ -18,15 +18,20 @@ fn has_roi(s: &Session) -> std::result::Result<(), String> {
/// (all position keys too) so nothing moves on screen.
fn crop(s: &mut Session, cid: effectcraft_project::ItemId, r: [f64; 4], label: &str) -> Result<Value> {
let (x, y) = (r[0].round(), r[1].round());
// At most 30,000 px a side, like any composition (#788).
let (w, h) = (effectcraft_project::limits::size_f64(r[2]), effectcraft_project::limits::size_f64(r[3]));
let (w, h) = (r[2].round().max(1.0) as u32, r[3].round().max(1.0) as u32);
s.edit(label, None, |proj, st| {
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
c.width = w;
c.height = h;
for l in c.layers.iter_mut().filter(|l| l.parent.is_none()) {
if let Some(tr) = l.transform_mut() {
super::layer::offset_position(tr, -x, -y);
let Some(pos) = l.transform_mut().and_then(|tr| tr.get_mut("position")) else { continue };
let mv = |v: &KV| {
let a = v.as_vec3();
KV::Vec3([a[0] - x, a[1] - y, a[2]])
};
pos.value = mv(&pos.value);
for k in &mut pos.keys {
k.value = mv(&k.value);
}
}
for g in &mut c.guides {
@ -122,26 +127,6 @@ fn save_frame(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"path": path, "width": img.width, "height": img.height, "time": t.seconds()}))
}
/// Composition ▸ Copy Frame to Clipboard (After Effects 26.3): render the frame as Save Frame As
/// ▸ File does and hand it to the frontend, which puts it on the system clipboard.
fn copy_frame(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let t = f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or(s.time());
let scale = f_p(p, "scale").unwrap_or(1.0).clamp(0.01, 4.0);
let bg = comp.background;
let img = s.render(cid, t, effectcraft_render::RenderOpts { scale, backend: effectcraft_render::Backend::Auto, ..Default::default() });
let (width, height) = (img.width, img.height);
let rgba = img.to_rgba8_over(bg);
let expected = (width as usize).checked_mul(height as usize).and_then(|n| n.checked_mul(4));
if width == 0 || height == 0 || expected != Some(rgba.len()) {
return Err(EngineError::Other(format!("cannot copy an empty or malformed {width}×{height} frame")));
}
s.events.push(crate::Event::CopyImage { width, height, rgba });
s.toast("Copied frame to the clipboard");
Ok(json!({"width": width, "height": height, "time": t.seconds()}))
}
/// Responsive Design — Time: protected comp markers for an intro, an outro or the work area.
fn responsive_time(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
@ -165,65 +150,6 @@ fn responsive_time(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"start": start.seconds(), "end": end.seconds()}))
}
// ---------------------------------------------------------------- composition presets
/// `comp.savePreset`: remember a frame size, pixel aspect and frame rate under a name in the
/// settings (shown in Composition Settings ▸ Preset). A preset with the same name is replaced.
fn save_preset(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "comp.savePreset";
let name = str_p(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(C, "missing `name`"))?;
let num = |k: &str| f_p(p, k).ok_or_else(|| bad(C, format!("missing `{k}`")));
let dim = |k: &str| {
num(k).and_then(|v| {
if v.fract() == 0.0 && (0.0..=f64::from(u32::MAX)).contains(&v) { Ok(v as u32) } else { Err(bad(C, format!("`{k}` must be a whole number"))) }
})
};
let preset =
CompPreset { name: name.to_string(), width: dim("width")?, height: dim("height")?, pixel_aspect: num("pixelAspect")?, frame_rate: num("frameRate")? };
if !preset.is_valid() {
return Err(bad(
C,
format!(
"not a valid preset: width and height 4–30000 px, pixel aspect 0.01–100, frame rate 1–999, a name of at most {} characters",
CompPreset::MAX_NAME
),
));
}
let presets = &mut s.prefs.comp_presets;
let replaced = match presets.iter_mut().find(|q| q.name == preset.name) {
Some(q) => {
*q = preset;
true
}
None => {
presets.push(preset);
false
}
};
s.save_prefs();
s.prefs_revision += 1;
Ok(json!({"name": name, "replaced": replaced}))
}
/// `comp.deletePreset`: forget a saved composition preset.
fn delete_preset(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "comp.deletePreset";
let name = str_p(p, "name").ok_or_else(|| bad(C, "missing `name`"))?;
let before = s.prefs.comp_presets.len();
s.prefs.comp_presets.retain(|q| q.name != name);
if s.prefs.comp_presets.len() == before {
return Err(bad(C, format!("no saved preset named `{name}`")));
}
s.save_prefs();
s.prefs_revision += 1;
Ok(Value::Null)
}
/// `comp.presets`: the saved composition presets.
fn list_presets(s: &mut Session, _: &Value) -> Result<Value> {
serde_json::to_value(&s.prefs.comp_presets).map_err(|e| bad("comp.presets", e.to_string()))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("comp.cropToRegionOfInterest", "Crop Comp to Region of Interest", ["Composition"], None, "{comp?}", has_roi, crop_to_roi),
@ -237,18 +163,6 @@ pub fn specs() -> Vec<CommandSpec> {
has_comp,
save_frame
),
cmd!("comp.copyFrame", "Copy Frame to Clipboard", ["Composition"], None, "{comp?, time?, scale?}", has_comp, copy_frame),
cmd!(
"comp.savePreset",
"Save Composition Preset",
[],
None,
"{name, width, height, pixelAspect, frameRate} (replaces a saved preset with the same name)",
always,
save_preset
),
cmd!("comp.deletePreset", "Delete Composition Preset", [], None, "{name}", always, delete_preset),
query!("comp.presets", "Composition Presets", "{}", list_presets),
cmd!("comp.responsiveTime", "Responsive Design — Time", [], None, "{op: intro|outro|workArea}", has_comp, responsive_time),
]
}

View File

@ -405,7 +405,6 @@ pub fn specs() -> Vec<CommandSpec> {
label: "Generate Fill Layer",
menu: &[],
shortcut: None,
alt_shortcuts: &[],
params: P,
enabled: has_layers,
run: generate,
@ -416,7 +415,6 @@ pub fn specs() -> Vec<CommandSpec> {
label: "Content-Aware Fill Layer...",
menu: &["Layer", "New"],
shortcut: None,
alt_shortcuts: &[],
params: P,
enabled: always,
run: open_panel,

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