Compare commits
No commits in common. "v0.7.0" and "ci-packaging-lint" have entirely different histories.
v0.7.0
...
ci-packagi
|
|
@ -1,50 +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
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
paths:
|
||||
- Cargo.toml
|
||||
- Cargo.lock
|
||||
- "**/Cargo.toml"
|
||||
- .github/workflows/freebsd.yml
|
||||
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:
|
||||
name: FreeBSD 14 (build + test)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 300
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build and test in a FreeBSD VM
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
cpu: 4
|
||||
mem: 12288
|
||||
# Nothing to bring back; copying the target dir out of the VM fails on large builds.
|
||||
copyback: false
|
||||
prepare: |
|
||||
pkg install -y git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
|
||||
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||
run: |
|
||||
. "$HOME/.cargo/env"
|
||||
# The checkout is synced in under another owner; let tests that call git read it.
|
||||
git config --global --add safe.directory "*"
|
||||
rustc --version
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked
|
||||
|
|
@ -47,22 +47,7 @@ jobs:
|
|||
- name: XML, desktop entry, AppStream, Flatpak manifest
|
||||
run: |
|
||||
xmllint --noout packaging/windows/effectcraft.wxs packaging/macos/*.plist* packaging/linux/*.xml*
|
||||
# Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon.
|
||||
if grep -oE '<Icon Id="[^"]*"' packaging/windows/effectcraft.wxs | grep -vE '\.(ico|exe)"$'; then
|
||||
echo "::error::Icon Id must end in .ico or .exe"; exit 1
|
||||
fi
|
||||
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 - <<'EOF'
|
||||
import yaml
|
||||
src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.effectcraft.yml"))
|
||||
bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.effectcraft.bundle.yml"))
|
||||
for m in (src, bundle):
|
||||
assert m["id"] == "ai.storyteller.effectcraft" and m["command"] == "effectcraft", m["id"]
|
||||
for key in ("runtime", "runtime-version", "sdk", "finish-args"):
|
||||
assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}"
|
||||
print("Flatpak manifests ok")
|
||||
EOF
|
||||
- name: FreeBSD package layout (stub binaries)
|
||||
run: bash packaging/freebsd/package.sh --dry-run
|
||||
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'"
|
||||
|
|
|
|||
|
|
@ -1,12 +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.
|
||||
#
|
||||
# The jobs that sign (macOS, Windows) and the one that publishes the draft 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. The
|
||||
# jobs that sign nothing (version, Linux, Flatpak, FreeBSD, web) run without it, so they get no
|
||||
# secrets and a dispatch on any branch builds and checks them as a dry run; there the signed jobs
|
||||
# are refused by the environment's branch rule and the draft release job is skipped.
|
||||
# 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:
|
||||
|
|
@ -38,7 +35,7 @@ jobs:
|
|||
version:
|
||||
name: version
|
||||
runs-on: ubuntu-latest
|
||||
# No `environment: release`: it reads Cargo.toml only (see the header).
|
||||
environment: release
|
||||
outputs:
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
date: ${{ steps.v.outputs.date }}
|
||||
|
|
@ -66,7 +63,7 @@ jobs:
|
|||
macos:
|
||||
name: macOS universal
|
||||
needs: version
|
||||
runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend)
|
||||
runs-on: macos-15
|
||||
environment: release
|
||||
env:
|
||||
EFFECTCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
|
|
@ -88,9 +85,6 @@ jobs:
|
|||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
run: packaging/macos/import-cert.sh
|
||||
# Renders the DMG background from packaging/macos/dmg/background.svg (package.sh).
|
||||
- name: Install resvg
|
||||
run: cargo install resvg --locked --version 0.48.1
|
||||
- name: Build, sign, notarize
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
|
|
@ -118,9 +112,6 @@ jobs:
|
|||
include:
|
||||
- { arch: x64, target: x86_64-pc-windows-msvc }
|
||||
- { arch: x86, target: i686-pc-windows-msvc }
|
||||
# Windows on ARM: cross-compiled on the x64 runner, so signing and WiX run as above.
|
||||
# .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware.
|
||||
- { arch: arm64, target: aarch64-pc-windows-msvc }
|
||||
env:
|
||||
EFFECTCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
EFFECTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
|
|
@ -163,7 +154,7 @@ jobs:
|
|||
# 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 }}
|
||||
# No `environment: release`: this job signs nothing, so it gets no release secrets.
|
||||
environment: release
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
|
|
@ -181,7 +172,7 @@ jobs:
|
|||
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 zsync
|
||||
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"
|
||||
|
|
@ -203,9 +194,6 @@ jobs:
|
|||
for appimage in dist/release/*.AppImage; do
|
||||
chmod +x "$appimage"
|
||||
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
|
||||
# Update information is embedded, and the .zsync for delta updates sits beside it.
|
||||
"$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|'
|
||||
test -s "$appimage.zsync"
|
||||
done
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
|
|
@ -213,93 +201,11 @@ jobs:
|
|||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
flatpak:
|
||||
name: Flatpak ${{ matrix.arch }}
|
||||
needs: [version, linux]
|
||||
# Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust
|
||||
# build here. 24.04 for a newer flatpak-builder (1.4) than 22.04 ships.
|
||||
runs-on: ${{ matrix.runner }}
|
||||
# No `environment: release`: this job signs nothing, so it gets no release secrets.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x86_64, runner: ubuntu-24.04 }
|
||||
- { arch: aarch64, runner: ubuntu-24.04-arm }
|
||||
env:
|
||||
EFFECTCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
EFFECTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/linux
|
||||
- name: Install flatpak-builder
|
||||
run: |
|
||||
sudo apt-get update
|
||||
# librsvg2-common: the SVG pixbuf loader. flatpak-builder runs the host's
|
||||
# `appstreamcli compose`, which renders the scalable icon and fails without it.
|
||||
sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common
|
||||
# Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs.
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
- name: Build the Flatpak bundle
|
||||
run: packaging/linux/flatpak-bundle.sh "dist/linux/effectcraft-$EFFECTCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-flatpak-${{ matrix.arch }}
|
||||
path: dist/release/*.flatpak
|
||||
if-no-files-found: error
|
||||
|
||||
freebsd:
|
||||
name: FreeBSD x86_64
|
||||
needs: version
|
||||
# GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM, the same image and packages as
|
||||
# .github/workflows/freebsd.yml.
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 240
|
||||
# No `environment: release`: this job signs nothing, so it gets no release secrets.
|
||||
env:
|
||||
EFFECTCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
EFFECTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build and package in a FreeBSD VM (tar.gz)
|
||||
# vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build).
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
cpu: 4
|
||||
mem: 12288
|
||||
# Passed into the VM by name; never templated into the script below.
|
||||
envs: "EFFECTCRAFT_VERSION EFFECTCRAFT_BUILD_DATE EFFECTCRAFT_BUILD_SHA INPUT_VERSION"
|
||||
# The FreeBSD CI packages, plus bash for the packaging script.
|
||||
prepare: |
|
||||
pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
|
||||
run: |
|
||||
set -e
|
||||
. "$HOME/.cargo/env"
|
||||
git config --global --add safe.directory "$PWD"
|
||||
# Build outside the checkout so only dist/ is copied back out of the VM (copying the
|
||||
# target dir back fails on large builds). 4 jobs: more runs the VM out of memory.
|
||||
export CARGO_TARGET_DIR=/var/tmp/effectcraft-target CARGO_BUILD_JOBS=4
|
||||
if [ -n "$INPUT_VERSION" ]; then cargo xtask version set "$EFFECTCRAFT_VERSION"; fi
|
||||
bash packaging/freebsd/package.sh
|
||||
tar -tzf dist/release/effectcraft-*-freebsd-x86_64.tar.gz \
|
||||
| grep -E '/bin/effectcraft$|/bin/effectcraft-cli$|/share/applications/ai\.storyteller\.effectcraft\.desktop$|/share/doc/effectcraft/LICENSE-MIT$'
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-freebsd-x86_64
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
web:
|
||||
name: Web (wasm)
|
||||
needs: version
|
||||
runs-on: ubuntu-latest
|
||||
# No `environment: release`: this job signs nothing, so it gets no release secrets.
|
||||
environment: release
|
||||
env:
|
||||
EFFECTCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
EFFECTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
|
|
@ -332,8 +238,7 @@ jobs:
|
|||
|
||||
release:
|
||||
name: Draft GitHub Release
|
||||
# Needs the signed jobs, so a dry run on a branch (where they are refused) never gets here.
|
||||
needs: [version, macos, windows, linux, flatpak, freebsd, web]
|
||||
needs: [version, macos, windows, linux, web]
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
permissions:
|
||||
|
|
|
|||
|
|
@ -1,97 +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
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
paths:
|
||||
- Cargo.toml
|
||||
- Cargo.lock
|
||||
- "**/Cargo.toml"
|
||||
- "**/build.rs"
|
||||
- .github/workflows/windows-arm64.yml
|
||||
- packaging/windows/**
|
||||
schedule:
|
||||
- cron: "41 4 * * 1" # weekly, so drift in the runner images shows up
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
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
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
|
||||
jobs:
|
||||
package:
|
||||
name: package (cross-compiled on x64)
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable
|
||||
with:
|
||||
targets: aarch64-pc-windows-msvc
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
|
||||
with:
|
||||
key: windows-arm64-package
|
||||
- name: Install WiX
|
||||
shell: pwsh
|
||||
run: dotnet tool install --global wix --version 5.0.2
|
||||
- name: Build and package (unsigned)
|
||||
shell: pwsh
|
||||
env:
|
||||
DIST: ${{ github.workspace }}\dist\arm64
|
||||
run: ./packaging/windows/package.ps1 -Arch arm64
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: effectcraft-windows-arm64
|
||||
path: dist/arm64/*
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
install:
|
||||
name: install and run on ARM64
|
||||
needs: package
|
||||
runs-on: windows-11-arm
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: effectcraft-windows-arm64
|
||||
path: dist
|
||||
- name: Install the MSI, check it, run it, uninstall it
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$msi = (Get-ChildItem dist/*.msi | Select-Object -First 1).FullName
|
||||
$p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', '/l*v', 'install.log' -Wait -PassThru
|
||||
if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" }
|
||||
$dir = Join-Path $env:ProgramFiles 'EffectCraft'
|
||||
foreach ($exe in 'effectcraft.exe', 'effectcraft-cli.exe') {
|
||||
$path = Join-Path $dir $exe
|
||||
if (-not (Test-Path $path)) { throw "$exe not installed in $dir" }
|
||||
$bytes = [System.IO.File]::ReadAllBytes($path)
|
||||
$machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4)
|
||||
if ($machine -ne 0xAA64) { throw "$exe is for machine 0x$('{0:X}' -f $machine), not ARM64" }
|
||||
Write-Output "ok $path (ARM64)"
|
||||
}
|
||||
# The installed CLI runs natively on ARM64.
|
||||
$cli = Join-Path $dir 'effectcraft-cli.exe'
|
||||
$out = & $cli --version 2>&1 | Out-String
|
||||
if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "effectcraft-cli --version exited $LASTEXITCODE" }
|
||||
Write-Output "ok effectcraft-cli --version: $($out.Trim())"
|
||||
$p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru
|
||||
if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" }
|
||||
if (Test-Path (Join-Path $dir 'effectcraft.exe')) { throw 'uninstall left effectcraft.exe behind' }
|
||||
Write-Output 'ok uninstalled'
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
*~
|
||||
plan/
|
||||
target/
|
||||
/target
|
||||
/dist
|
||||
/log
|
||||
.DS_Store
|
||||
|
|
|
|||
27
AGENTS.md
27
AGENTS.md
|
|
@ -105,38 +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
|
||||
|
||||
- [docs/gaps.md](docs/gaps.md): where EffectCraft falls short of After Effects and the prioritised workstreams (G1–G9); read it before choosing work
|
||||
- [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).
|
||||
|
|
|
|||
|
|
@ -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`) |
|
||||
|
|
@ -69,38 +68,8 @@ Screenshots of EffectCraft itself, rendered headlessly, showing only procedurall
|
|||
`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
|
||||
|
|
|
|||
|
|
@ -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): the honest assessment of where we fall short and the prioritised workstreams. 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`).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1134,7 +1134,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft"
|
||||
version = "0.7.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"cpal",
|
||||
"effectcraft-engine",
|
||||
|
|
@ -1155,7 +1155,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-automation"
|
||||
version = "0.7.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-engine",
|
||||
"effectcraft-host",
|
||||
|
|
@ -1167,14 +1167,14 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-av1enc"
|
||||
version = "0.7.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"filmcraft-av1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-cli"
|
||||
version = "0.7.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"cpu-time",
|
||||
"effectcraft-automation",
|
||||
|
|
@ -1190,7 +1190,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-color"
|
||||
version = "0.7.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
|
|
@ -1198,14 +1198,13 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-effects"
|
||||
version = "0.7.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-geom",
|
||||
"effectcraft-keyframe",
|
||||
"effectcraft-project",
|
||||
"effectcraft-raster",
|
||||
"effectcraft-segment",
|
||||
"effectcraft-track",
|
||||
"log",
|
||||
"rayon",
|
||||
|
|
@ -1215,7 +1214,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-engine"
|
||||
version = "0.7.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-effects",
|
||||
|
|
@ -1230,7 +1229,6 @@ dependencies = [
|
|||
"effectcraft-psd",
|
||||
"effectcraft-raster",
|
||||
"effectcraft-render",
|
||||
"effectcraft-segment",
|
||||
"effectcraft-svg",
|
||||
"effectcraft-text",
|
||||
"effectcraft-time",
|
||||
|
|
@ -1248,11 +1246,10 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-export"
|
||||
version = "0.7.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-av1enc",
|
||||
"effectcraft-color",
|
||||
"effectcraft-effects",
|
||||
"effectcraft-hevcenc",
|
||||
"effectcraft-keyframe",
|
||||
"effectcraft-media",
|
||||
|
|
@ -1278,7 +1275,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-expr"
|
||||
version = "0.7.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"boa_engine",
|
||||
"effectcraft-color",
|
||||
|
|
@ -1295,7 +1292,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-geom"
|
||||
version = "0.7.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
|
|
@ -1303,7 +1300,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-gpu"
|
||||
version = "0.7.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"effectcraft-color",
|
||||
|
|
@ -1315,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.7.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"filmcraft-hevc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-host"
|
||||
version = "0.7.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-engine",
|
||||
"effectcraft-export",
|
||||
|
|
@ -1347,7 +1341,6 @@ dependencies = [
|
|||
"effectcraft-script",
|
||||
"effectcraft-svg",
|
||||
"effectcraft-time",
|
||||
"exr",
|
||||
"image",
|
||||
"rayon",
|
||||
"serde_json",
|
||||
|
|
@ -1355,7 +1348,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-interchange"
|
||||
version = "0.7.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-keyframe",
|
||||
|
|
@ -1374,7 +1367,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-keyframe"
|
||||
version = "0.7.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-geom",
|
||||
|
|
@ -1386,7 +1379,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-lottie"
|
||||
version = "0.7.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-effects",
|
||||
|
|
@ -1403,7 +1396,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-media"
|
||||
version = "0.7.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-model",
|
||||
"effectcraft-pdf",
|
||||
|
|
@ -1428,7 +1421,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-model"
|
||||
version = "0.7.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-geom",
|
||||
"image",
|
||||
|
|
@ -1439,14 +1432,14 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-opusenc"
|
||||
version = "0.7.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"filmcraft-opus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-path"
|
||||
version = "0.7.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-geom",
|
||||
"effectcraft-keyframe",
|
||||
|
|
@ -1458,7 +1451,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-pdf"
|
||||
version = "0.7.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-raster",
|
||||
"effectcraft-svg",
|
||||
|
|
@ -1472,7 +1465,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-plugin"
|
||||
version = "0.7.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-effects",
|
||||
"effectcraft-keyframe",
|
||||
|
|
@ -1484,7 +1477,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-project"
|
||||
version = "0.7.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-geom",
|
||||
|
|
@ -1497,14 +1490,14 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-psd"
|
||||
version = "0.7.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"miniz_oxide 0.8.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-raster"
|
||||
version = "0.7.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-geom",
|
||||
|
|
@ -1513,7 +1506,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-render"
|
||||
version = "0.7.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-effects",
|
||||
|
|
@ -1535,7 +1528,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-script"
|
||||
version = "0.7.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"boa_engine",
|
||||
"effectcraft-engine",
|
||||
|
|
@ -1548,18 +1541,9 @@ dependencies = [
|
|||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-segment"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"miniz_oxide 0.8.9",
|
||||
"ndarray",
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-svg"
|
||||
version = "0.7.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-geom",
|
||||
"effectcraft-path",
|
||||
|
|
@ -1571,7 +1555,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-text"
|
||||
version = "0.7.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-keyframe",
|
||||
"harfrust",
|
||||
|
|
@ -1583,7 +1567,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-time"
|
||||
version = "0.7.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
|
|
@ -1591,10 +1575,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-track"
|
||||
version = "0.7.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-raster",
|
||||
"effectcraft-segment",
|
||||
"rayon",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
@ -1603,7 +1586,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-ui-egui"
|
||||
version = "0.7.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-engine",
|
||||
|
|
@ -1619,7 +1602,6 @@ dependencies = [
|
|||
"eframe",
|
||||
"egui",
|
||||
"egui_kittest",
|
||||
"exr",
|
||||
"image",
|
||||
"kurbo",
|
||||
"log",
|
||||
|
|
@ -1627,18 +1609,16 @@ dependencies = [
|
|||
"rayon",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sys-locale",
|
||||
"toml",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-vp9enc"
|
||||
version = "0.7.0"
|
||||
version = "0.2.0"
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-web"
|
||||
version = "0.7.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-engine",
|
||||
"effectcraft-export",
|
||||
|
|
@ -1752,7 +1732,6 @@ dependencies = [
|
|||
"raw-window-handle",
|
||||
"smithay-clipboard",
|
||||
"web-time",
|
||||
"webbrowser",
|
||||
"winit",
|
||||
]
|
||||
|
||||
|
|
@ -2334,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"
|
||||
|
|
@ -2749,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"
|
||||
|
|
@ -3101,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"
|
||||
|
|
@ -3228,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"
|
||||
|
|
@ -4245,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"
|
||||
|
|
@ -4953,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"
|
||||
|
|
@ -5278,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"
|
||||
|
|
@ -5678,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"
|
||||
|
|
@ -6437,7 +6318,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
|||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.7.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
|||
default-members = ["crates/*", "apps/*"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.7.0"
|
||||
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" }
|
||||
|
|
@ -103,8 +102,6 @@ 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"]
|
||||
|
|
|
|||
111
README.md
111
README.md
|
|
@ -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,62 +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; 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.
|
||||
|
||||
The [ROADMAP](ROADMAP.md) has the plan and [docs/gaps.md](docs/gaps.md) the full, honest
|
||||
assessment. 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
|
||||
|
||||
|
|
@ -250,51 +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.
|
||||
|
||||
### 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 |
|
||||
|
||||
### 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
|
||||
|
|
@ -307,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) |
|
||||
|
||||
|
|
@ -354,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
|
||||
|
||||
[](https://www.star-history.com/?repos=storytold%2Feffectcraft&type=date&legend=top-left)
|
||||
|
|
|
|||
54
ROADMAP.md
54
ROADMAP.md
|
|
@ -1,11 +1,5 @@
|
|||
# Roadmap
|
||||
|
||||
- 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.
|
||||
|
||||
EffectCraft aims to do what After Effects does, with the same panels, menus and behaviour, written
|
||||
from scratch in Rust. This is where it stands. The milestones overlap; several are worked on at
|
||||
once.
|
||||
|
|
@ -16,7 +10,7 @@ once.
|
|||
| 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 | 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, content-keyed RAM preview frames) |
|
||||
| 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 |
|
||||
|
|
@ -25,48 +19,20 @@ once.
|
|||
| 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 (trained models, open source, downloaded on demand, in a swappable model module: MobileSAM for Roto Brush 2.0 / 3.0, MediaPipe Face Landmarker for face tracking) |
|
||||
| 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 |
|
||||
|
||||
## How far from full parity
|
||||
|
||||
There are two honest answers, and they are far apart. The full assessment, with the evidence, is
|
||||
in [docs/gaps.md](docs/gaps.md).
|
||||
Measured feature by feature in [docs/parity.md](docs/parity.md) (4 October 2026):
|
||||
|
||||
- **Breadth: ≈ 99%.** Almost every After Effects feature exists: 89 of 92 catalogued features
|
||||
done and 3 partial, all 306 effects implemented, 280 of them on the GPU
|
||||
([docs/parity.md](docs/parity.md)). This is our own checklist, graded by the agents that built
|
||||
the features.
|
||||
- **Real use: ≈ 30–50% (estimated, not yet measured).** Whether someone who uses After Effects for
|
||||
a living can do client work in EffectCraft. What holds it back:
|
||||
- **Fidelity is unmeasured.** No test compares our output with After Effects itself, and users
|
||||
are still finding behaviour bugs in features marked done.
|
||||
- **After Effects projects can't be opened.** EffectCraft can't read `.aep` / `.aepx` files,
|
||||
and third-party After Effects plug-ins can't run.
|
||||
- **Reliability is uneven across platforms.** Early Linux users hit basic problems: viewer
|
||||
panning, panel docking, drag-and-drop import. A Windows user found the font menus offered only
|
||||
the bundled fonts (fixed 6 October: they list every installed font). Reports of 6 October
|
||||
covered drag and drop between panels, a Render Queue menu that ignored clicks, angle
|
||||
revolutions, and preview with audio and with little video memory (all fixed 7 October).
|
||||
- **AI tools are new.** Roto Brush's trained model (MobileSAM, M13.35) and face tracking's
|
||||
(MediaPipe Face Landmarker, M13.36) are optional downloads, not yet compared with After
|
||||
Effects.
|
||||
|
||||
## Where we're going
|
||||
|
||||
In priority order (details and "done" criteria in [docs/gaps.md](docs/gaps.md)):
|
||||
|
||||
| | Workstream | Why |
|
||||
|---|---|---|
|
||||
| G1 | Measure fidelity against After Effects: a corpus of test projects rendered and sampled in both apps, scored per feature | Turns every estimate into a measurement; a feature that behaves differently is not done |
|
||||
| G2 | Real-user reliability on macOS, Windows and Linux; fix every open user issue | Basic workflows must work everywhere before more features matter |
|
||||
| G3 | Stability: a green gate on the current toolchain, fuzzing of every input | A crash loses people's work |
|
||||
| G4 | Open After Effects projects (`.aep` / `.aepx`, pending an owner decision on clean-room scope); relinking moved footage | The biggest barrier to switching |
|
||||
| G5 | Performance benchmarks at 1080p and 4K on real-world-sized projects | Unknown today |
|
||||
| G6 | Media depth: encoder efficiency, camera and phone formats | Files are larger than from mature encoders; camera formats are unverified |
|
||||
| G7 | Learned models: Roto Brush (M13.35: MobileSAM) and face tracking (M13.36: MediaPipe Face Landmarker), open source, optional downloads, swappable model module; next, measure them | Quality against After Effects is unmeasured |
|
||||
| G8 | Plug-in ecosystem on our WebAssembly plug-in API | After Effects plug-ins can't run here |
|
||||
| G9 | Localisation, accessibility, user documentation | Reach beyond English-speaking power users |
|
||||
- **≈ 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).
|
||||
|
|
|
|||
|
|
@ -9,20 +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 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
|
||||
|
|
@ -37,54 +36,10 @@
|
|||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
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
|
||||
|
|
@ -93,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]
|
||||
|
|
@ -101,7 +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
|
||||
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
|
||||
|
|
@ -111,7 +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
|
||||
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";
|
||||
|
||||
|
|
@ -158,26 +112,6 @@ const VALUED: &[&str] = &[
|
|||
"--opus-app",
|
||||
];
|
||||
|
||||
/// 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 {
|
||||
pos: Vec<String>,
|
||||
opts: Vec<(String, Option<String>)>,
|
||||
|
|
@ -187,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 {
|
||||
|
|
@ -198,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));
|
||||
}
|
||||
|
|
@ -209,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()),
|
||||
|
|
@ -249,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) {
|
||||
|
|
@ -266,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)
|
||||
}
|
||||
|
||||
|
|
@ -324,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)
|
||||
}
|
||||
|
|
@ -382,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()) {
|
||||
|
|
@ -409,16 +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");
|
||||
}
|
||||
match cmd {
|
||||
"render" => render(args, json_out)?,
|
||||
"bench" => bench_cmd(args)?,
|
||||
|
|
@ -437,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(""),
|
||||
|
|
@ -459,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(""));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -515,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") {
|
||||
|
|
@ -524,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"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -567,34 +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 b = backend(args, false)?;
|
||||
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()),
|
||||
_ => {}
|
||||
}
|
||||
McpServer::new(b).serve_stdio().map_err(|e| e.to_string())?;
|
||||
}
|
||||
other => return usage_err(&format!("unknown subcommand `{other}`")),
|
||||
}
|
||||
|
|
@ -615,12 +507,6 @@ 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> {
|
||||
|
|
@ -636,7 +522,6 @@ fn render(args: &Args, json_out: bool) -> Result<(), Failure> {
|
|||
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);
|
||||
|
|
@ -692,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("?"),
|
||||
|
|
@ -708,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),
|
||||
|
|
@ -723,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![];
|
||||
|
|
@ -731,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}")),
|
||||
|
|
@ -756,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(""),
|
||||
|
|
@ -824,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
|
||||
}));
|
||||
|
|
@ -832,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(())
|
||||
|
|
@ -904,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),
|
||||
|
|
@ -912,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -935,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),
|
||||
|
|
@ -943,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.
|
||||
|
|
@ -1106,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`:
|
||||
|
|
@ -1124,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 };
|
||||
|
|
@ -1204,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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
||||
|
|
@ -130,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();
|
||||
|
|
@ -239,218 +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();
|
||||
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!({}));
|
||||
assert!(init["instructions"].as_str().unwrap().contains(path));
|
||||
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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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())?;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
@ -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");
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -14,8 +14,7 @@ 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"] }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
|
||||
serde_json = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
cpal = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -76,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) {
|
||||
|
|
|
|||
|
|
@ -1,27 +1,13 @@
|
|||
//! 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)) {
|
||||
|
|
@ -32,243 +18,44 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
|||
}
|
||||
};
|
||||
eprintln!("effectcraft: control server listening on 127.0.0.1:{port}");
|
||||
std::thread::spawn(move || accept_loop(listener, tx, ctx));
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,151 +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). A crash in a driver
|
||||
//! can't be caught, so each launch leaves a marker in the settings folder until its window has
|
||||
//! drawn a frame. 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 effectcraft_engine::prefs::{PREFS_FILE, Prefs};
|
||||
use eframe::wgpu::Backends;
|
||||
|
||||
/// Left in the settings folder from a launch until its window draws.
|
||||
const MARKER: &str = "launch-pending";
|
||||
|
||||
/// 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 never drew: the message to show.
|
||||
pub notice: Option<String>,
|
||||
marker: Option<PathBuf>,
|
||||
/// Holds the marker's lock until the window draws (or the process ends).
|
||||
lock: Option<File>,
|
||||
}
|
||||
|
||||
impl Launch {
|
||||
/// Read Window Graphics in the settings folder `dir`, switch it to OpenGL when the previous
|
||||
/// launch never drew (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 never drew its window: switching Window Graphics to OpenGL"),
|
||||
Err(e) => log::warn!("the last launch never drew its window; saving Window Graphics failed: {e}"),
|
||||
}
|
||||
notice = Some(
|
||||
"EffectCraft's last launch stopped before 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 }
|
||||
}
|
||||
|
||||
/// The window drew its first frame: the launch made it.
|
||||
pub fn drawn(&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.
|
||||
let mut l = Launch::begin(Some(&dir), false);
|
||||
assert_eq!((l.backends, l.notice.is_some()), (None, false));
|
||||
assert!(dir.join(MARKER).exists());
|
||||
l.drawn();
|
||||
assert!(!dir.join(MARKER).exists());
|
||||
// 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.drawn();
|
||||
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 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,20 +2,13 @@
|
|||
//!
|
||||
//! 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.
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
mod appimage;
|
||||
mod audio_out;
|
||||
mod control_server;
|
||||
mod launch_guard;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod native_menu;
|
||||
|
||||
|
|
@ -24,65 +17,36 @@ 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()
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
// 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 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's
|
||||
// window never drew (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 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),
|
||||
}
|
||||
}
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("EffectCraft")
|
||||
|
|
@ -93,39 +57,14 @@ fn main() -> eframe::Result {
|
|||
.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: with `RUST_LOG=info` they go to stderr, so a window that never
|
||||
// appears shows how far start-up got (#234).
|
||||
log::info!("effectcraft {}: opening the window", env!("CARGO_PKG_VERSION"));
|
||||
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).
|
||||
|
|
@ -133,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() {
|
||||
|
|
@ -158,15 +97,8 @@ fn main() -> eframe::Result {
|
|||
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);
|
||||
}
|
||||
|
|
@ -183,15 +115,7 @@ fn main() -> eframe::Result {
|
|||
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")]
|
||||
{
|
||||
|
|
@ -203,70 +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),
|
||||
app,
|
||||
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."
|
||||
);
|
||||
log::error!("{message}");
|
||||
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.
|
||||
|
|
@ -274,9 +141,6 @@ struct Desktop {
|
|||
app: EffectcraftApp,
|
||||
#[cfg(target_os = "macos")]
|
||||
menu: native_menu::NativeBar,
|
||||
launch: launch_guard::Launch,
|
||||
/// The first frame was drawn (logged once).
|
||||
drawn: bool,
|
||||
}
|
||||
|
||||
impl eframe::App for Desktop {
|
||||
|
|
@ -288,11 +152,6 @@ impl eframe::App for Desktop {
|
|||
|
||||
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
|
||||
self.app.ui(ui, frame);
|
||||
if !self.drawn {
|
||||
self.drawn = true;
|
||||
self.launch.drawn();
|
||||
log::info!("first frame drawn");
|
||||
}
|
||||
}
|
||||
|
||||
fn on_exit(&mut self) {
|
||||
|
|
@ -304,6 +163,24 @@ impl eframe::App for Desktop {
|
|||
}
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
|
|
@ -331,37 +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)
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
|
||||
/// 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)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,38 +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
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -15,11 +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;
|
||||
mod long_job;
|
||||
pub mod server;
|
||||
pub mod tools;
|
||||
|
||||
|
|
@ -62,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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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, ¶ms) {
|
||||
Ok(mut result) => {
|
||||
modern_result_fields(method, ¶ms, &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,77 +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`).
|
||||
pub fn serve(&mut self, input: impl BufRead + Send, 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::Mutex::new(None::<std::io::Error>);
|
||||
let served = std::thread::scope(|scope| {
|
||||
let read_error = &read_error;
|
||||
std::thread::Builder::new().name("mcp-input".into()).spawn_scoped(scope, move || {
|
||||
for line in input.lines() {
|
||||
let line = match line {
|
||||
Ok(line) => line,
|
||||
Err(e) => {
|
||||
*read_error.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(e);
|
||||
break;
|
||||
}
|
||||
};
|
||||
if tx.send(Some(line)).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let _ = tx.send(None);
|
||||
})?;
|
||||
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()?;
|
||||
}
|
||||
/// 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()?;
|
||||
}
|
||||
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!({})),
|
||||
|
|
@ -181,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}"))),
|
||||
}
|
||||
|
|
@ -246,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"},
|
||||
|
|
@ -260,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}})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -177,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();
|
||||
|
|
@ -218,167 +196,6 @@ fn stdio_loop() {
|
|||
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(input.as_bytes(), &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!({}));
|
||||
assert!(init["instructions"].as_str().unwrap().contains(path));
|
||||
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();
|
||||
}
|
||||
|
||||
#[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 {
|
||||
|
|
@ -576,491 +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}");
|
||||
}
|
||||
|
||||
/// 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 "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(send);
|
||||
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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
@ -116,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."})
|
||||
|
|
@ -202,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")), ("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);
|
||||
|
|
@ -215,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![];
|
||||
|
|
@ -537,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 {
|
||||
|
|
@ -553,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,
|
||||
|
|
@ -655,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(
|
||||
|
|
@ -676,21 +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) and the name in the font's own language. Narrow with `query`; `rescan` picks up fonts installed since launch. Set one with execute_command `layer.setText {\"layer\", \"font\", \"style\"}`.",
|
||||
bridge_only: false,
|
||||
schema: || {
|
||||
schema(
|
||||
json!({
|
||||
"query": {"type": "string", "description": "Only families whose name (English or own-language) contains this text."},
|
||||
"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.",
|
||||
|
|
@ -883,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}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -247,7 +247,7 @@ pub fn process(id: &str, params: &Params, buf: &mut [f32], rate: u32, start: 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));
|
||||
buf.chunks_exact_mut(2).for_each(|f| f.swap(0, 1));
|
||||
}
|
||||
}
|
||||
"ec.audio.basstreble" => {
|
||||
|
|
@ -471,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 };
|
||||
|
|
@ -497,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;
|
||||
|
|
@ -560,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];
|
||||
|
|
@ -696,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;
|
||||
|
|
|
|||
|
|
@ -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] }
|
||||
|
|
|
|||
|
|
@ -173,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");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1602,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;
|
||||
|
|
@ -1661,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),
|
||||
|
|
@ -2038,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);
|
||||
|
|
|
|||
|
|
@ -238,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,
|
||||
|
|
@ -263,12 +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])
|
||||
}
|
||||
/// 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 {
|
||||
|
|
@ -681,7 +662,6 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[
|
|||
("sourceCrops", "Source Crops"),
|
||||
// Simulation.
|
||||
("extras", "Extras"),
|
||||
("effectCamera", "Effect Camera"),
|
||||
("wiggle", "Wiggle"),
|
||||
("light", "Light"),
|
||||
("shading", "Shading"),
|
||||
|
|
@ -1109,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.
|
||||
|
|
@ -1132,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.
|
||||
|
|
|
|||
|
|
@ -339,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::*;
|
||||
|
|
@ -581,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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -593,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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.chunks_exact(4).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)];
|
||||
|
|
@ -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
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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: ¶ms, 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(¶ms), key(&moved));
|
||||
moved.values.insert("extras/randomSeed".into(), num(9.0));
|
||||
assert_ne!(key(¶ms), key(&moved));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn particle_systems2_seek_and_direction() {
|
||||
let v = vec![
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -424,13 +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)
|
||||
(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
|
||||
|
|
@ -462,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);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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(¶ms);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
@ -306,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]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -338,8 +338,7 @@ fn reveal(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
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}))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -268,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
|
||||
),
|
||||
|
|
@ -277,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
|
||||
),
|
||||
|
|
|
|||
|
|
@ -20,12 +20,7 @@ fn has_clip(s: &Session) -> std::result::Result<(), String> {
|
|||
// Text editing pastes the system clipboard's text (passed as `text`) or copied text.
|
||||
return Ok(());
|
||||
}
|
||||
if s.state.clipboard.is_empty()
|
||||
&& s.state.key_clipboard.is_empty()
|
||||
&& s.state.effect_clipboard.is_empty()
|
||||
&& s.state.contents_clipboard.is_empty()
|
||||
&& s.state.link_clipboard.is_none()
|
||||
{
|
||||
if s.state.clipboard.is_empty() && s.state.key_clipboard.is_empty() && s.state.effect_clipboard.is_empty() && s.state.link_clipboard.is_none() {
|
||||
Err("the clipboard is empty".into())
|
||||
} else {
|
||||
Ok(())
|
||||
|
|
@ -109,11 +104,6 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
}
|
||||
return Ok(json!({"effects": out}));
|
||||
}
|
||||
// Shape items selected → duplicate them inside their layers.
|
||||
let items = super::prop_groups::selected_contents(s);
|
||||
if !items.is_empty() {
|
||||
return super::prop_groups::duplicate_contents(s, &items);
|
||||
}
|
||||
}
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
let new = s.edit("Duplicate", None, |proj, st| {
|
||||
|
|
@ -142,24 +132,11 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
if s.state.text_edit.is_some() && p.get("layers").is_none() {
|
||||
return s.execute("text.delete", json!({}));
|
||||
}
|
||||
// Time Remap selected (its name, which selects all its keys) → time remapping off, not the
|
||||
// layer (After Effects).
|
||||
if p.get("layers").is_none() {
|
||||
let remapped = super::layer_time::selected_time_remap(s);
|
||||
if !remapped.is_empty() {
|
||||
return super::layer_time::disable_time_remap(s, &remapped);
|
||||
}
|
||||
}
|
||||
// Keyframes selected → delete keys; mask vertices → delete them; else layers.
|
||||
if !s.state.selected_keys.is_empty() && p.get("layers").is_none() {
|
||||
return s.execute("keys.delete", json!({}));
|
||||
}
|
||||
// (Points selected with their mask, which selecting a mask does: the mask goes, below.)
|
||||
let comp = s.active_comp();
|
||||
let loose = s.state.selected_vertices.iter().any(|v| {
|
||||
!s.state.selected_props.contains(&(v.layer, v.mask)) && comp.and_then(|c| c.layer(v.layer)).is_some_and(|l| l.props.find_group(v.mask).is_some())
|
||||
});
|
||||
if loose && p.get("layers").is_none() {
|
||||
if !s.state.selected_vertices.is_empty() && p.get("layers").is_none() {
|
||||
return s.execute("mask.deleteVertices", json!({}));
|
||||
}
|
||||
// Puppet pins selected → delete the pins (not their layer).
|
||||
|
|
@ -170,12 +147,6 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
if p.get("layers").is_none() && !super::effect::selected_effects(s).is_empty() {
|
||||
return s.execute("effect.remove", json!({}));
|
||||
}
|
||||
// Masks, shape items, text animators or trackers selected → remove them.
|
||||
if p.get("layers").is_none()
|
||||
&& let Some(n) = super::prop_groups::remove_selected(s)?
|
||||
{
|
||||
return Ok(n);
|
||||
}
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
let ids = super::unlocked(s, cid, ids, "edit.clear")?;
|
||||
// Children of deleted layers are unparented where they are (like After Effects).
|
||||
|
|
@ -208,7 +179,6 @@ fn clear_clipboards(s: &mut Session) {
|
|||
s.state.clipboard.clear();
|
||||
s.state.key_clipboard.clear();
|
||||
s.state.effect_clipboard.clear();
|
||||
s.state.contents_clipboard.clear();
|
||||
s.state.link_clipboard = None;
|
||||
s.state.clip_is_keys = false;
|
||||
}
|
||||
|
|
@ -222,22 +192,12 @@ fn copy(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
if !s.state.selected_keys.is_empty() && p.get("layers").is_none() {
|
||||
s.state.link_clipboard = None;
|
||||
s.state.effect_clipboard.clear();
|
||||
s.state.contents_clipboard.clear();
|
||||
return s.execute("keys.copy", json!({}));
|
||||
}
|
||||
// Effects selected (Effect Controls / timeline) → copy the effects.
|
||||
if p.get("layers").is_none() && !super::effect::selected_effects(s).is_empty() {
|
||||
return s.execute("effect.copy", json!({}));
|
||||
}
|
||||
// Shape items selected in a shape layer's Contents → copy them (not their layer).
|
||||
if p.get("layers").is_none() {
|
||||
let items = super::prop_groups::copy_contents(s);
|
||||
if !items.is_empty() {
|
||||
clear_clipboards(s);
|
||||
s.state.contents_clipboard = items;
|
||||
return Ok(json!({"contents": s.state.contents_clipboard.len()}));
|
||||
}
|
||||
}
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
s.state.clip_is_keys = false;
|
||||
let comp = s.project.comp(cid).ok_or(crate::EngineError::NoComp)?;
|
||||
|
|
@ -373,14 +333,6 @@ fn cut(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
s.execute("keys.copy", json!({}))?;
|
||||
return s.execute("keys.delete", json!({}));
|
||||
}
|
||||
// Shape items selected → copied, then removed from their layers (Paste moves them).
|
||||
if p.get("layers").is_none() && super::effect::selected_effects(s).is_empty() {
|
||||
let items = super::prop_groups::selected_contents(s);
|
||||
if !items.is_empty() {
|
||||
copy(s, p)?;
|
||||
return super::prop_groups::cut_contents(s, &items);
|
||||
}
|
||||
}
|
||||
copy(s, p)?;
|
||||
delete(s, p)
|
||||
}
|
||||
|
|
@ -398,9 +350,6 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
if !s.state.effect_clipboard.is_empty() {
|
||||
return s.execute("effect.paste", p.clone());
|
||||
}
|
||||
if !s.state.contents_clipboard.is_empty() {
|
||||
return super::prop_groups::paste_contents(s, p);
|
||||
}
|
||||
let cid = super::comp_id(s, p)?;
|
||||
let clip = s.state.clipboard.clone();
|
||||
let new = s.edit("Paste", None, |proj, st| {
|
||||
|
|
@ -682,8 +631,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
vec![
|
||||
cmd!("edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, undo),
|
||||
cmd!("edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, redo),
|
||||
cmd!("edit.cut", "Cut", ["Edit"], Some("Cmd+X"), "{layers?} (keyframes, effects or shape items when they are selected)", layers_or_keys, cut),
|
||||
cmd!("edit.copy", "Copy", ["Edit"], Some("Cmd+C"), "{layers?} (keyframes, effects or shape items when they are selected)", layers_or_keys, copy),
|
||||
cmd!("edit.cut", "Cut", ["Edit"], Some("Cmd+X"), "{layers?} (keyframes when keys are selected)", layers_or_keys, cut),
|
||||
cmd!("edit.copy", "Copy", ["Edit"], Some("Cmd+C"), "{layers?} (keyframes when keys are selected)", layers_or_keys, copy),
|
||||
cmd!(
|
||||
"edit.copyWithPropertyLinks",
|
||||
"Copy with Property Links",
|
||||
|
|
@ -703,18 +652,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
|s, p| copy_links(s, p, true)
|
||||
),
|
||||
cmd!("edit.copyExpressionOnly", "Copy Expression Only", ["Edit"], None, "{}", has_props_or_layers, copy_expression_only),
|
||||
cmd!(
|
||||
"edit.paste",
|
||||
"Paste",
|
||||
["Edit"],
|
||||
Some("Cmd+V"),
|
||||
"{} (layers, keyframes at the CTI, effects, shape items into the selected shape layers, or property links / expressions)",
|
||||
has_clip,
|
||||
paste
|
||||
),
|
||||
cmd!("edit.paste", "Paste", ["Edit"], Some("Cmd+V"), "{} (layers, keyframes at the CTI, or property links / expressions)", has_clip, paste),
|
||||
cmd!("edit.pasteReversedKeyframes", "Paste Reversed Keyframes", ["Edit"], None, "{layers?, prop?|path?, time?}", has_key_clip, paste_reversed),
|
||||
cmd!("edit.clear", "Clear", ["Edit"], Some("Delete"), "{layers?}", layers_or_keys, delete),
|
||||
cmd!("edit.duplicate", "Duplicate", ["Edit"], Some("Cmd+D"), "{layers?} (effects or shape items when they are selected)", has_layers, duplicate),
|
||||
cmd!("edit.duplicate", "Duplicate", ["Edit"], Some("Cmd+D"), "{layers?}", has_layers, duplicate),
|
||||
cmd!("edit.splitLayer", "Split Layer", ["Edit"], Some("Cmd+Shift+D"), "{layers?}", has_layers, split),
|
||||
cmd!("edit.liftWorkArea", "Lift Work Area", ["Edit"], None, "{layers?}", has_comp, lift),
|
||||
cmd!("edit.extractWorkArea", "Extract Work Area", ["Edit"], None, "{layers?}", has_comp, extract),
|
||||
|
|
|
|||
|
|
@ -78,7 +78,13 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
}
|
||||
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()
|
||||
ids.iter()
|
||||
.filter_map(|id| comp.layer(*id))
|
||||
.map(|l| {
|
||||
let (w, h) = effectcraft_render::source_size(&s.project, l);
|
||||
(l.id, if w == 0 { [comp.width as f64, comp.height as f64] } else { [w as f64, h as f64] })
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let uids = s.edit(&format!("Apply {}", spec.name), None, |proj, st| {
|
||||
let mut out = vec![];
|
||||
|
|
@ -126,19 +132,6 @@ fn find_fx(s: &Session, p: &Value, cmd: &str) -> Result<(effectcraft_project::It
|
|||
Ok((cid, lid, g.uid))
|
||||
}
|
||||
|
||||
/// `effect.warning`: what Effect Controls warns about an effect (a custom OCIO config it can't
|
||||
/// read, or transforms it passes through), `null` when it renders as asked.
|
||||
fn warning(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "effect.warning";
|
||||
let (cid, lid, uid) = find_fx(s, p, cmd)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
|
||||
let g = layer.effects().and_then(|fx| fx.groups().find(|g| g.uid == uid)).ok_or_else(|| bad(cmd, "no such effect"))?;
|
||||
let t = super::time_p(s, p, Some(comp));
|
||||
let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None };
|
||||
Ok(json!({"effect": uid, "warning": effectcraft_effects::warning(g, &mut |pr| ctx.value(layer, pr))}))
|
||||
}
|
||||
|
||||
/// A colour parameter's eyedropper on a keyer (Effect Controls): the colour of the effect's input
|
||||
/// (the layer's pixels before this effect, so a keyed screen can still be picked) at a
|
||||
/// layer-space point, set on the parameter. `average` takes the 5 × 5 pixels around it.
|
||||
|
|
@ -308,7 +301,6 @@ fn copy(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
fx.iter().filter_map(|(l, u)| comp.layer(*l).and_then(|l| l.effects()).and_then(|e| e.groups().find(|g| g.uid == *u)).cloned()).collect();
|
||||
s.state.clipboard.clear();
|
||||
s.state.key_clipboard.clear();
|
||||
s.state.contents_clipboard.clear();
|
||||
s.state.link_clipboard = None;
|
||||
s.state.clip_is_keys = false;
|
||||
let n = groups.len();
|
||||
|
|
@ -375,7 +367,8 @@ fn reset(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let (cid, lid, uid) = find_fx(s, p, "effect.reset")?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
|
||||
let size = effectcraft_render::effect_bounds(&s.project, comp, layer).0;
|
||||
let (w, h) = effectcraft_render::source_size(&s.project, layer);
|
||||
let size = if w == 0 { [comp.width as f64, comp.height as f64] } else { [w as f64, h as f64] };
|
||||
let lt = layer.layer_time(s.time());
|
||||
let g = layer.effects().and_then(|fx| fx.groups().find(|g| g.uid == uid)).ok_or_else(|| bad("effect.reset", "gone"))?;
|
||||
let spec = match &g.kind {
|
||||
|
|
@ -421,42 +414,15 @@ fn list(_: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(json!(v))
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// The named channels of layer `lid` in the active comp at the current time: its
|
||||
/// multi-layer OpenEXR footage's layers and channels (`diffuse.R`, `depth.Z`, …), which
|
||||
/// EXtractoR and the 3D Channel effects read. `None` for other layers.
|
||||
pub fn layer_channels(&self, lid: LayerId) -> Option<std::sync::Arc<effectcraft_raster::AuxChannels>> {
|
||||
let layer = self.active_comp()?.layer(lid)?;
|
||||
let effectcraft_project::LayerSource::Footage { item } = layer.source else { return None };
|
||||
let effectcraft_project::ItemKind::Footage(f) = &self.project.item(item)?.kind else { return None };
|
||||
self.footage.aux(item, f, layer.layer_time(self.time()))
|
||||
}
|
||||
}
|
||||
|
||||
/// `layer.channels`: the layers and channels of a layer's multi-layer OpenEXR footage, and
|
||||
/// what each layer shows in red, green, blue and alpha (EXtractoR's popups).
|
||||
fn channels(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (_, lid) = super::layer_p(s, p, "layer.channels")?;
|
||||
let Some(aux) = s.layer_channels(lid) else { return Ok(json!({"channels": [], "layers": []})) };
|
||||
let layers: Vec<Value> = aux.layers().into_iter().map(|l| json!({"name": l, "rgba": aux.layer_rgba(l)})).collect();
|
||||
Ok(json!({"channels": aux.names(), "layers": layers}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("effect.apply", "Apply Effect", [], None, "{effect: id|name (e.g. Gaussian Blur), layers?}", has_layers, apply),
|
||||
crate::query!(
|
||||
"layer.channels",
|
||||
"Layer Channels",
|
||||
"{layer?} → {channels: [name], layers: [{name, rgba: [r, g, b, a channel]}]} — a multi-layer OpenEXR layer's channels (EXtractoR's red / green / blue / alpha take these names)",
|
||||
channels
|
||||
),
|
||||
cmd!(
|
||||
"effect.pickColor",
|
||||
"Pick Effect Colour",
|
||||
[],
|
||||
None,
|
||||
"{layer?, effect?: index|uid|name, param: id (e.g. screenColour) | prop: uid, x, y (effect space), average?: bool (5×5), time?} — the colour of the effect's input there",
|
||||
"{layer?, effect?: index|uid|name, param: id (e.g. screenColour) | prop: uid, x, y (layer px), average?: bool (5×5), time?} — the colour of the effect's input there",
|
||||
has_layers,
|
||||
pick_color
|
||||
),
|
||||
|
|
@ -470,12 +436,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
cmd!("effect.paste", "Paste Effects", [], None, "{layers?} — adds the copied effects to the layers", has_layers, paste),
|
||||
cmd!("effect.reset", "Reset Effect", [], None, "{layer?, effect}", has_layers, reset),
|
||||
crate::query!("effect.list", "List Effects", "{filter?}", list),
|
||||
crate::query!(
|
||||
"effect.warning",
|
||||
"Effect Warning",
|
||||
"{layer?, effect?: index|uid|name (default: the selected effect), time?} → {effect, warning: string|null} — what Effect Controls warns about (an OCIO config it can't read, transforms it passes through)",
|
||||
warning
|
||||
),
|
||||
crate::query!(
|
||||
"effect.plugins.list",
|
||||
"List Effect Plug-ins",
|
||||
|
|
|
|||
|
|
@ -886,8 +886,7 @@ pub fn read_template(bytes: &[u8]) -> std::result::Result<(Value, Project, Vec<(
|
|||
return Err("the template was made by a newer EffectCraft".into());
|
||||
}
|
||||
let text = String::from_utf8(get("project.ecproj").ok_or("template has no project.ecproj")?).map_err(|e| e.to_string())?;
|
||||
let mut project = Project::from_json(&text).map_err(|e| e.to_string())?;
|
||||
crate::upgrade_effects(&mut project);
|
||||
let project = Project::from_json(&text).map_err(|e| e.to_string())?;
|
||||
Ok((manifest, project, entries))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
//! File menu.
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_project::{FootageKind, ItemId, ItemKind, LayerSource, Project};
|
||||
use effectcraft_project::{FootageKind, ItemKind, Project};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, b_p, bad, str_p};
|
||||
use crate::sequence::{SequenceOptions, Source};
|
||||
use super::{CommandSpec, always, bad, str_p};
|
||||
use crate::{EngineError, Result, Session, cmd};
|
||||
|
||||
fn has_path(s: &Session) -> std::result::Result<(), String> {
|
||||
|
|
@ -102,215 +101,7 @@ fn revert(s: &mut Session, _: &Value) -> Result<Value> {
|
|||
open(s, &json!({"path": path}))
|
||||
}
|
||||
|
||||
/// File ▸ Import; with `addToComp` (files dropped on the Timeline or the Composition viewer) they
|
||||
/// also become layers of the active comp ([`add_to_comp`]).
|
||||
///
|
||||
/// With `background` (files dropped on the window or picked in the Import dialog) the files are
|
||||
/// probed on a background thread, so decoding a slow video doesn't freeze the app: the job shows
|
||||
/// the file being read and how many are left (Window ▸ Progress, `jobs.list`), then adds them in
|
||||
/// one undo step, selects them and reports how many arrived ("Imported 3 items").
|
||||
fn import_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let target = s.active_comp_id();
|
||||
let mut pending = prepare_import(s, p)?;
|
||||
if b_p(p, "background").unwrap_or(false) && !pending.sources.is_empty() {
|
||||
let sources = std::mem::take(&mut pending.sources);
|
||||
let total = sources.len();
|
||||
let label = match sources.first() {
|
||||
Some(one) if total == 1 => format!("Importing {}", one.name()),
|
||||
_ => format!("Importing {total} files"),
|
||||
};
|
||||
let p = p.clone();
|
||||
return s.spawn_task("import", label, false, move |ctl| {
|
||||
let mut probed = Vec::with_capacity(total);
|
||||
for (k, src) in sources.iter().enumerate() {
|
||||
ctl.message(format!("{} ({} of {total})", src.name(), k + 1));
|
||||
if !ctl.progress(k as u64, total as u64) {
|
||||
return Err(crate::render_queue::CANCELLED.into());
|
||||
}
|
||||
probed.push(pending.prober.probe(src));
|
||||
}
|
||||
ctl.progress(total as u64, total as u64);
|
||||
let apply: crate::jobs::Apply = Box::new(move |s: &mut Session| {
|
||||
let made = pending.comps.len() + probed.iter().filter(|r| r.is_ok()).count();
|
||||
let mut r = add_footage(s, pending, probed)?;
|
||||
finish_import(s, &mut r, target, &p);
|
||||
let mut toast = format!("Imported {made} item{}", if made == 1 { "" } else { "s" });
|
||||
let errors: Vec<&str> = r["errors"].as_array().into_iter().flatten().filter_map(Value::as_str).collect();
|
||||
match errors.as_slice() {
|
||||
[] => {}
|
||||
[one] => toast = format!("{toast}. {one}"),
|
||||
[first, rest @ ..] => toast = format!("{toast}. {first} (and {} more)", rest.len()),
|
||||
}
|
||||
r["toast"] = json!(toast);
|
||||
Ok(r)
|
||||
});
|
||||
Ok(apply)
|
||||
});
|
||||
}
|
||||
let probed = pending.sources.iter().map(|src| pending.prober.probe(src)).collect();
|
||||
let mut r = add_footage(s, pending, probed)?;
|
||||
finish_import(s, &mut r, target, p);
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// After an import (its result `r`): with `addToComp`, the new items become layers of `target`;
|
||||
/// what couldn't be added joins the result's errors.
|
||||
fn finish_import(s: &mut Session, r: &mut Value, target: Option<ItemId>, p: &Value) {
|
||||
let errors = add_to_comp(s, r, target, p);
|
||||
if !errors.is_empty()
|
||||
&& let Some(e) = r.get_mut("errors").and_then(Value::as_array_mut)
|
||||
{
|
||||
e.extend(errors.into_iter().map(Value::from));
|
||||
}
|
||||
}
|
||||
|
||||
/// With `addToComp`, put what an import (its result `r`) made into `target`, the comp that was
|
||||
/// active before it, one layer under the other, at the drop's `time`, `index` and `position`
|
||||
/// (as in `layer.addItem`): the comps of layered files (their layers' footage stays in the
|
||||
/// Project panel) and the other footage. `target` stays open. Returns what couldn't be added.
|
||||
pub(crate) fn add_to_comp(s: &mut Session, r: &Value, target: Option<ItemId>, p: &Value) -> Vec<String> {
|
||||
let Some(cid) = target.filter(|_| p.get("addToComp").and_then(Value::as_bool).unwrap_or(false)) else { return vec![] };
|
||||
let ids = |k: &str| r.get(k).and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_u64).collect::<Vec<u64>>();
|
||||
let comps = ids("comps");
|
||||
let in_comps: std::collections::HashSet<u64> = comps
|
||||
.iter()
|
||||
.filter_map(|c| s.project.comp(ItemId(*c)))
|
||||
.flat_map(|c| c.layers.iter())
|
||||
.filter_map(|l| match l.source {
|
||||
LayerSource::Footage { item } | LayerSource::Comp { item } | LayerSource::Solid { item } => Some(item.0),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let add: Vec<u64> = comps.iter().copied().chain(ids("items").into_iter().filter(|i| !in_comps.contains(i))).collect();
|
||||
let mut errors = vec![];
|
||||
for (k, item) in add.into_iter().enumerate() {
|
||||
let mut params = json!({"comp": cid.0, "item": item, "time": p.get("time"), "position": p.get("position")});
|
||||
if let Some(i) = p.get("index").and_then(Value::as_u64) {
|
||||
params["index"] = json!(i.saturating_add(k as u64));
|
||||
}
|
||||
if let Err(e) = s.execute("layer.addItem", params) {
|
||||
errors.push(e.to_string());
|
||||
}
|
||||
}
|
||||
if s.active_comp_id() != Some(cid) {
|
||||
s.open_comp(cid);
|
||||
}
|
||||
errors
|
||||
}
|
||||
|
||||
/// File ▸ Import, all in one go (scripts and commands built on it); see [`import_cmd`].
|
||||
pub(crate) fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let pending = prepare_import(s, p)?;
|
||||
let probed = pending.sources.iter().map(|src| pending.prober.probe(src)).collect();
|
||||
add_footage(s, pending, probed)
|
||||
}
|
||||
|
||||
/// An import whose layered files are already compositions: the files and image sequences left
|
||||
/// to probe as footage, how, and what the import made so far.
|
||||
struct PendingImport {
|
||||
sources: Vec<Source>,
|
||||
prober: Prober,
|
||||
comps: Vec<u64>,
|
||||
items: Vec<u64>,
|
||||
errors: Vec<String>,
|
||||
}
|
||||
|
||||
/// Everything probing a file needs, apart from the session (it can run on a background thread).
|
||||
struct Prober {
|
||||
importer: Option<std::sync::Arc<dyn crate::Importer>>,
|
||||
services: std::sync::Arc<dyn crate::Services>,
|
||||
/// Settings ▸ Import ▸ Report Missing Frames.
|
||||
report_missing_frames: bool,
|
||||
/// Settings ▸ Import ▸ Interpret Unlabeled Alpha As, for stills and for movies.
|
||||
still_alpha: Option<effectcraft_project::AlphaMode>,
|
||||
movie_alpha: Option<effectcraft_project::AlphaMode>,
|
||||
/// Image sequences' frames per second: the import's `frameRate`, or Settings ▸ Import ▸
|
||||
/// Sequence Footage.
|
||||
sequence_rate: effectcraft_time::FrameRate,
|
||||
/// PDF / Illustrator page (0-based).
|
||||
page: u32,
|
||||
/// Choose Layer: one layer of a Photoshop document.
|
||||
psd_layer: Option<Value>,
|
||||
}
|
||||
|
||||
/// A file (or image sequence) probed as footage.
|
||||
struct Probed {
|
||||
/// The Project panel name.
|
||||
name: String,
|
||||
footage: effectcraft_project::Footage,
|
||||
/// Its alpha is unlabeled and Settings say Ask User: open Interpret Footage.
|
||||
ask_alpha: bool,
|
||||
warning: Option<String>,
|
||||
}
|
||||
|
||||
impl Prober {
|
||||
/// Probe a file or image sequence as footage; the error names the file.
|
||||
fn probe(&self, src: &Source) -> std::result::Result<Probed, String> {
|
||||
let path = src.path();
|
||||
let name = src.name();
|
||||
let fail = |e: String| format!("{path}: {e}");
|
||||
// Data files (JSON, CSV, TSV) for data-driven animation: kept as text in the project.
|
||||
if let Source::File(_) = src
|
||||
&& let Some(f) = data_footage(self.services.as_ref(), path)
|
||||
{
|
||||
return f.map(|footage| Probed { name, footage, ask_alpha: false, warning: None }).map_err(fail);
|
||||
}
|
||||
let importer = self.importer.as_ref().ok_or_else(|| fail("media import is not available in this build".into()))?;
|
||||
let mut f = crate::sequence::probe(importer.as_ref(), src, self.sequence_rate).map_err(fail)?;
|
||||
let mut warning = None;
|
||||
// Settings ▸ Import ▸ Report Missing Frames (numbered sequences).
|
||||
if f.kind == FootageKind::Sequence
|
||||
&& !f.alphabetical
|
||||
&& self.report_missing_frames
|
||||
&& let Some(gaps) = crate::sequence::missing_report(&f.sequence)
|
||||
{
|
||||
warning = Some(format!("{name}: {gaps} (shown as placeholders)"));
|
||||
}
|
||||
// Settings ▸ Import ▸ Interpret Unlabeled Alpha As: images only (a 3D model renders its
|
||||
// own alpha, and audio and data have none).
|
||||
let mut ask_alpha = false;
|
||||
if matches!(f.kind, FootageKind::Still | FootageKind::Sequence | FootageKind::Video)
|
||||
&& f.alpha != effectcraft_project::AlphaMode::Ignore
|
||||
&& !alpha_is_labeled(path, &f.codec)
|
||||
{
|
||||
let setting = if f.kind == FootageKind::Video { self.movie_alpha } else { self.still_alpha };
|
||||
match setting {
|
||||
Some(a) => f.alpha = a,
|
||||
None => ask_alpha = true,
|
||||
}
|
||||
}
|
||||
// Page: another page of a PDF / Illustrator file.
|
||||
if self.page > 0 && matches!(f.codec.as_str(), "PDF" | "AI") {
|
||||
let doc = self
|
||||
.services
|
||||
.read_file(path)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|b| effectcraft_pdf::parse_page(&b, self.page as usize).map_err(|e| e.to_string()))
|
||||
.map_err(fail)?;
|
||||
(f.width, f.height) = doc.pixel_size();
|
||||
f.page = self.page;
|
||||
}
|
||||
// Choose Layer: one layer of a Photoshop document (document-sized).
|
||||
if let Some(sel) = &self.psd_layer
|
||||
&& f.codec == "PSD"
|
||||
{
|
||||
let (index, name) = self
|
||||
.services
|
||||
.read_file(path)
|
||||
.ok()
|
||||
.and_then(|b| effectcraft_psd::Psd::parse(b).ok())
|
||||
.and_then(|d| find_psd_layer(&d, sel))
|
||||
.ok_or_else(|| fail(format!("no layer {sel}")))?;
|
||||
f.layer = Some(effectcraft_project::SourceLayer { index: index as u32, name, layer_size: false, embedded: None, placed: false });
|
||||
}
|
||||
Ok(Probed { name, footage: f, ask_alpha, warning })
|
||||
}
|
||||
}
|
||||
|
||||
/// The first part of an import: read the parameters, note the files as recent footage, and
|
||||
/// import layered files (Photoshop, PDF, Illustrator, EPS) as compositions when `importAs` (or,
|
||||
/// for dropped files, Default Drag Import As) asks for that. The other files are left to probe.
|
||||
fn prepare_import(s: &mut Session, p: &Value) -> Result<PendingImport> {
|
||||
let paths: Vec<String> = match p.get("paths").or(p.get("path")) {
|
||||
Some(Value::Array(a)) => a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect(),
|
||||
Some(Value::String(x)) => vec![x.clone()],
|
||||
|
|
@ -339,8 +130,8 @@ fn prepare_import(s: &mut Session, p: &Value) -> Result<PendingImport> {
|
|||
},
|
||||
};
|
||||
let mut paths = paths;
|
||||
let mut comps = vec![];
|
||||
let mut items = vec![];
|
||||
let mut out_comps = vec![];
|
||||
let mut ids = vec![];
|
||||
let mut errors = vec![];
|
||||
if let Some(retain) = retain {
|
||||
let mut rest = vec![];
|
||||
|
|
@ -350,9 +141,9 @@ fn prepare_import(s: &mut Session, p: &Value) -> Result<PendingImport> {
|
|||
Ok(b) if effectcraft_pdf::sniff(&b).is_some() && !path.to_ascii_lowercase().ends_with(".svg") => {
|
||||
// PDF / Illustrator / EPS: one layer per file layer.
|
||||
match import_vector_comp(s, &path, &b, page) {
|
||||
Ok((comp, made)) => {
|
||||
comps.push(comp);
|
||||
items.extend(made);
|
||||
Ok((comp, items)) => {
|
||||
out_comps.push(comp);
|
||||
ids.extend(items);
|
||||
}
|
||||
Err(e) => errors.push(e.to_string()),
|
||||
}
|
||||
|
|
@ -364,84 +155,127 @@ fn prepare_import(s: &mut Session, p: &Value) -> Result<PendingImport> {
|
|||
}
|
||||
};
|
||||
match import_psd_comp(s, &path, bytes, retain) {
|
||||
Ok((comp, made, warnings)) => {
|
||||
comps.push(comp);
|
||||
items.extend(made);
|
||||
Ok((comp, items, warnings)) => {
|
||||
out_comps.push(comp);
|
||||
ids.extend(items);
|
||||
errors.extend(warnings);
|
||||
}
|
||||
Err(e) => errors.push(format!("{path}: {e}")),
|
||||
}
|
||||
}
|
||||
paths = rest;
|
||||
}
|
||||
// Numbered stills as image sequences (the Import dialog's "<format> Sequence" and Force
|
||||
// Alphabetical Order), at `frameRate` or Settings ▸ Import ▸ Sequence Footage.
|
||||
let opts = SequenceOptions::from_params(p);
|
||||
let fps = match p.get("frameRate") {
|
||||
None | Some(Value::Null) => s.prefs.import.sequence_fps,
|
||||
Some(v) => v.as_f64().filter(|x| *x > 0.0 && *x <= 999.0).ok_or_else(|| bad("file.import", "frameRate: frames per second, up to 999"))?,
|
||||
};
|
||||
let sources = crate::sequence::group(s.services.as_ref(), &paths, opts);
|
||||
let prober = Prober {
|
||||
importer: s.importer.clone(),
|
||||
services: s.services.clone(),
|
||||
report_missing_frames: s.prefs.import.report_missing_frames,
|
||||
still_alpha: s.prefs.unlabeled_alpha(false),
|
||||
movie_alpha: s.prefs.unlabeled_alpha(true),
|
||||
sequence_rate: effectcraft_time::FrameRate::from_f64(fps),
|
||||
page,
|
||||
psd_layer: p.get("layer").cloned(),
|
||||
};
|
||||
Ok(PendingImport { sources, prober, comps, items, errors })
|
||||
}
|
||||
|
||||
/// The last part of an import: add the probed files to the project (one undo step) and select
|
||||
/// everything the import made, so the Project panel shows it.
|
||||
fn add_footage(s: &mut Session, pending: PendingImport, probed: Vec<std::result::Result<Probed, String>>) -> Result<Value> {
|
||||
let PendingImport { comps, mut items, mut errors, .. } = pending;
|
||||
let mut found = vec![];
|
||||
let mut warnings = vec![];
|
||||
for r in probed {
|
||||
match r {
|
||||
Ok(f) => {
|
||||
warnings.extend(f.warning.clone());
|
||||
found.push(f);
|
||||
if paths.is_empty() {
|
||||
if let Some(c) = out_comps.first() {
|
||||
s.open_comp(effectcraft_project::ItemId(*c));
|
||||
}
|
||||
Err(e) => errors.push(e),
|
||||
return Ok(json!({"items": ids, "comps": out_comps, "errors": errors}));
|
||||
}
|
||||
}
|
||||
let mut probed = vec![];
|
||||
let psd_layer = p.get("layer").cloned();
|
||||
let seq_rate = effectcraft_time::FrameRate::from_f64(s.prefs.import.sequence_fps);
|
||||
let mut ask_alpha = vec![];
|
||||
let mut warnings = vec![];
|
||||
for path in &paths {
|
||||
// Data files (JSON, CSV, TSV) for data-driven animation: kept as text in the project.
|
||||
if let Some(f) = data_footage(s, path) {
|
||||
match f {
|
||||
Ok(f) => probed.push((path.clone(), f)),
|
||||
Err(e) => errors.push(format!("{path}: {e}")),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let Some(importer) = s.importer.clone() else {
|
||||
errors.push(format!("{path}: media import is not available in this build"));
|
||||
continue;
|
||||
};
|
||||
match importer.probe(path) {
|
||||
Ok(mut f) => {
|
||||
// Settings ▸ Import ▸ Report Missing Frames.
|
||||
if f.kind == FootageKind::Sequence
|
||||
&& s.prefs.import.report_missing_frames
|
||||
&& let Some(gaps) = missing_frames(&f.sequence)
|
||||
{
|
||||
warnings.push(format!("{path}: {gaps}"));
|
||||
}
|
||||
// Settings ▸ Import ▸ Interpret Unlabeled Alpha As.
|
||||
if f.alpha != effectcraft_project::AlphaMode::Ignore && !alpha_is_labeled(path, &f.codec) {
|
||||
match s.prefs.unlabeled_alpha(f.kind == FootageKind::Video) {
|
||||
Some(a) => f.alpha = a,
|
||||
None => ask_alpha.push(paths.iter().position(|x| x == path).unwrap_or(0)),
|
||||
}
|
||||
}
|
||||
// Settings ▸ Import ▸ Sequence Footage frames per second.
|
||||
if f.kind == FootageKind::Sequence {
|
||||
let r = seq_rate;
|
||||
let frames = f.frame_rate.frame_at(f.duration);
|
||||
f.frame_rate = r;
|
||||
f.duration = r.tick_of(frames.max(1));
|
||||
}
|
||||
// Page: another page of a PDF / Illustrator file.
|
||||
if page > 0 && matches!(f.codec.as_str(), "PDF" | "AI") {
|
||||
match s
|
||||
.services
|
||||
.read_file(path)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|b| effectcraft_pdf::parse_page(&b, page as usize).map_err(|e| e.to_string()))
|
||||
{
|
||||
Ok(doc) => {
|
||||
(f.width, f.height) = doc.pixel_size();
|
||||
f.page = page;
|
||||
}
|
||||
Err(e) => {
|
||||
errors.push(format!("{path}: {e}"));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Choose Layer: one layer of a Photoshop document (document-sized).
|
||||
if let Some(sel) = &psd_layer
|
||||
&& f.codec == "PSD"
|
||||
{
|
||||
match s.services.read_file(path).ok().and_then(|b| effectcraft_psd::Psd::parse(b).ok()).and_then(|d| find_psd_layer(&d, sel)) {
|
||||
Some((index, name)) => {
|
||||
f.layer = Some(effectcraft_project::SourceLayer { index: index as u32, name, layer_size: false, embedded: None, placed: false })
|
||||
}
|
||||
None => {
|
||||
errors.push(format!("{path}: no layer {sel}"));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
probed.push((path.clone(), f))
|
||||
}
|
||||
Err(e) => errors.push(format!("{path}: {e}")),
|
||||
}
|
||||
}
|
||||
let mut asked: Vec<u64> = vec![];
|
||||
let made: Vec<ItemId> = comps.iter().map(|c| ItemId(*c)).collect();
|
||||
if !found.is_empty() {
|
||||
s.edit("Import", None, |proj, st| {
|
||||
let mut selection = made;
|
||||
for f in found {
|
||||
let name = f.name;
|
||||
let footage = f.footage;
|
||||
let name = match &footage.layer {
|
||||
Some(l) => format!("{}/{name}", l.name),
|
||||
None if footage.page > 0 => format!("{name} (Page {})", footage.page + 1),
|
||||
None => name,
|
||||
};
|
||||
let label = match footage.kind {
|
||||
FootageKind::Still | FootageKind::Sequence => Label::Lavender,
|
||||
FootageKind::Audio => Label::SeaFoam,
|
||||
FootageKind::Video | FootageKind::Model => Label::Aqua,
|
||||
FootageKind::Data => Label::Sandstone,
|
||||
};
|
||||
let id = proj.add_item(&name, label, None, ItemKind::Footage(footage));
|
||||
items.push(id.0);
|
||||
if f.ask_alpha {
|
||||
asked.push(id.0);
|
||||
}
|
||||
selection.push(id);
|
||||
s.edit("Import", None, |proj, st| {
|
||||
for (path, f) in probed {
|
||||
let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(path.clone());
|
||||
let name = match &f.layer {
|
||||
Some(l) => format!("{}/{name}", l.name),
|
||||
None if f.page > 0 => format!("{name} (Page {})", f.page + 1),
|
||||
None => name,
|
||||
};
|
||||
let label = match f.kind {
|
||||
FootageKind::Still | FootageKind::Sequence => Label::Lavender,
|
||||
FootageKind::Audio => Label::SeaFoam,
|
||||
FootageKind::Video | FootageKind::Model => Label::Aqua,
|
||||
FootageKind::Data => Label::Sandstone,
|
||||
};
|
||||
let ask = ask_alpha.contains(&paths.iter().position(|x| *x == path).unwrap_or(usize::MAX));
|
||||
let id = proj.add_item(&name, label, None, ItemKind::Footage(f));
|
||||
ids.push(id.0);
|
||||
if ask {
|
||||
asked.push(id.0);
|
||||
}
|
||||
st.project_selection = selection;
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
if let Some(c) = comps.first() {
|
||||
s.open_comp(ItemId(*c));
|
||||
st.project_selection = vec![id];
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
if let Some(c) = out_comps.first() {
|
||||
s.open_comp(effectcraft_project::ItemId(*c));
|
||||
}
|
||||
for w in &warnings {
|
||||
s.events.push(crate::Event::Toast { message: w.clone(), error: true });
|
||||
|
|
@ -450,7 +284,7 @@ fn add_footage(s: &mut Session, pending: PendingImport, probed: Vec<std::result:
|
|||
if let Some(first) = asked.first() {
|
||||
s.events.push(crate::Event::Frontend { command: "file.interpretFootage".into(), params: json!({"item": first, "reason": "unlabeledAlpha"}) });
|
||||
}
|
||||
Ok(json!({"items": items, "comps": comps, "errors": errors, "warnings": warnings, "unlabeledAlpha": asked}))
|
||||
Ok(json!({"items": ids, "comps": out_comps, "errors": errors, "warnings": warnings, "unlabeledAlpha": asked}))
|
||||
}
|
||||
|
||||
/// Whether a file format states how its alpha is stored (PNG, GIF, WebP and PSD are straight by
|
||||
|
|
@ -460,6 +294,29 @@ fn alpha_is_labeled(path: &str, codec: &str) -> bool {
|
|||
matches!(ext.as_str(), "png" | "gif" | "webp" | "psd" | "psb" | "exr" | "svg" | "jpg" | "jpeg" | "ai" | "pdf" | "eps") || codec.eq_ignore_ascii_case("PSD")
|
||||
}
|
||||
|
||||
/// Gaps in an image sequence's frame numbers ("missing frames 4–6, 9"), from the digit run at the
|
||||
/// end of each file name. `None` when the numbering is continuous (or there is none).
|
||||
pub(crate) fn missing_frames(files: &[String]) -> Option<String> {
|
||||
let num = |f: &String| -> Option<i64> {
|
||||
let stem = std::path::Path::new(f).file_stem()?.to_string_lossy().to_string();
|
||||
let digits: String = stem.chars().rev().take_while(char::is_ascii_digit).collect::<Vec<_>>().into_iter().rev().collect();
|
||||
digits.parse().ok()
|
||||
};
|
||||
let mut ns: Vec<i64> = files.iter().filter_map(num).collect();
|
||||
ns.sort_unstable();
|
||||
ns.dedup();
|
||||
let mut gaps = vec![];
|
||||
let mut total = 0;
|
||||
for w in ns.windows(2) {
|
||||
if w[1] - w[0] > 1 {
|
||||
let (a, b) = (w[0] + 1, w[1] - 1);
|
||||
total += b - a + 1;
|
||||
gaps.push(if a == b { a.to_string() } else { format!("{a}–{b}") });
|
||||
}
|
||||
}
|
||||
(!gaps.is_empty()).then(|| format!("{total} missing frame{} ({})", if total == 1 { "" } else { "s" }, gaps.join(", ")))
|
||||
}
|
||||
|
||||
/// A Photoshop layer by index or name (pixel layers only).
|
||||
fn find_psd_layer(d: &effectcraft_psd::Psd, sel: &Value) -> Option<(usize, String)> {
|
||||
let l = match sel {
|
||||
|
|
@ -511,13 +368,13 @@ fn import_vector_comp(s: &mut Session, path: &str, bytes: &[u8], page: u32) -> R
|
|||
pub const DATA_EXTENSIONS: &[&str] = &["json", "csv", "tsv"];
|
||||
|
||||
/// A data footage item for `path` (JSON / CSV / TSV), `None` for other files.
|
||||
pub(crate) fn data_footage(services: &dyn crate::Services, path: &str) -> Option<std::result::Result<effectcraft_project::Footage, String>> {
|
||||
pub(crate) fn data_footage(s: &Session, path: &str) -> Option<std::result::Result<effectcraft_project::Footage, String>> {
|
||||
let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
|
||||
if !DATA_EXTENSIONS.contains(&ext.as_str()) {
|
||||
return None;
|
||||
}
|
||||
Some((|| {
|
||||
let bytes = services.read_file(path).map_err(|e| e.to_string())?;
|
||||
let bytes = s.services.read_file(path).map_err(|e| e.to_string())?;
|
||||
let text = String::from_utf8(bytes).map_err(|_| "data files must be UTF-8 text".to_string())?;
|
||||
if ext == "json" {
|
||||
serde_json::from_str::<Value>(text.trim_start_matches('\u{feff}')).map_err(|e| format!("invalid JSON: {e}"))?;
|
||||
|
|
@ -641,20 +498,11 @@ fn render_backend(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
pub fn backend_status(s: &Session) -> Value {
|
||||
let adapter = s.accel.as_ref().map(|a| a.name());
|
||||
let gpu = s.project.settings.gpu_acceleration;
|
||||
// Why it renders on the CPU (#262).
|
||||
let why = if !gpu {
|
||||
Some("the project's renderer is Mercury Software Only".to_string())
|
||||
} else if adapter.is_none() {
|
||||
Some(s.accel_note.clone().unwrap_or_else(|| "no GPU compositor is attached".into()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
json!({
|
||||
"renderer": if gpu { "Mercury GPU Acceleration" } else { "Mercury Software Only" },
|
||||
"gpuAcceleration": gpu,
|
||||
"adapter": adapter,
|
||||
"active": if gpu && adapter.is_some() { "gpu" } else { "cpu" },
|
||||
"why": why,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -680,9 +528,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"File...",
|
||||
["File", "Import"],
|
||||
Some("Cmd+I"),
|
||||
"{paths: [string] (files or folders), sequence?: bool (default true: numbered stills of one run import as one image sequence; one picked file brings its whole run, several picked files that range), alphabetical?: bool (Force Alphabetical Order: every image of that type in the folder, by name), frameRate?: fps (image sequences; default Settings ▸ Import ▸ Sequence Footage), importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), page?: number from 1 (PDF / Illustrator page), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As), addToComp?: bool (also add them to the active comp, at time?, index?, position? as in layer.addItem), background?: bool (probe the files in a background job, jobs.list / jobs.wait; returns {job})}",
|
||||
"{paths: [string], importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), page?: number from 1 (PDF / Illustrator page), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As)}",
|
||||
always,
|
||||
import_cmd
|
||||
import
|
||||
),
|
||||
cmd!(
|
||||
"file.projectSettings",
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ use effectcraft_time::{FrameRate, Tick};
|
|||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, b_p, bad, f_p, has_comp, has_project_selection, str_p};
|
||||
use crate::sequence::SequenceOptions;
|
||||
use crate::{EngineError, Result, Session, cmd};
|
||||
|
||||
fn has_footage_selection(s: &Session) -> std::result::Result<(), String> {
|
||||
|
|
@ -439,8 +438,8 @@ fn run_script(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
&& p.get("steps").is_none()
|
||||
&& is_js_script(path)
|
||||
{
|
||||
if is_jsxbin(path) {
|
||||
return Err(bad("file.runScript", JSXBIN));
|
||||
if path.to_ascii_lowercase().ends_with(".jsxbin") {
|
||||
return Err(bad("file.runScript", "binary .jsxbin scripts are not supported: run the .jsx source"));
|
||||
}
|
||||
let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
|
||||
let code = String::from_utf8_lossy(&bytes).to_string();
|
||||
|
|
@ -488,14 +487,7 @@ fn run_script(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
/// command scripts.
|
||||
fn is_js_script(path: &str) -> bool {
|
||||
let l = path.to_ascii_lowercase();
|
||||
l.ends_with(".jsx") || l.ends_with(".js") || is_jsxbin(path)
|
||||
}
|
||||
|
||||
/// Compiled `.jsxbin` scripts are an undocumented binary encoding, not JavaScript.
|
||||
const JSXBIN: &str = "binary .jsxbin scripts are not supported: run the .jsx source";
|
||||
|
||||
fn is_jsxbin(name: &str) -> bool {
|
||||
name.to_ascii_lowercase().ends_with(".jsxbin")
|
||||
l.ends_with(".jsx") || l.ends_with(".js") || l.ends_with(".jsxbin")
|
||||
}
|
||||
|
||||
/// A script error becomes a toast and a command error.
|
||||
|
|
@ -527,11 +519,6 @@ fn run_js(s: &mut Session, code: &str, name: &str, console: bool) -> Result<Valu
|
|||
fn script_run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let code = str_p(p, "code").ok_or_else(|| bad("script.run", "missing `code` (JavaScript)"))?.to_string();
|
||||
let name = str_p(p, "name").unwrap_or("script").to_string();
|
||||
// A compiled script (named `.jsxbin`, or its text starts with the format's header) would only
|
||||
// fail to parse at 1:1.
|
||||
if is_jsxbin(&name) || code.trim_start_matches('\u{feff}').trim_start().starts_with("@JSXBIN@") {
|
||||
return Err(bad("script.run", JSXBIN));
|
||||
}
|
||||
run_js(s, &code, &name, super::b_p(p, "console").unwrap_or(false))
|
||||
}
|
||||
|
||||
|
|
@ -553,40 +540,6 @@ pub(crate) struct Interpretation {
|
|||
fields: Option<effectcraft_project::FieldOrder>,
|
||||
profile: Option<Option<effectcraft_project::ColorSpace>>,
|
||||
linear: Option<bool>,
|
||||
/// Color ▸ Preserve RGB.
|
||||
preserve_rgb: Option<bool>,
|
||||
/// Start Timecode.
|
||||
start: Option<StartTimecode>,
|
||||
}
|
||||
|
||||
/// Interpret Footage ▸ Start Timecode.
|
||||
#[derive(Clone, Debug)]
|
||||
enum StartTimecode {
|
||||
/// Use Source File Timecode.
|
||||
Source,
|
||||
/// Override Start: a frame number.
|
||||
Frame(i64),
|
||||
/// Override Start: timecode, read at the footage's frame rate.
|
||||
Timecode(String),
|
||||
}
|
||||
|
||||
impl StartTimecode {
|
||||
/// The highest Override Start, in frames.
|
||||
const MAX: i64 = 1_000_000_000;
|
||||
|
||||
/// The frame (at `rate`) to store, `Some(None)` for Use Source; `None` when the timecode
|
||||
/// doesn't fit at `rate`.
|
||||
fn frame(&self, rate: FrameRate) -> Option<Option<i64>> {
|
||||
match self {
|
||||
StartTimecode::Source => Some(None),
|
||||
StartTimecode::Frame(n) => Some(Some(*n)),
|
||||
StartTimecode::Timecode(t) => Self::read(t, rate).map(Some),
|
||||
}
|
||||
}
|
||||
|
||||
fn read(t: &str, rate: FrameRate) -> Option<i64> {
|
||||
effectcraft_time::parse_timecode(t, rate, false, 0).ok().filter(|n| (0..=Self::MAX).contains(n))
|
||||
}
|
||||
}
|
||||
|
||||
impl Interpretation {
|
||||
|
|
@ -633,33 +586,12 @@ impl Interpretation {
|
|||
None => None,
|
||||
};
|
||||
it.linear = super::b_p(p, "linearLight");
|
||||
it.preserve_rgb = super::b_p(p, "preserveRgb");
|
||||
// Start Timecode: Use Source File Timecode (`overrideStart: false`), or Override Start
|
||||
// (timecode or a frame number).
|
||||
let start_err = || bad(cmd, "startTimecode: timecode (0:00:41:20) or a frame number, 0 to 1e9");
|
||||
it.start = match (super::b_p(p, "overrideStart"), p.get("startTimecode")) {
|
||||
(Some(false), _) => Some(StartTimecode::Source),
|
||||
(_, None | Some(Value::Null)) => None,
|
||||
(_, Some(Value::String(t))) => {
|
||||
// Read at 30 fps to check it; it's read again at the footage's rate.
|
||||
StartTimecode::read(t, FrameRate::FPS_30).ok_or_else(start_err)?;
|
||||
Some(StartTimecode::Timecode(t.clone()))
|
||||
}
|
||||
(_, Some(v)) => Some(StartTimecode::Frame(
|
||||
v.as_f64().filter(|x| x.is_finite() && (0.0..=StartTimecode::MAX as f64).contains(x)).map(|x| x.round() as i64).ok_or_else(start_err)?,
|
||||
)),
|
||||
};
|
||||
Ok(it)
|
||||
}
|
||||
|
||||
pub(crate) fn apply(&self, f: &mut Footage, guessed: Option<(AlphaMode, [f32; 3])>) {
|
||||
let before = f.frame_rate;
|
||||
self.apply_settings(f, guessed);
|
||||
// A sequence's length is its frames.
|
||||
if f.kind == FootageKind::Sequence && !f.sequence.is_empty() {
|
||||
f.sync_sequence_duration();
|
||||
return;
|
||||
}
|
||||
// Conforming keeps the frames and changes how long they last (12 frames at 30 fps are
|
||||
// 0.4 s; conformed to 12 fps, 1 s). Stills have no frame rate of their own.
|
||||
let (old, new) = (before.as_f64(), f.frame_rate.as_f64());
|
||||
|
|
@ -708,13 +640,6 @@ impl Interpretation {
|
|||
if let Some(v) = self.linear {
|
||||
f.linear_light = v;
|
||||
}
|
||||
if let Some(v) = self.preserve_rgb {
|
||||
f.preserve_rgb = v;
|
||||
}
|
||||
// After the frame rate: timecode is read at the footage's rate.
|
||||
if let Some(start) = self.start.as_ref().and_then(|t| t.frame(f.frame_rate)) {
|
||||
f.start_timecode = start;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -791,8 +716,7 @@ fn interpret(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Some(ItemKind::Footage(f)) => Some(json!({
|
||||
"item": i.0, "alpha": format!("{:?}", f.alpha), "matteColor": f.premul_color, "invertAlpha": f.invert_alpha,
|
||||
"fields": f.fields.label(), "pixelAspect": f.pixel_aspect, "loop": f.loop_count, "frameRate": f.frame_rate.as_f64(),
|
||||
"linearLight": f.linear_light, "preserveRgb": f.preserve_rgb, "startTimecode": f.timecode(0), "overrideStart": f.start_timecode.is_some(),
|
||||
"frames": f.sequence_frames(),
|
||||
"linearLight": f.linear_light,
|
||||
})),
|
||||
_ => None,
|
||||
})
|
||||
|
|
@ -821,7 +745,6 @@ fn apply_interpretation(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
f.fields = src.fields;
|
||||
f.invert_alpha = src.invert_alpha;
|
||||
f.linear_light = src.linear_light;
|
||||
f.preserve_rgb = src.preserve_rgb;
|
||||
f.color_profile = src.color_profile;
|
||||
if src.native_rate.is_some() {
|
||||
if f.native_rate.is_none() {
|
||||
|
|
@ -840,11 +763,8 @@ fn replace_footage(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let path = str_p(p, "path").ok_or_else(|| bad("file.replaceFootage", "missing `path`"))?.to_string();
|
||||
let item = *items_p(s, p).first().ok_or_else(|| bad("file.replaceFootage", "select footage"))?;
|
||||
let importer = s.importer.clone().ok_or_else(|| EngineError::Other("media import is not available in this build".into()))?;
|
||||
// A numbered still brings its image sequence, as on import (`sequence: false` for one frame).
|
||||
let opts = SequenceOptions::from_params(p);
|
||||
let src = crate::sequence::source_of(s.services.as_ref(), &path, opts);
|
||||
let f = crate::sequence::probe(importer.as_ref(), &src, s.sequence_rate()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let name = src.name();
|
||||
let f = importer.probe(&path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(path.clone());
|
||||
s.edit("Replace Footage", None, |proj, _| {
|
||||
let it = proj.item_mut(item).ok_or_else(|| bad("file.replaceFootage", "no such item"))?;
|
||||
it.name = name;
|
||||
|
|
@ -911,14 +831,11 @@ fn replace_with_solid(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
|
||||
fn reload(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let items = items_p(s, p);
|
||||
if s.importer.is_none() {
|
||||
return Err(EngineError::Other("media import is not available in this build".into()));
|
||||
}
|
||||
let Some(importer) = s.importer.clone() else { return Err(EngineError::Other("media import is not available in this build".into())) };
|
||||
let mut updates = vec![];
|
||||
for i in &items {
|
||||
if let Some(ItemKind::Footage(f)) = s.project.item(*i).map(|x| &x.kind) {
|
||||
// An image sequence picks up frames added to (or removed from) its run.
|
||||
let mut nf = match s.probe_footage(&f.path) {
|
||||
let mut nf = match importer.probe(&f.path) {
|
||||
Ok(n) => n,
|
||||
Err(_) => Footage { missing: true, ..f.clone() },
|
||||
};
|
||||
|
|
@ -926,13 +843,6 @@ fn reload(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
nf.alpha = f.alpha;
|
||||
nf.loop_count = f.loop_count;
|
||||
nf.pixel_aspect = f.pixel_aspect;
|
||||
nf.start_timecode = f.start_timecode;
|
||||
if nf.kind == FootageKind::Sequence && f.kind == FootageKind::Sequence {
|
||||
nf.frame_rate = f.frame_rate;
|
||||
nf.native_rate = f.native_rate;
|
||||
nf.alphabetical = f.alphabetical;
|
||||
nf.sync_sequence_duration();
|
||||
}
|
||||
updates.push((*i, nf));
|
||||
}
|
||||
}
|
||||
|
|
@ -1032,7 +942,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Main...",
|
||||
["File", "Interpret Footage"],
|
||||
Some("Cmd+Alt+G"),
|
||||
"{items?, frameRate?: fps|\"file\", alpha?: straight|premultiplied|ignore|guess, guessAlpha?, matteColor?, invertAlpha?, loop?, pixelAspect?, fields?: off|upper|lower, colorProfile?: srgb|rec709|rec2020|p3|auto, linearLight?, preserveRgb? (the file's values unconverted), overrideStart?: bool (false: Use Source File Timecode), startTimecode?: timecode|frame (Override Start)}",
|
||||
"{items?, frameRate?: fps|\"file\", alpha?: straight|premultiplied|ignore|guess, guessAlpha?, matteColor?, invertAlpha?, loop?, pixelAspect?, fields?: off|upper|lower, colorProfile?: srgb|rec709|rec2020|p3|auto, linearLight?}",
|
||||
has_footage_selection,
|
||||
interpret
|
||||
),
|
||||
|
|
@ -1054,15 +964,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
has_interpretation,
|
||||
apply_interpretation
|
||||
),
|
||||
cmd!(
|
||||
"file.replaceFootage",
|
||||
"File...",
|
||||
["File", "Replace Footage"],
|
||||
Some("Cmd+H"),
|
||||
"{path, item?, sequence?: bool (default true: a numbered still brings its image sequence), alphabetical?: bool}",
|
||||
has_footage_selection,
|
||||
replace_footage
|
||||
),
|
||||
cmd!("file.replaceFootage", "File...", ["File", "Replace Footage"], Some("Cmd+H"), "{path, item?}", has_footage_selection, replace_footage),
|
||||
cmd!(
|
||||
"file.replaceWithPlaceholder",
|
||||
"Placeholder...",
|
||||
|
|
|
|||
|
|
@ -47,15 +47,6 @@ fn source_timing(s: &Session, item: ItemId) -> (Option<Tick>, effectcraft_time::
|
|||
}
|
||||
}
|
||||
|
||||
/// The Footage panel's current time: footage shows its timecode (Start Timecode), anything else
|
||||
/// time from 0.
|
||||
pub fn timecode(item: Option<&effectcraft_project::Item>, fr: effectcraft_time::FrameRate, frame: i64) -> String {
|
||||
match item.map(|i| &i.kind) {
|
||||
Some(ItemKind::Footage(f)) => f.timecode(frame),
|
||||
_ => effectcraft_time::format_timecode_ae(frame, fr, false),
|
||||
}
|
||||
}
|
||||
|
||||
fn source_time(s: &Session, p: &Value, item: ItemId) -> Option<Tick> {
|
||||
let (_, fr) = source_timing(s, item);
|
||||
if let Some(t) = f_p(p, "time") {
|
||||
|
|
@ -203,11 +194,9 @@ fn can_edit(s: &Session) -> std::result::Result<(), String> {
|
|||
fn info(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let Some(v) = &s.state.footage_panel else { return Ok(Value::Null) };
|
||||
let (dur, fr) = source_timing(s, v.item);
|
||||
let item = s.project.item(v.item);
|
||||
let name = item.map(|i| i.name.clone()).unwrap_or_default();
|
||||
let frame = fr.frame_at(v.time);
|
||||
let name = s.project.item(v.item).map(|i| i.name.clone()).unwrap_or_default();
|
||||
Ok(
|
||||
json!({"item": v.item.0, "name": name, "time": v.time.seconds(), "frame": frame, "timecode": timecode(item, fr, frame), "in": v.in_point.map(|t| t.seconds()), "out": v.out_point.map(|t| t.seconds()), "duration": dur.map(|d| d.seconds()), "frameRate": fr.as_f64()}),
|
||||
json!({"item": v.item.0, "name": name, "time": v.time.seconds(), "frame": fr.frame_at(v.time), "in": v.in_point.map(|t| t.seconds()), "out": v.out_point.map(|t| t.seconds()), "duration": dur.map(|d| d.seconds()), "frameRate": fr.as_f64()}),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -165,7 +165,7 @@ pub fn layered_exr(s: &Session, cid: ItemId, t: Tick, scale: f64) -> Result<(Vec
|
|||
let sp = straight(*p);
|
||||
let a = sp[3];
|
||||
for c in 0..3 {
|
||||
out[c].push(srgb_to_linear(sp[c].max(0.0)) * a);
|
||||
out[c].push(srgb_to_linear(sp[c].clamp(0.0, 1.0)) * a);
|
||||
}
|
||||
out[3].push(a);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,14 +46,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
),
|
||||
fe!("playback.cacheWhenIdle", "Cache Frames When Idle", ["Composition", "Preview"], None, "{value?}", always),
|
||||
fe!("playback.audio", "Audio", ["Composition", "Preview"], None, "{value?: include audio in previews}", always),
|
||||
fe!(
|
||||
"playback.scrubAudio",
|
||||
"Scrub Audio",
|
||||
[],
|
||||
None,
|
||||
"{time? (s, default the current time)} — plays one frame of the comp's audio there (Ctrl/Cmd-drag the current time)",
|
||||
has_comp
|
||||
),
|
||||
// Viewer.
|
||||
fe!("view.zoomIn", "Zoom In", ["View"], Some("."), "{}", has_comp),
|
||||
fe!("view.zoomOut", "Zoom Out", ["View"], Some(","), "{}", has_comp),
|
||||
|
|
@ -72,14 +64,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
fe!("view.options", "View Options...", ["View"], Some("Cmd+Alt+U"), "{}", has_comp),
|
||||
fe!("view.layerControls", "Show Layer Controls", ["View"], Some("Cmd+Shift+H"), "{value?}", always),
|
||||
fe!("view.fullScreen", "Enter Full Screen", ["View"], Some("Ctrl+Cmd+F"), "{}", always),
|
||||
fe!(
|
||||
"view.newViewer",
|
||||
"New Viewer",
|
||||
["View"],
|
||||
Some("Alt+Shift+N"),
|
||||
"{} — another Composition viewer of the active comp; the one in use is locked",
|
||||
has_comp
|
||||
),
|
||||
// Panels and workspaces.
|
||||
fe!(
|
||||
"window.panel",
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Other ArtCraft Apps",
|
||||
[],
|
||||
None,
|
||||
"{app: photocraft|vectorcraft|filmcraft|lightcraft|pdfcraft|designcraft, kind?: page|github}",
|
||||
"{app: photocraft|vectorcraft|filmcraft|lightcraft|printcraft|designcraft, kind?: page|github}",
|
||||
always,
|
||||
sibling
|
||||
),
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ pub(crate) fn label_keys(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
pub fn amplitudes(buf: &[f32]) -> (f64, f64, f64) {
|
||||
let n = (buf.len() / 2).max(1) as f64;
|
||||
let (mut l, mut r) = (0.0f64, 0.0f64);
|
||||
for c in buf.as_chunks::<2>().0 {
|
||||
for c in buf.chunks_exact(2) {
|
||||
l += c[0].abs() as f64;
|
||||
r += c[1].abs() as f64;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
//! Layer menu.
|
||||
|
||||
use effectcraft_color::{BlendMode, Label};
|
||||
use effectcraft_keyframe::{Justify, TextDoc, Value as KV};
|
||||
use effectcraft_keyframe::{Justify, ShapePath, TextDoc, Value as KV};
|
||||
use effectcraft_project::build::{self, Ids};
|
||||
use effectcraft_project::{
|
||||
Comp, FrameBlend, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, MaskMode, MatteKind, Project, PropGroup, Quality, Solid, TrackMatte,
|
||||
|
|
@ -24,58 +24,13 @@ pub(crate) fn color_p(p: &Value, k: &str) -> Option<[f32; 3]> {
|
|||
}
|
||||
|
||||
/// Insert a new layer above the selection (or at the top) and select it.
|
||||
pub(crate) fn insert_layer(proj: &mut Project, st: &mut crate::EditorState, cid: ItemId, layer: Layer) -> Result<LayerId> {
|
||||
insert_layer_at(proj, st, cid, layer, None)
|
||||
}
|
||||
|
||||
/// `index`: an `index` parameter, the layer's 1-based place in the stack (1 = top; past the
|
||||
/// bottom = the bottom), as 0-based.
|
||||
pub(crate) fn index_p(p: &Value, cmd: &str) -> Result<Option<usize>> {
|
||||
match p.get("index") {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => {
|
||||
v.as_u64().filter(|i| *i >= 1).map(|i| Some(usize::try_from(i - 1).unwrap_or(usize::MAX))).ok_or_else(|| bad(cmd, "index: 1-based stack position"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A `position` parameter: `[x, y]` in comp pixels (finite).
|
||||
pub(crate) fn position_p(p: &Value, cmd: &str) -> Result<Option<[f64; 2]>> {
|
||||
match p.get("position") {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => v
|
||||
.as_array()
|
||||
.and_then(|a| Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?]))
|
||||
.filter(|c| c.iter().all(|x| x.is_finite()))
|
||||
.map(Some)
|
||||
.ok_or_else(|| bad(cmd, "position: [x, y] in comp pixels")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Put a new layer's Position at `pos` (comp pixels).
|
||||
pub(crate) fn place(l: &mut Layer, pos: Option<[f64; 2]>) {
|
||||
if let Some([x, y]) = pos
|
||||
&& let Some(pr) = l.props.prop_mut("transform/position")
|
||||
{
|
||||
let z = match pr.value {
|
||||
KV::Vec3(v) => v[2],
|
||||
_ => 0.0,
|
||||
};
|
||||
pr.value = KV::Vec3([x, y, z]);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`insert_layer`] at stack position `at` (0 = top) instead of above the selected layer.
|
||||
pub(crate) fn insert_layer_at(proj: &mut Project, st: &mut crate::EditorState, cid: ItemId, mut layer: Layer, at: Option<usize>) -> Result<LayerId> {
|
||||
pub(crate) fn insert_layer(proj: &mut Project, st: &mut crate::EditorState, cid: ItemId, mut layer: Layer) -> Result<LayerId> {
|
||||
// Text and shape layers in Advanced 3D comps get Geometry Options (extrusion, bevels).
|
||||
if proj.comp(cid).is_some_and(|c| c.renderer == effectcraft_project::Renderer::Advanced3D) {
|
||||
super::model3d::add_geometry_options(&mut proj.next_id, &mut layer);
|
||||
}
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let at = match at {
|
||||
Some(i) => i.min(comp.layers.len()),
|
||||
None => st.selected_layers.first().and_then(|id| comp.layers.iter().position(|l| l.id == *id)).unwrap_or(0),
|
||||
};
|
||||
let at = st.selected_layers.first().and_then(|id| comp.layers.iter().position(|l| l.id == *id)).unwrap_or(0);
|
||||
let id = layer.id;
|
||||
comp.layers.insert(at, layer);
|
||||
st.selected_layers = vec![id];
|
||||
|
|
@ -205,6 +160,30 @@ fn new_text(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(json!({"layer": id.0}))
|
||||
}
|
||||
|
||||
/// Contents for a new shape (one group with path + fill + stroke).
|
||||
fn shape_contents(ids: &mut Ids, kind: &str, size: [f64; 2], fill: Option<[f64; 4]>, stroke: Option<([f64; 4], f64)>) -> Option<PropGroup> {
|
||||
let (path, gname) = match kind {
|
||||
"rect" | "rectangle" => (build::shape_rect(ids, size, [0.0, 0.0], 0.0), "Rectangle 1"),
|
||||
"rounded" | "roundedRect" => (build::shape_rect(ids, size, [0.0, 0.0], size[0].min(size[1]) * 0.15), "Rectangle 1"),
|
||||
"ellipse" => (build::shape_ellipse(ids, size, [0.0, 0.0]), "Ellipse 1"),
|
||||
"star" => (build::shape_star(ids, true, 5.0, [0.0, 0.0], size[0] / 2.0, size[0] / 4.0), "Polystar 1"),
|
||||
"polygon" => (build::shape_star(ids, false, 6.0, [0.0, 0.0], size[0] / 2.0, 0.0), "Polystar 1"),
|
||||
_ => return None,
|
||||
};
|
||||
let mut items = vec![path];
|
||||
if let Some((c, w)) = stroke {
|
||||
items.push(build::shape_stroke(ids, c, w));
|
||||
}
|
||||
if let Some(c) = fill {
|
||||
items.push(build::shape_fill(ids, c));
|
||||
}
|
||||
Some(build::shape_group(ids, gname, items))
|
||||
}
|
||||
|
||||
fn c4(c: Option<[f32; 3]>, d: [f64; 4]) -> [f64; 4] {
|
||||
c.map(|c| [c[0] as f64, c[1] as f64, c[2] as f64, 1.0]).unwrap_or(d)
|
||||
}
|
||||
|
||||
fn new_shape(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = comp_id(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
|
||||
|
|
@ -212,24 +191,27 @@ fn new_shape(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let size = p
|
||||
.get("size")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| [a.first().and_then(Value::as_f64).unwrap_or(200.0), a.get(1).and_then(Value::as_f64).unwrap_or(200.0)])
|
||||
.map(|a| [a[0].as_f64().unwrap_or(200.0), a.get(1).and_then(Value::as_f64).unwrap_or(200.0)])
|
||||
.unwrap_or([300.0, 300.0]);
|
||||
// A fill, and a stroke only with a width (not the Tools bar's paint).
|
||||
let paint = super::shape_tool::paint_p(p, &Default::default(), "layer.newShape")?;
|
||||
let pos = p
|
||||
.get("position")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| [a.first().and_then(Value::as_f64).unwrap_or(0.0), a.get(1).and_then(Value::as_f64).unwrap_or(0.0)]);
|
||||
let fill = Some(c4(color_p(p, "fill"), [0.25, 0.55, 1.0, 1.0]));
|
||||
let stroke = (f_p(p, "strokeWidth").unwrap_or(0.0) > 0.0).then(|| (c4(color_p(p, "stroke"), [1.0, 1.0, 1.0, 1.0]), f_p(p, "strokeWidth").unwrap_or(2.0)));
|
||||
let pos = p.get("position").and_then(|v| v.as_array()).map(|a| [a[0].as_f64().unwrap_or(0.0), a.get(1).and_then(Value::as_f64).unwrap_or(0.0)]);
|
||||
let name = str_p(p, "name").unwrap_or("Shape Layer 1").to_string();
|
||||
let id = s.edit("New Shape Layer", None, |proj, st| {
|
||||
let lid = super::shape_tool::new_shape_layer(proj, st, &comp, cid, Some(&name), pos)?;
|
||||
let mut l = build::layer(proj, &comp, &name, LayerSource::Shape, (comp.width, comp.height), None);
|
||||
let mut next = proj.next_id;
|
||||
let g = super::shape_tool::shape_group(&mut Ids(&mut next), &kind, size, &paint);
|
||||
proj.next_id = next;
|
||||
if let Some(g) = g {
|
||||
super::shape_tool::add_to_contents(proj, cid, lid, g, [0.0, 0.0], "layer.newShape")?;
|
||||
if let Some(g) = shape_contents(&mut Ids(&mut next), &kind, size, fill, stroke)
|
||||
&& let Some(c) = l.props.sub_mut("contents")
|
||||
{
|
||||
c.children.push(g.into());
|
||||
}
|
||||
Ok(lid)
|
||||
proj.next_id = next;
|
||||
if let Some(pos) = pos
|
||||
&& let Some(pr) = l.props.prop_mut("transform/position")
|
||||
{
|
||||
pr.value = KV::Vec3([pos[0], pos[1], 0.0]);
|
||||
}
|
||||
insert_layer(proj, st, cid, l)
|
||||
})?;
|
||||
Ok(json!({"layer": id.0}))
|
||||
}
|
||||
|
|
@ -276,17 +258,12 @@ fn add_item(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
return Err(bad("layer.addItem", "data files can't be layers; read them in expressions with footage(\"name\").sourceData"));
|
||||
}
|
||||
ItemKind::Footage(f) if f.kind == effectcraft_project::FootageKind::Model => {
|
||||
return super::model3d::new_model(
|
||||
s,
|
||||
&serde_json::json!({"comp": cid.0, "item": item.0, "time": f_p(p, "time"), "index": p.get("index"), "position": p.get("position")}),
|
||||
);
|
||||
return super::model3d::new_model(s, &serde_json::json!({"comp": cid.0, "item": item.0, "time": f_p(p, "time")}));
|
||||
}
|
||||
ItemKind::Footage(f) => (LayerSource::Footage { item }, (f.width, f.height), Some(f.duration)),
|
||||
ItemKind::Solid(so) => (LayerSource::Solid { item }, (so.width, so.height), None),
|
||||
ItemKind::Folder => return Err(bad("layer.addItem", "folders can't be layers")),
|
||||
};
|
||||
let index = index_p(p, "layer.addItem")?;
|
||||
let position = position_p(p, "layer.addItem")?;
|
||||
let fr = comp.frame_rate;
|
||||
// Settings ▸ General ▸ Create Layers at Composition Start Time (off: at the current time).
|
||||
let default_start = if s.prefs.general.create_layers_at_comp_start || s.active_comp_id() != Some(cid) { Tick::ZERO } else { s.time() };
|
||||
|
|
@ -296,8 +273,7 @@ fn add_item(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
l.start_time = start;
|
||||
l.in_point = start;
|
||||
l.out_point = fr.snap_nearest((start + dur.unwrap_or(comp.duration)).min(comp.duration)).max(start + fr.frame_duration());
|
||||
place(&mut l, position);
|
||||
insert_layer_at(proj, st, cid, l, index)
|
||||
insert_layer(proj, st, cid, l)
|
||||
})?;
|
||||
Ok(json!({"layer": id.0}))
|
||||
}
|
||||
|
|
@ -377,8 +353,7 @@ fn set_switch(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let name = str_p(p, "switch").ok_or_else(|| bad("layer.setSwitch", "missing `switch`"))?.to_string();
|
||||
let v = b_p(p, "value");
|
||||
let label = format!("Layer Switch ({name})");
|
||||
// `merge`: a switch dragged over several layers in the Timeline is one undo step.
|
||||
let r = s.edit(&label, merge_p(p), |proj, _| {
|
||||
let r = s.edit(&label, None, |proj, _| {
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let mut last = false;
|
||||
// Toggle relative to the first layer so a multi-selection ends up consistent.
|
||||
|
|
@ -637,45 +612,6 @@ pub(crate) fn parent_fixes(s: &Session, cid: ItemId, ids: &[LayerId], parent: Op
|
|||
.collect()
|
||||
}
|
||||
|
||||
/// Validate the proposed ancestor chain before any authored edit, including when compensation
|
||||
/// is disabled. Bound both unique ancestors and linear lookup/selection work on loaded graphs.
|
||||
fn check_parent_chain(comp: &Comp, ids: &[LayerId], parent: Option<LayerId>, max_visits: usize, max_work: usize) -> Result<()> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut work = 0usize;
|
||||
let mut cur = parent;
|
||||
while let Some(id) = cur {
|
||||
if seen.len() >= max_visits {
|
||||
return Err(bad("layer.setParent", "parent chain exceeds the safety visit limit"));
|
||||
}
|
||||
if !seen.insert(id) {
|
||||
return Err(bad("layer.setParent", "parent chain contains an existing cycle"));
|
||||
}
|
||||
for selected in ids {
|
||||
if work >= max_work {
|
||||
return Err(bad("layer.setParent", "parent chain exceeds the safety work limit"));
|
||||
}
|
||||
work += 1;
|
||||
if *selected == id {
|
||||
return Err(bad("layer.setParent", "parenting would create a cycle"));
|
||||
}
|
||||
}
|
||||
let mut ancestor = None;
|
||||
for layer in &comp.layers {
|
||||
if work >= max_work {
|
||||
return Err(bad("layer.setParent", "parent chain exceeds the safety work limit"));
|
||||
}
|
||||
work += 1;
|
||||
if layer.id == id {
|
||||
ancestor = Some(layer);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let layer = ancestor.ok_or_else(|| bad("layer.setParent", "parent chain refers to a missing layer"))?;
|
||||
cur = layer.parent;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_parent(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
|
|
@ -683,9 +619,16 @@ fn set_parent(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(resolve_layer(comp, v).ok_or_else(|| bad("layer.setParent", "no such parent layer"))?),
|
||||
};
|
||||
// A loaded cycle need not include a selected child. Validate before compensation or edit;
|
||||
// do not impose the renderer's 64-ancestor truncation as a new ordinary hierarchy limit.
|
||||
check_parent_chain(comp, &ids, parent, 4096, 1_048_576)?;
|
||||
// Cycle check.
|
||||
if let Some(par) = parent {
|
||||
let mut cur = Some(par);
|
||||
while let Some(c) = cur {
|
||||
if ids.contains(&c) {
|
||||
return Err(bad("layer.setParent", "parenting would create a cycle"));
|
||||
}
|
||||
cur = comp.layer(c).and_then(|l| l.parent);
|
||||
}
|
||||
}
|
||||
// Like AE's pick-whip, parenting keeps the layer where it is: its transform is re-expressed
|
||||
// in the new parent's space (Position, Rotation, Scale), unless `compensate: false`.
|
||||
let compensate = b_p(p, "compensate").unwrap_or(true);
|
||||
|
|
@ -969,9 +912,10 @@ fn add_mask(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Some([x, y, rw, rh]) => (x + rw / 2.0, y + rh / 2.0, rw, rh),
|
||||
None => (if w > 0.0 { w / 2.0 } else { 0.0 }, h / 2.0, w * 0.6, h * 0.6),
|
||||
};
|
||||
// The shape tools' masks: rectangle, ellipse, rounded rectangle, polygon or star.
|
||||
let path = super::shape_tool::mask_path(shape, [cx, cy], [rw, rh])
|
||||
.ok_or_else(|| bad("layer.addMask", format!("unknown shape `{shape}`; one of rect, ellipse, rounded, polygon, star")))?;
|
||||
let path = match shape {
|
||||
"ellipse" => ShapePath::ellipse([cx, cy], rw, rh),
|
||||
_ => ShapePath::rect([cx, cy], rw, rh),
|
||||
};
|
||||
let mode = str_p(p, "mode").and_then(MaskMode::from_name).unwrap_or(MaskMode::Add);
|
||||
let cycle = s.prefs.appearance.cycle_mask_colors;
|
||||
let uid = s.edit("New Mask", None, |proj, _| {
|
||||
|
|
@ -1048,9 +992,7 @@ fn add_shape_item(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
"polygon" => build::shape_star(&mut ids, false, 5.0, [0.0, 0.0], 100.0, 0.0),
|
||||
"fill" => build::shape_fill(&mut ids, [1.0, 0.0, 0.0, 1.0]),
|
||||
"stroke" => build::shape_stroke(&mut ids, [1.0, 1.0, 1.0, 1.0], 2.0),
|
||||
"path" => build::shape_path(&mut ids, Default::default()),
|
||||
"gfill" | "gradientFill" => build::shape_gradient_fill(&mut ids, false, [-100.0, 0.0], [100.0, 0.0], Default::default()),
|
||||
"gstroke" | "gradientStroke" => build::shape_gradient_stroke(&mut ids, false, [-100.0, 0.0], [100.0, 0.0], Default::default(), 2.0),
|
||||
"trim" | "trimPaths" => build::shape_trim(&mut ids, 0.0, 100.0, 0.0),
|
||||
"repeater" => build::shape_repeater(&mut ids, 3.0, [100.0, 0.0]),
|
||||
k => build::shape_simple_op(&mut ids, k).ok_or_else(|| bad("layer.addShapeItem", format!("unknown kind `{k}`")))?,
|
||||
|
|
@ -1141,22 +1083,6 @@ pub(crate) fn update_position(tr: &mut PropGroup, lt: Tick, f: impl Fn([f64; 3])
|
|||
}
|
||||
}
|
||||
|
||||
/// A Transform property's default (Reset) by match id: the anchor at the centre of the source
|
||||
/// (`w`×`h`), the position at the comp's (`cw`×`ch`), 100% scale and opacity, no rotation.
|
||||
pub(crate) fn transform_default(m: &str, (w, h): (f64, f64), (cw, ch): (f64, f64)) -> Option<KV> {
|
||||
Some(match m {
|
||||
"anchor" => KV::Vec3([w / 2.0, h / 2.0, 0.0]),
|
||||
"position" => KV::Vec3([cw / 2.0, ch / 2.0, 0.0]),
|
||||
"positionX" => KV::Scalar(cw / 2.0),
|
||||
"positionY" => KV::Scalar(ch / 2.0),
|
||||
"positionZ" | "rotation" | "rotationX" | "rotationY" => KV::Scalar(0.0),
|
||||
"scale" => KV::Vec3([100.0; 3]),
|
||||
"orientation" => KV::Vec3([0.0; 3]),
|
||||
"opacity" => KV::Scalar(100.0),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn transform_op(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
let ids = unlocked(s, cid, ids, "layer.transform")?;
|
||||
|
|
@ -1179,12 +1105,14 @@ fn transform_op(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
};
|
||||
match op.as_str() {
|
||||
"reset" => {
|
||||
for m in ["anchor", "scale", "rotation", "rotationX", "rotationY", "orientation", "opacity"] {
|
||||
if let Some(v) = transform_default(m, (w, h), (cw, ch)) {
|
||||
set(tr, m, v);
|
||||
}
|
||||
}
|
||||
set(tr, "anchor", KV::Vec3([w / 2.0, h / 2.0, 0.0]));
|
||||
update_position(tr, lt, |_| [cw / 2.0, ch / 2.0, 0.0]);
|
||||
set(tr, "scale", KV::Vec3([100.0; 3]));
|
||||
set(tr, "rotation", KV::Scalar(0.0));
|
||||
set(tr, "rotationX", KV::Scalar(0.0));
|
||||
set(tr, "rotationY", KV::Scalar(0.0));
|
||||
set(tr, "orientation", KV::Vec3([0.0; 3]));
|
||||
set(tr, "opacity", KV::Scalar(100.0));
|
||||
}
|
||||
// Centred in the frame; a 3D layer keeps its depth.
|
||||
"center" => update_position(tr, lt, |p| [cw / 2.0, ch / 2.0, p[2]]),
|
||||
|
|
@ -1300,7 +1228,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Shape Layer",
|
||||
["Layer", "New"],
|
||||
None,
|
||||
"{kind?: rect|rounded|ellipse|star|polygon|none, name?, size?, fill?, fillType?, fillBlend?, fillOpacity?, stroke?, strokeType?, strokeBlend?, strokeOpacity?, strokeWidth?, position?}",
|
||||
"{kind?: rect|rounded|ellipse|star|polygon|none, name?, size?, fill?, stroke?, strokeWidth?, position?}",
|
||||
has_comp,
|
||||
new_shape
|
||||
),
|
||||
|
|
@ -1314,15 +1242,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
has_layer_settings,
|
||||
layer_settings
|
||||
),
|
||||
cmd!(
|
||||
"layer.addItem",
|
||||
"Add Footage to Comp",
|
||||
["File"],
|
||||
Some("Cmd+/"),
|
||||
"{item: id|name, time? (s, the In point), index? (1-based stack position; default above the selected layer), position? ([x, y] comp px; default the centre), duration? (s, for a still)}",
|
||||
has_comp,
|
||||
add_item
|
||||
),
|
||||
cmd!("layer.addItem", "Add Footage to Comp", ["File"], Some("Cmd+/"), "{item: id|name, time?, duration? (s, for a still)}", has_comp, add_item),
|
||||
cmd!("layer.select", "Select Layers", [], None, "{layers: [id|name|#n], add?, toggle?}", has_comp, select),
|
||||
cmd!("layer.selectNext", "Select Next Layer", [], Some("Cmd+ArrowDown"), "{add?}", has_comp, select_next),
|
||||
cmd!("layer.selectPrevious", "Select Previous Layer", [], Some("Cmd+ArrowUp"), "{add?}", has_comp, select_prev),
|
||||
|
|
@ -1331,7 +1251,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Layer Switch",
|
||||
[],
|
||||
None,
|
||||
"{layers?, switch: video|audio|solo|lock|shy|collapse|quality|fx|frameBlend|motionBlur|adjustment|threeD|guide|preserveTransparency, value?, merge?}",
|
||||
"{layers?, switch: video|audio|solo|lock|shy|collapse|quality|fx|frameBlend|motionBlur|adjustment|threeD|guide|preserveTransparency, value?}",
|
||||
has_layers,
|
||||
set_switch
|
||||
),
|
||||
|
|
@ -1383,7 +1303,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"New Mask",
|
||||
["Layer", "Mask"],
|
||||
Some("Cmd+Shift+N"),
|
||||
"{layer?, shape?: rect|ellipse|rounded|polygon|star, rect? [x,y,w,h] (layer space; a polygon or star fits its width), mode?}",
|
||||
"{layer?, shape?: rect|ellipse, rect? [x,y,w,h], mode?}",
|
||||
has_layers,
|
||||
add_mask
|
||||
),
|
||||
|
|
@ -1393,7 +1313,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Add (Shape)",
|
||||
[],
|
||||
None,
|
||||
"{layer?, kind: group|rect|ellipse|star|polygon|path|fill|stroke|gfill|gstroke|trim|repeater|round|offset|pucker|twist|zigzag|wiggle|merge, group?: uid|path} → {uid, path}",
|
||||
"{layer?, kind: group|rect|ellipse|star|polygon|fill|stroke|gfill|trim|repeater|round|offset|pucker|twist|zigzag|wiggle|merge, group?: uid|path} → {uid, path}",
|
||||
has_layers,
|
||||
add_shape_item
|
||||
),
|
||||
|
|
@ -1420,120 +1340,3 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
|
||||
#[allow(dead_code)]
|
||||
fn unused(_: &Comp) {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod parent_chain_tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn fixture() -> (Session, u64, u64, u64) {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name":"Parent safety","width":16,"height":16,"duration":1})).unwrap();
|
||||
let a = s.execute("layer.newSolid", json!({"name":"A"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let b = s.execute("layer.newSolid", json!({"name":"B"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let c = s.execute("layer.newSolid", json!({"name":"C"})).unwrap()["layer"].as_u64().unwrap();
|
||||
for id in [a, b, c] {
|
||||
s.execute("prop.set", json!({"layer":id,"path":"transform/anchor","value":[0,0,0]})).unwrap();
|
||||
}
|
||||
s.execute("prop.set", json!({"layer":a,"path":"transform/position","value":[100,200,0]})).unwrap();
|
||||
s.execute("prop.set", json!({"layer":c,"path":"transform/position","value":[10,20,0]})).unwrap();
|
||||
s.history.undo.clear();
|
||||
(s, a, b, c)
|
||||
}
|
||||
fn malformed_parent(s: &mut Session, child: u64, parent: u64) {
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(LayerId(child)).unwrap().parent = Some(LayerId(parent));
|
||||
}
|
||||
fn assert_atomic_error(s: &mut Session, child: u64, parent: u64, compensate: bool, message: &str) {
|
||||
let before = s.project.clone();
|
||||
let revision = s.revision;
|
||||
let undo = s.history.undo.len();
|
||||
let err = s.execute("layer.setParent", json!({"layers":[child],"parent":parent,"compensate":compensate})).unwrap_err();
|
||||
assert!(err.to_string().contains(message), "{err}");
|
||||
assert!(Arc::ptr_eq(&before, &s.project));
|
||||
assert_eq!(s.revision, revision);
|
||||
assert_eq!(s.history.undo.len(), undo);
|
||||
}
|
||||
#[test]
|
||||
fn loaded_cycle_excluding_selected_child_is_rejected_atomically() {
|
||||
for compensate in [true, false] {
|
||||
let (mut s, a, b, c) = fixture();
|
||||
// Simulate a malformed loaded project. Never execute this on the unguarded baseline.
|
||||
malformed_parent(&mut s, a, b);
|
||||
malformed_parent(&mut s, b, a);
|
||||
assert_atomic_error(&mut s, c, a, compensate, "existing cycle");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn dangling_loaded_parent_is_rejected_atomically() {
|
||||
for compensate in [true, false] {
|
||||
let (mut s, a, _, c) = fixture();
|
||||
malformed_parent(&mut s, a, u64::MAX);
|
||||
assert_atomic_error(&mut s, c, a, compensate, "missing layer");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn selected_descendant_and_direct_self_are_rejected() {
|
||||
let (mut s, a, _, c) = fixture();
|
||||
assert_atomic_error(&mut s, c, c, false, "create a cycle");
|
||||
s.execute("layer.setParent", json!({"layers":[a],"parent":c,"compensate":false})).unwrap();
|
||||
assert_atomic_error(&mut s, c, a, true, "create a cycle");
|
||||
}
|
||||
#[test]
|
||||
fn ordinary_parent_and_unparent_keep_explicit_false_and_undo_semantics() {
|
||||
for compensate in [true, false] {
|
||||
let (mut s, a, _, c) = fixture();
|
||||
let old = s.project.clone();
|
||||
let world = |s: &Session| {
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let comp = s.active_comp().unwrap();
|
||||
let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, comp, s.time());
|
||||
ctx.world_matrix(comp.layer(LayerId(c)).unwrap()).apply(effectcraft_geom::Vec3::ZERO)
|
||||
};
|
||||
let before = world(&s);
|
||||
s.execute("layer.setParent", json!({"layers":[c],"parent":a,"compensate":compensate})).unwrap();
|
||||
let expected = if compensate { before } else { before + effectcraft_geom::vec3(100.0, 200.0, 0.0) };
|
||||
assert!((world(&s) - expected).length() < 1e-9);
|
||||
assert_eq!(s.active_comp().unwrap().layer(LayerId(c)).unwrap().parent, Some(LayerId(a)));
|
||||
assert_eq!(s.history.undo.len(), 1);
|
||||
let parented = s.project.clone();
|
||||
assert!(s.undo());
|
||||
assert!(Arc::ptr_eq(&s.project, &old));
|
||||
assert!(s.redo());
|
||||
assert!(Arc::ptr_eq(&s.project, &parented));
|
||||
s.execute("layer.setParent", json!({"layers":[c],"parent":null,"compensate":compensate})).unwrap();
|
||||
assert_eq!(s.active_comp().unwrap().layer(LayerId(c)).unwrap().parent, None);
|
||||
assert!(s.undo());
|
||||
assert!(Arc::ptr_eq(&s.project, &parented));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn ancestor_and_lookup_work_budgets_fail_closed() {
|
||||
let (mut s, a, b, c) = fixture();
|
||||
malformed_parent(&mut s, a, b);
|
||||
let comp = s.active_comp().unwrap();
|
||||
// New solids are inserted above the selection: C,B,A. Walking A->B scans
|
||||
// three then two layers, and compares the one selected child twice: seven work units.
|
||||
assert_eq!(comp.layers.iter().map(|l| l.id).collect::<Vec<_>>(), vec![LayerId(c), LayerId(b), LayerId(a)]);
|
||||
assert!(check_parent_chain(comp, &[LayerId(c)], Some(LayerId(a)), 2, 7).is_ok());
|
||||
assert!(check_parent_chain(comp, &[LayerId(c)], Some(LayerId(a)), 2, 6).unwrap_err().to_string().contains("work limit"));
|
||||
assert!(check_parent_chain(comp, &[LayerId(c)], Some(LayerId(a)), 2, 256).is_ok());
|
||||
assert!(check_parent_chain(comp, &[LayerId(c)], Some(LayerId(a)), 1, 256).unwrap_err().to_string().contains("visit limit"));
|
||||
assert!(check_parent_chain(comp, &[LayerId(c)], Some(LayerId(a)), 2, 1).unwrap_err().to_string().contains("work limit"));
|
||||
assert!(check_parent_chain(comp, &[LayerId(c)], None, 0, 0).is_ok(), "unparenting does not traverse ancestors");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn well_formed_chain_beyond_renderer_depth_is_not_unnecessarily_rejected() {
|
||||
let (s, _, _, c) = fixture();
|
||||
let mut comp = s.active_comp().unwrap().clone();
|
||||
let template = comp.layers.first().unwrap().clone();
|
||||
for n in 0..80 {
|
||||
let mut layer = template.clone();
|
||||
layer.id = LayerId(10_000 + n);
|
||||
layer.parent = (n < 79).then_some(LayerId(10_001 + n));
|
||||
comp.layers.push(layer);
|
||||
}
|
||||
assert!(check_parent_chain(&comp, &[LayerId(c)], Some(LayerId(10_000)), 4096, 1_048_576).is_ok());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,12 +6,12 @@
|
|||
|
||||
use effectcraft_keyframe::{Keyframe, Value as KV};
|
||||
use effectcraft_project::build::Ids;
|
||||
use effectcraft_project::{Layer, LayerId, LayerSource, Node, ParamUi, Property};
|
||||
use effectcraft_project::{Layer, LayerSource, Node, ParamUi, Property};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, b_p, bad, f_p, has_layers, layers_p, str_p};
|
||||
use crate::{EngineError, KeyRef, Result, Session, cmd};
|
||||
use crate::{EngineError, Result, Session, cmd};
|
||||
|
||||
/// Layers whose source has its own time (precomps and footage) can be remapped.
|
||||
fn remappable(l: &Layer) -> bool {
|
||||
|
|
@ -86,10 +86,6 @@ fn enable_time_remap(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
if !comp.layers.iter().filter(|l| ids.contains(&l.id)).any(remappable) {
|
||||
return Err(bad("layer.enableTimeRemap", "time remapping needs a footage or composition layer"));
|
||||
}
|
||||
// Each layer's source duration (stills have none): a layer extended past it ends its remap
|
||||
// at the source's end, where its last frame starts to hold (After Effects).
|
||||
let src_end: Vec<(LayerId, Tick)> =
|
||||
comp.layers.iter().filter(|l| ids.contains(&l.id)).filter_map(|l| Some((l.id, l.source.item().and_then(|i| s.project.item(i))?.duration()?))).collect();
|
||||
let on = s.edit("Enable Time Remapping", None, |proj, _| {
|
||||
let mut next = proj.next_id;
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
|
|
@ -101,13 +97,7 @@ fn enable_time_remap(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
if target && !has {
|
||||
let mut pr = remap_prop(&mut Ids(&mut next));
|
||||
let (lin, lout) = (l.layer_time(l.in_point), l.layer_time(l.out_point));
|
||||
let (a, mut b) = (lin.min(lout), lin.max(lout));
|
||||
if let Some((_, end)) = src_end.iter().find(|(id, _)| *id == l.id)
|
||||
&& lin <= lout
|
||||
&& *end > a
|
||||
{
|
||||
b = b.min(*end);
|
||||
}
|
||||
let (a, b) = (lin.min(lout), lin.max(lout));
|
||||
pr.value = KV::Scalar(source_secs(l, l.in_point));
|
||||
pr.keys = vec![Keyframe::new(a, KV::Scalar(a.seconds())), Keyframe::new(b, KV::Scalar(b.seconds()))];
|
||||
if lin > lout {
|
||||
|
|
@ -126,40 +116,6 @@ fn enable_time_remap(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(json!(on))
|
||||
}
|
||||
|
||||
/// Layers of the active comp whose Time Remap property is selected with all of its keys (clicking
|
||||
/// its name selects them all, and a key selects its property): Edit ▸ Clear turns their time
|
||||
/// remapping off instead of deleting the keys or the layer.
|
||||
pub(crate) fn selected_time_remap(s: &Session) -> Vec<LayerId> {
|
||||
let Some(comp) = s.active_comp() else { return vec![] };
|
||||
let st = &s.state;
|
||||
st.selected_props
|
||||
.iter()
|
||||
.filter(|(lid, uid)| {
|
||||
comp.layer(*lid)
|
||||
.and_then(|l| l.props.get("timeRemap"))
|
||||
.filter(|pr| pr.uid == *uid)
|
||||
.is_some_and(|pr| pr.keys.iter().all(|k| st.selected_keys.contains(&KeyRef { layer: *lid, prop: *uid, time: k.time })))
|
||||
})
|
||||
.map(|(lid, _)| *lid)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Turn time remapping off on `layers` (one undo step) and drop the removed property and its
|
||||
/// keys from the selection.
|
||||
pub(crate) fn disable_time_remap(s: &mut Session, layers: &[LayerId]) -> Result<Value> {
|
||||
let ids: Vec<u64> = layers.iter().map(|l| l.0).collect();
|
||||
let r = s.execute("layer.enableTimeRemap", json!({"layers": ids, "value": false}))?;
|
||||
let comp = s.active_comp();
|
||||
let gone = |lid: LayerId, uid: u64| layers.contains(&lid) && comp.and_then(|c| c.layer(lid)).is_none_or(|l| l.props.find(uid).is_none());
|
||||
let (props, keys): (Vec<_>, Vec<_>) = (
|
||||
s.state.selected_props.iter().copied().filter(|(l, u)| !gone(*l, *u)).collect(),
|
||||
s.state.selected_keys.iter().copied().filter(|k| !gone(k.layer, k.prop)).collect(),
|
||||
);
|
||||
s.state.selected_props = props;
|
||||
s.state.selected_keys = keys;
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Freeze Frame: time remapping holding the current source frame for the whole layer.
|
||||
fn freeze(s: &mut Session, p: &Value, last: bool) -> Result<Value> {
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use effectcraft_time::{TICKS_PER_SECOND, Tick};
|
|||
use serde_json::{Value, json};
|
||||
|
||||
use super::prop::prop_ref;
|
||||
use super::{CommandSpec, b_p, bad, has_layers, layer_mut, layer_p, str_p};
|
||||
use super::{CommandSpec, b_p, bad, has_layers, layer_mut, layer_p};
|
||||
use crate::{EngineError, Result, Session, cmd};
|
||||
|
||||
const AXES: [(&str, &str); 3] = [("positionX", "X Position"), ("positionY", "Y Position"), ("positionZ", "Z Position")];
|
||||
|
|
@ -164,29 +164,6 @@ fn pick_whip(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let r = reference(comp, from, target, tu, s.prefs.general.expression_pick_whip_compact).ok_or_else(|| bad("prop.pickWhip", "no such target property"))?;
|
||||
let (me, them) = (from.props.find(uid).ok_or(EngineError::NoComp)?, target.props.find(tu).ok_or(EngineError::NoComp)?);
|
||||
let (dm, dt) = (dims(from, me), dims(target, them));
|
||||
// Dropped on one of the target's values: that dimension (`position[0]`), as in After Effects.
|
||||
let (r, dt) = match t.get("dimension").and_then(Value::as_u64) {
|
||||
Some(d) if d < dt as u64 => (format!("{r}[{d}]"), 1),
|
||||
Some(d) => return Err(bad("prop.pickWhip", format!("the target has {dt} dimension(s), no dimension {d}"))),
|
||||
None => (r, dt),
|
||||
};
|
||||
// While the expression is being edited the reference goes in at the cursor, replacing the
|
||||
// selected text (After Effects): `expression` is the text being edited, `range` the
|
||||
// selection [start, end] in characters.
|
||||
if let Some(base) = str_p(p, "expression") {
|
||||
let range: Option<Vec<u64>> = p.get("range").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).collect());
|
||||
let n = base.chars().count() as u64;
|
||||
let (a, b) = match range.as_deref() {
|
||||
Some(&[a, b]) => (a.min(b).min(n), a.max(b).min(n)),
|
||||
None => (n, n),
|
||||
Some(_) => return Err(bad("prop.pickWhip", "`range` must be [start, end] (characters)")),
|
||||
};
|
||||
let head: String = base.chars().take(a as usize).collect();
|
||||
let tail: String = base.chars().skip(b as usize).collect();
|
||||
let text = format!("{head}{r}{tail}");
|
||||
s.execute("prop.setExpression", json!({"layer": lid.0, "prop": uid, "expression": text}))?;
|
||||
return Ok(json!(text));
|
||||
}
|
||||
let text = if dt == 1 && dm > 1 {
|
||||
format!("temp = {r};\n[{}]", vec!["temp"; dm].join(", "))
|
||||
} else if dt > 1 && dm == 1 {
|
||||
|
|
@ -206,7 +183,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Pick Whip (Link Property)",
|
||||
[],
|
||||
None,
|
||||
"{layer?, path|prop, target: {layer, path|prop, dimension?}, expression?, range?: [start, end]} → sets an AE reference expression (`dimension`: one of the target's values, `position[0]`; with `expression`: inserts it there, replacing the characters in `range`, default the end)",
|
||||
"{layer?, path|prop, target: {layer, path|prop}} → sets an AE reference expression",
|
||||
has_layers,
|
||||
pick_whip
|
||||
),
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ fn stroke(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
.iter()
|
||||
.position(|t| t.eq_ignore_ascii_case(tool_name))
|
||||
.ok_or_else(|| bad(cmd, format!("unknown tool `{tool_name}` ({})", LIQUIFY_TOOLS.join(" | "))))?;
|
||||
let raw = p.get("points").and_then(Value::as_array).ok_or_else(|| bad(cmd, "missing `points` [[x, y], …] (effect space)"))?;
|
||||
let raw = p.get("points").and_then(Value::as_array).ok_or_else(|| bad(cmd, "missing `points` [[x, y], …] (layer space)"))?;
|
||||
let mut points = vec![];
|
||||
for q in raw {
|
||||
let a = q.as_array().ok_or_else(|| bad(cmd, "each point is [x, y]"))?;
|
||||
|
|
@ -66,7 +66,8 @@ fn stroke(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
return Err(bad(cmd, "Liquify works on footage, solid, text, shape and precomp layers"));
|
||||
}
|
||||
let lt = layer.layer_time(t);
|
||||
let size = effectcraft_render::effect_bounds(&s.project, comp, layer).0;
|
||||
let (w, h) = effectcraft_render::source_size(&s.project, layer);
|
||||
let size = if w == 0 { [comp.width as f64, comp.height as f64] } else { [w as f64, h as f64] };
|
||||
let want = p.get("effect").and_then(Value::as_u64);
|
||||
let (fx_uid, count) = s.edit("Liquify Stroke", None, |proj, _st| {
|
||||
let mut next = proj.next_id;
|
||||
|
|
@ -146,7 +147,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Liquify Stroke",
|
||||
[],
|
||||
None,
|
||||
"{layer?, effect?: uid, tool?: warp|turbulence|twirlClockwise|twirlCounterclockwise|pucker|bloat|shiftPixels|reflection|clone|reconstruction|freeze|thaw, points: [[x,y],…] (effect space), size?, pressure? (1-100), jitter? (1-100), cloneOffset?: [dx,dy], time? (s) | frame?}",
|
||||
"{layer?, effect?: uid, tool?: warp|turbulence|twirlClockwise|twirlCounterclockwise|pucker|bloat|shiftPixels|reflection|clone|reconstruction|freeze|thaw, points: [[x,y],…] (layer space), size?, pressure? (1-100), jitter? (1-100), cloneOffset?: [dx,dy], time? (s) | frame?}",
|
||||
has_comp,
|
||||
stroke
|
||||
),
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
//! comp seconds; layer markers are stored in layer time. All commands are undoable.
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_project::{Comp, CuePoint, ItemId, Layer, LayerId, LayerSource, Marker, Project};
|
||||
use effectcraft_project::{Comp, CuePoint, ItemId, LayerId, LayerSource, Marker};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
|
|
@ -85,56 +85,6 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(Value::Array(ms.iter().enumerate().map(|(i, m)| marker_json(c, o, i, m)).collect()))
|
||||
}
|
||||
|
||||
/// The nested comp's markers on a precomp layer, at their times in the containing comp `comp`
|
||||
/// and within the layer's In–Out range, as After Effects shows them on the precomp layer's bar:
|
||||
/// `(comp time, index in the nested comp, marker)`. The layer's start time and stretch map them;
|
||||
/// with time remapping, or a stretch over protected regions (Responsive Design), a marker sits at
|
||||
/// the first frame the layer shows its time.
|
||||
pub fn nested_markers<'a>(project: &'a Project, comp_id: ItemId, comp: &Comp, layer: &Layer) -> Vec<(Tick, usize, &'a Marker)> {
|
||||
let LayerSource::Comp { item } = layer.source else { return vec![] };
|
||||
let Some(nc) = project.comp(item) else { return vec![] };
|
||||
if nc.markers.is_empty() || item == comp_id {
|
||||
return vec![];
|
||||
}
|
||||
let shown = |t: Tick| t >= layer.in_point && t < layer.out_point;
|
||||
let stretched_regions = (layer.stretch - 100.0).abs() > 1e-9 && nc.markers.iter().any(|m| m.protected);
|
||||
if layer.props.get("timeRemap").is_none() && !stretched_regions {
|
||||
return nc.markers.iter().enumerate().map(|(i, m)| (layer.comp_time(m.time), i, m)).filter(|(t, _, _)| shown(*t)).collect();
|
||||
}
|
||||
// The layer's source time at every frame of its range; each marker at the first frame that
|
||||
// reaches it (within rounding: a thousandth of a nested frame).
|
||||
let fr = comp.frame_rate;
|
||||
let (f0, f1) = (fr.frame_at(layer.in_point), fr.frame_at(layer.out_point));
|
||||
let frames: Vec<(Tick, Tick)> = (f0..f1.min(f0.saturating_add(100_000)))
|
||||
.map(|f| {
|
||||
let t = fr.tick_of(f);
|
||||
(t, effectcraft_render::EvalCtx::new(project, comp_id, comp, t).source_time(layer))
|
||||
})
|
||||
.collect();
|
||||
let eps = nc.frame_duration().0 / 1000;
|
||||
nc.markers
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, m)| frames.iter().find(|(_, src)| src.0.saturating_add(eps) >= m.time.0).map(|(t, _)| (*t, i, m)))
|
||||
.filter(|(t, _, _)| shown(*t))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `markers.nested`: the nested comp's markers on a precomp layer (see [`nested_markers`]).
|
||||
fn nested(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "markers.nested";
|
||||
let cid = comp_id(s, p)?;
|
||||
let c = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let lid = p.get("layer").and_then(|v| resolve_layer(c, v)).ok_or_else(|| bad(cmd, "missing `layer` (a precomp layer)"))?;
|
||||
let l = c.layer(lid).ok_or_else(|| bad(cmd, "no such layer"))?;
|
||||
let LayerSource::Comp { item } = l.source else { return Err(bad(cmd, "not a precomp layer")) };
|
||||
let v: Vec<Value> = nested_markers(&s.project, cid, c, l)
|
||||
.into_iter()
|
||||
.map(|(t, i, m)| json!({"time": t.seconds(), "comp": item.0, "index": i, "nestedTime": m.time.seconds(), "duration": m.duration.seconds(), "comment": m.comment, "label": m.label.name()}))
|
||||
.collect();
|
||||
Ok(json!(v))
|
||||
}
|
||||
|
||||
fn locked(c: &Comp, o: Owner) -> bool {
|
||||
matches!(o, Owner::Layer(l) if c.layer(l).is_some_and(|l| l.markers_locked))
|
||||
}
|
||||
|
|
@ -349,12 +299,6 @@ fn has_precomp_layer(s: &Session) -> std::result::Result<(), String> {
|
|||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
query!("markers.list", "List Markers", "{layer? (omit for composition markers)}", list),
|
||||
query!(
|
||||
"markers.nested",
|
||||
"Nested Comp Markers",
|
||||
"{layer: a precomp layer, comp?} — its comp's markers at their times in this comp (as on the layer bar)",
|
||||
nested
|
||||
),
|
||||
cmd!(
|
||||
"markers.set",
|
||||
"Marker Settings",
|
||||
|
|
|
|||
|
|
@ -187,7 +187,6 @@ fn track_mask(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
path,
|
||||
method,
|
||||
times: times.clone(),
|
||||
face_model: if method.is_face() { s.models.face() } else { None },
|
||||
};
|
||||
s.state.mask_track_method = method;
|
||||
let wait = b_p(p, "wait").unwrap_or(false);
|
||||
|
|
@ -196,8 +195,6 @@ fn track_mask(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(json!({
|
||||
"mask": uid,
|
||||
"method": method.id(),
|
||||
// The engine face tracking tries first (the classic one when the model finds no face).
|
||||
"faceModel": method.is_face().then(|| s.models.face().map_or(effectcraft_segment::CLASSICAL, |m| m.info().id)),
|
||||
"frames": times.len() - 1,
|
||||
"start": times[0].seconds(),
|
||||
"end": times.last().map(|t| t.seconds()),
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ mod layer_time;
|
|||
pub(crate) mod link;
|
||||
mod liquify;
|
||||
mod lottie;
|
||||
pub mod markers;
|
||||
mod markers;
|
||||
mod mask;
|
||||
pub mod mask_interp;
|
||||
pub(crate) mod model3d;
|
||||
|
|
@ -54,8 +54,7 @@ pub mod roto_cmds;
|
|||
mod scene_detect;
|
||||
pub mod scripts;
|
||||
mod settings;
|
||||
pub mod shape_stroke;
|
||||
pub mod shape_tool;
|
||||
mod shape_stroke;
|
||||
mod stubs;
|
||||
mod styles;
|
||||
mod text_anim;
|
||||
|
|
@ -74,6 +73,8 @@ pub(crate) mod watch_folder;
|
|||
pub(crate) use app_more::report_text as report_text_for_tests;
|
||||
pub(crate) use app_more::view_command;
|
||||
#[cfg(test)]
|
||||
pub(crate) use file::missing_frames as missing_frames_for_tests;
|
||||
#[cfg(test)]
|
||||
pub(crate) use keys_more::amplitudes as amplitudes_for_tests;
|
||||
#[cfg(test)]
|
||||
pub(crate) use mask::split_segment as split_segment_for_tests;
|
||||
|
|
@ -132,7 +133,6 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
|||
SPECS.get_or_init(|| {
|
||||
let mut v = Vec::new();
|
||||
v.extend(file::specs());
|
||||
v.extend(crate::models::specs());
|
||||
v.extend(edit::specs());
|
||||
v.extend(comp::specs());
|
||||
v.extend(layer::specs());
|
||||
|
|
@ -164,7 +164,6 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
|||
v.extend(view::specs());
|
||||
v.extend(viewer_cmds::specs());
|
||||
v.extend(shape_stroke::specs());
|
||||
v.extend(shape_tool::specs());
|
||||
v.extend(focus::specs());
|
||||
v.extend(key_labels::specs());
|
||||
v.extend(key_transform::specs());
|
||||
|
|
@ -442,10 +441,9 @@ pub(crate) fn merge_p(p: &Value) -> Option<&str> {
|
|||
|
||||
/// `comp`: item id or name; default the active comp.
|
||||
pub(crate) fn comp_id(s: &Session, p: &Value) -> Result<ItemId> {
|
||||
let missing = |v: &Value| EngineError::NoSuchComp(v.to_string());
|
||||
match p.get("comp") {
|
||||
Some(v @ Value::Number(n)) => n.as_u64().map(ItemId).filter(|id| s.project.comp(*id).is_some()).ok_or_else(|| missing(v)),
|
||||
Some(v @ Value::String(name)) => s.project.items.values().find(|i| &i.name == name && i.as_comp().is_some()).map(|i| i.id).ok_or_else(|| missing(v)),
|
||||
Some(Value::Number(n)) => n.as_u64().map(ItemId).filter(|id| s.project.comp(*id).is_some()).ok_or(EngineError::NoComp),
|
||||
Some(Value::String(name)) => s.project.items.values().find(|i| &i.name == name && i.as_comp().is_some()).map(|i| i.id).ok_or(EngineError::NoComp),
|
||||
_ => s.active_comp_id().ok_or(EngineError::NoComp),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ use effectcraft_project::{FootageKind, ItemId, ItemKind, Layer, LayerSource, Nod
|
|||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::layer::{color_p, index_p, insert_layer, insert_layer_at, place, position_p};
|
||||
use super::layer::{color_p, insert_layer};
|
||||
use super::{CommandSpec, b_p, bad, comp_id, f_p, has_comp, has_layers, layer_mut, layer_p, layers_p, str_p};
|
||||
use crate::{EngineError, Result, Session, cmd};
|
||||
|
||||
|
|
@ -61,9 +61,6 @@ fn comp_renderer(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
sync_geometry_options(proj, cid);
|
||||
Ok(())
|
||||
})?;
|
||||
if r == Renderer::Classic3D && s.project.comp(cid).is_some_and(|c| c.layers.iter().any(|l| l.source.is_model())) {
|
||||
s.toast("Classic 3D doesn't draw 3D model layers: they show with the Advanced 3D renderer");
|
||||
}
|
||||
Ok(json!({"renderer": renderer_id(r)}))
|
||||
}
|
||||
|
||||
|
|
@ -74,25 +71,6 @@ fn renderer_id(r: Renderer) -> &'static str {
|
|||
}
|
||||
}
|
||||
|
||||
/// Only the Advanced 3D renderer draws model and primitive layers, so adding one switches a
|
||||
/// Classic 3D comp to it, like After Effects (inside the adding edit: one undo step). Whether
|
||||
/// the renderer changed.
|
||||
fn use_advanced_3d(proj: &mut effectcraft_project::Project, cid: ItemId) -> Result<bool> {
|
||||
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let switched = c.renderer != Renderer::Advanced3D;
|
||||
c.renderer = Renderer::Advanced3D;
|
||||
sync_geometry_options(proj, cid);
|
||||
Ok(switched)
|
||||
}
|
||||
|
||||
/// Tell the user when [`use_advanced_3d`] changed the comp's renderer.
|
||||
fn report_switch(s: &mut Session, cid: ItemId, switched: bool) {
|
||||
if switched {
|
||||
let name = s.project.item(cid).map_or("the composition", |i| i.name.as_str()).to_string();
|
||||
s.toast(format!("\"{name}\" now uses the Advanced 3D renderer, which draws 3D models"));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- model layers
|
||||
|
||||
/// Set a property's value at comp time `t` (adds a key when it is animated).
|
||||
|
|
@ -141,9 +119,7 @@ pub(crate) fn new_model(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let name = str_p(p, "name").unwrap_or(&it.name).to_string();
|
||||
let fr = comp.frame_rate;
|
||||
let start = fr.snap_nearest(f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or(Tick::ZERO));
|
||||
let index = index_p(p, "layer.newModel")?;
|
||||
let position = position_p(p, "layer.newModel")?;
|
||||
let (id, switched) = s.edit("New 3D Model Layer", None, |proj, st| {
|
||||
let id = s.edit("New 3D Model Layer", None, |proj, st| {
|
||||
let mut l = build::layer(proj, &comp, &name, LayerSource::Model { item }, (comp.width, comp.height), None);
|
||||
let g = build::model_geometry_options(&mut Ids(&mut proj.next_id), scale, &clips);
|
||||
if let Some(slot) = l.props.children.iter_mut().find(|c| c.match_id() == "geometryOptions") {
|
||||
|
|
@ -152,12 +128,9 @@ pub(crate) fn new_model(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
l.start_time = start;
|
||||
l.in_point = start;
|
||||
l.out_point = comp.duration.max(start + fr.frame_duration());
|
||||
place(&mut l, position);
|
||||
let id = insert_layer_at(proj, st, cid, l, index)?;
|
||||
Ok((id, use_advanced_3d(proj, cid)?))
|
||||
insert_layer(proj, st, cid, l)
|
||||
})?;
|
||||
report_switch(s, cid, switched);
|
||||
Ok(json!({"layer": id.0, "item": item.0, "advanced3d": true}))
|
||||
Ok(json!({"layer": id.0, "item": item.0}))
|
||||
}
|
||||
|
||||
fn kind_p(p: &Value, cmd: &str) -> Result<PrimitiveKind> {
|
||||
|
|
@ -211,7 +184,7 @@ fn new_primitive(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
|
||||
let name = str_p(p, "name").map(str::to_string).unwrap_or_else(|| kind.label().to_string());
|
||||
let t = super::time_p(s, p, Some(&comp));
|
||||
let (id, switched) = s.edit(&format!("New {}", kind.label()), None, |proj, st| {
|
||||
let id = s.edit(&format!("New {}", kind.label()), None, |proj, st| {
|
||||
let mut l = build::layer(proj, &comp, &name, LayerSource::Primitive { kind }, (comp.width, comp.height), None);
|
||||
for k in ["width", "height", "depth", "radius", "tubeRadius", "segments", "rings"] {
|
||||
if let Some(v) = f_p(p, k) {
|
||||
|
|
@ -223,11 +196,11 @@ fn new_primitive(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
set_at(&mut l, "transform/position", t, KV::Vec3([g(0), g(1), g(2)]));
|
||||
}
|
||||
apply_material(&mut l, p, t, "layer.newPrimitive")?;
|
||||
let id = insert_layer(proj, st, cid, l)?;
|
||||
Ok((id, use_advanced_3d(proj, cid)?))
|
||||
insert_layer(proj, st, cid, l)
|
||||
})?;
|
||||
report_switch(s, cid, switched);
|
||||
Ok(json!({"layer": id.0, "kind": kind.label(), "advanced3d": true}))
|
||||
// Primitives are drawn by the Advanced 3D renderer.
|
||||
let advanced = s.project.comp(cid).is_some_and(|c| c.renderer == Renderer::Advanced3D);
|
||||
Ok(json!({"layer": id.0, "kind": kind.label(), "advanced3d": advanced}))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- environment layer
|
||||
|
|
@ -376,7 +349,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"3D Model Layer",
|
||||
[],
|
||||
None,
|
||||
"{item?: id or name of a 3D model footage item, path?: a .gltf, .glb or .obj file to import, name?, time? (s), index? (1-based stack position), position? ([x, y] comp px)}; switches a Classic 3D comp to Advanced 3D",
|
||||
"{item?: id or name of a 3D model footage item, path?: a .gltf, .glb or .obj file to import, name?, time? (s)}",
|
||||
has_comp,
|
||||
new_model
|
||||
),
|
||||
|
|
@ -385,7 +358,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"3D Primitive",
|
||||
[],
|
||||
None,
|
||||
"{kind: cube|sphere|plane|torus|cone|cylinder, name?, width?, height?, depth?, radius?, tubeRadius?, segments?, rings?, position? [x,y,z], baseColor?, metallic?, roughness?, emissive?, castsShadows?, acceptsShadows?, acceptsLights?}; switches a Classic 3D comp to Advanced 3D",
|
||||
"{kind: cube|sphere|plane|torus|cone|cylinder, name?, width?, height?, depth?, radius?, tubeRadius?, segments?, rings?, position? [x,y,z], baseColor?, metallic?, roughness?, emissive?, castsShadows?, acceptsShadows?, acceptsLights?}",
|
||||
has_comp,
|
||||
new_primitive
|
||||
),
|
||||
|
|
@ -394,7 +367,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"New 3D Primitive",
|
||||
[],
|
||||
None,
|
||||
"{kind: cube|sphere|plane|torus|cone|cylinder, name?, width?, height?, depth?, radius?, tubeRadius?, segments?, rings?, position? [x,y,z], baseColor?, metallic?, roughness?, emissive?, castsShadows?, acceptsShadows?, acceptsLights?}; switches a Classic 3D comp to Advanced 3D",
|
||||
"{kind: cube|sphere|plane|torus|cone|cylinder, name?, width?, height?, depth?, radius?, tubeRadius?, segments?, rings?, position? [x,y,z], baseColor?, metallic?, roughness?, emissive?, castsShadows?, acceptsShadows?, acceptsLights?}",
|
||||
has_comp,
|
||||
new_primitive
|
||||
),
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ pub struct PaintOptions {
|
|||
pub clone_preset: u32,
|
||||
/// Source layer (None = the layer being painted).
|
||||
pub clone_source: Option<u64>,
|
||||
/// Source point set with Alt/Option-click (effect space of the source layer).
|
||||
/// Source point set with Alt/Option-click (layer space of the source layer).
|
||||
pub clone_point: Option<[f64; 2]>,
|
||||
/// Aligned: keep the source/destination offset between strokes.
|
||||
pub aligned: bool,
|
||||
|
|
@ -271,7 +271,7 @@ fn preset(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
}
|
||||
|
||||
fn set_clone_source(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let point = pt(p.get("point")).ok_or_else(|| bad("paint.setCloneSource", "missing `point` [x, y] (effect space)"))?;
|
||||
let point = pt(p.get("point")).ok_or_else(|| bad("paint.setCloneSource", "missing `point` [x, y] (layer space)"))?;
|
||||
let (_, lid) = layer_p(s, p, "paint.setCloneSource")?;
|
||||
s.state.paint.clone_source = Some(lid.0);
|
||||
s.state.paint.clone_point = Some(point);
|
||||
|
|
@ -300,7 +300,7 @@ fn stroke(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Some(k) => StrokeKind::from_name(k).ok_or_else(|| bad(cmd, format!("unknown kind `{k}` (brush | clone | eraser)")))?,
|
||||
None => StrokeKind::Brush,
|
||||
};
|
||||
let raw = p.get("points").and_then(Value::as_array).ok_or_else(|| bad(cmd, "missing `points` [[x, y, pressure?], …] (effect space)"))?;
|
||||
let raw = p.get("points").and_then(Value::as_array).ok_or_else(|| bad(cmd, "missing `points` [[x, y, pressure?], …] (layer space)"))?;
|
||||
let mut points = vec![];
|
||||
let mut pressure = vec![];
|
||||
for q in raw {
|
||||
|
|
@ -325,7 +325,8 @@ fn stroke(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let t = fr.snap_nearest(super::time_p(s, p, Some(comp)));
|
||||
let layer = comp.layer(lid).ok_or_else(|| bad(cmd, "no layer"))?;
|
||||
let lt = layer.layer_time(t);
|
||||
let size = effectcraft_render::effect_bounds(&s.project, comp, layer).0;
|
||||
let (w, h) = effectcraft_render::source_size(&s.project, layer);
|
||||
let size = if w == 0 { [comp.width as f64, comp.height as f64] } else { [w as f64, h as f64] };
|
||||
if layer.effects().is_none() || !layer.source.is_av() {
|
||||
return Err(bad(cmd, "this layer can't be painted on"));
|
||||
}
|
||||
|
|
@ -462,7 +463,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Paint Stroke",
|
||||
[],
|
||||
None,
|
||||
"{layer?, kind?: brush|clone|eraser, points: [[x,y,pressure?],…] (effect space), time? (s) | frame?, duration? (s, drawing time for Write On), color?: [r,g,b,a], diameter?, angle?, hardness?, roundness?, spacing?, opacity?, flow?, mode?, channels?: RGBA|RGB|Alpha, durationMode?: constant|writeOn|singleFrame|custom, customFrames?, eraseMode?: layerSourceAndPaint|paintOnly|lastStrokeOnly, cloneSource?: layer id, clonePosition?: [x,y], cloneTimeShift? (s), lockSourceTime?, cloneTime? (s), sizePressure?, minSize?, opacityPressure?, flowPressure?}",
|
||||
"{layer?, kind?: brush|clone|eraser, points: [[x,y,pressure?],…] (layer space), time? (s) | frame?, duration? (s, drawing time for Write On), color?: [r,g,b,a], diameter?, angle?, hardness?, roundness?, spacing?, opacity?, flow?, mode?, channels?: RGBA|RGB|Alpha, durationMode?: constant|writeOn|singleFrame|custom, customFrames?, eraseMode?: layerSourceAndPaint|paintOnly|lastStrokeOnly, cloneSource?: layer id, clonePosition?: [x,y], cloneTimeShift? (s), lockSourceTime?, cloneTime? (s), sizePressure?, minSize?, opacityPressure?, flowPressure?}",
|
||||
has_comp,
|
||||
stroke
|
||||
),
|
||||
|
|
|
|||
|
|
@ -104,14 +104,15 @@ fn browser_import(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
if !browser(s)?.fetch(&list, p) {
|
||||
return Ok(json!({"pending": true, "paths": list}));
|
||||
}
|
||||
let target = s.active_comp_id();
|
||||
let mut r = s.execute("file.import", json!({"paths": paths}))?;
|
||||
// Dragged into the Timeline or the Composition viewer: into the comp, where they were dropped.
|
||||
let errors = super::file::add_to_comp(s, &r, target, p);
|
||||
if !errors.is_empty()
|
||||
&& let Some(e) = r.get_mut("errors").and_then(Value::as_array_mut)
|
||||
let r = s.execute("file.import", json!({"paths": paths}))?;
|
||||
// Add to the active comp (drag into the timeline).
|
||||
if b_p(p, "addToComp").unwrap_or(false)
|
||||
&& s.active_comp_id().is_some()
|
||||
&& let Some(items) = r.get("items").and_then(Value::as_array)
|
||||
{
|
||||
e.extend(errors.into_iter().map(Value::from));
|
||||
for it in items {
|
||||
s.execute("layer.addItem", json!({"item": it}))?;
|
||||
}
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
|
@ -120,8 +121,9 @@ fn file_info(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let path = str_p(p, "path").ok_or_else(|| bad("mediaBrowser.fileInfo", "missing `path`"))?;
|
||||
let (created, modified, size) = mb::file_dates(path);
|
||||
let mut v = json!({"path": path, "kind": mb::kind_of(path), "size": size, "created": created.map(mb::iso_date), "modified": modified.map(mb::iso_date)});
|
||||
if matches!(mb::kind_of(path), Some("video" | "image" | "audio" | "model"))
|
||||
&& let Ok(f) = s.probe_footage(path)
|
||||
if let Some(imp) = s.importer.clone()
|
||||
&& matches!(mb::kind_of(path), Some("video" | "image" | "audio" | "model"))
|
||||
&& let Ok(f) = imp.probe(path)
|
||||
{
|
||||
v["footage"] = mb::footage_metadata(&f);
|
||||
}
|
||||
|
|
@ -231,15 +233,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
cmd!("mediaBrowser.go", "Go to Folder", [], None, "{path?: folder | \"..\", importableOnly?}", browser_enabled, browser_go),
|
||||
cmd!("mediaBrowser.addFavorite", "Add to Favorites", [], None, "{path?}", browser_enabled, |s, p| browser_favorite(s, p, true)),
|
||||
cmd!("mediaBrowser.removeFavorite", "Remove from Favorites", [], None, "{path?}", browser_enabled, |s, p| browser_favorite(s, p, false)),
|
||||
cmd!(
|
||||
"mediaBrowser.import",
|
||||
"Import",
|
||||
[],
|
||||
None,
|
||||
"{paths, addToComp?, time?, index?, position? (where the layers go, as in layer.addItem)}",
|
||||
browser_enabled,
|
||||
browser_import
|
||||
),
|
||||
cmd!("mediaBrowser.import", "Import", [], None, "{paths, addToComp?}", browser_enabled, browser_import),
|
||||
cmd!(
|
||||
"mediaBrowser.action",
|
||||
"Media Browser Action",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ use effectcraft_project::{ItemId, Project};
|
|||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, b_p, bad, str_p};
|
||||
use crate::{EngineError, Result, Session, cmd, query};
|
||||
use crate::{EngineError, Result, Session, cmd};
|
||||
|
||||
/// `item` (id or name) → id.
|
||||
fn item_ref(s: &Session, v: &Value, cmd: &str) -> Result<ItemId> {
|
||||
|
|
@ -140,40 +140,17 @@ fn set_comment(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// The items deleting `ids` removes: them and, for folders, everything inside.
|
||||
fn doomed_items(project: &Project, ids: &[ItemId]) -> Vec<ItemId> {
|
||||
project.items.keys().copied().filter(|id| ids.iter().any(|d| is_within(project, *id, *d))).collect()
|
||||
}
|
||||
|
||||
/// What deleting `items` takes with it: (layers using them, in how many comps), counting only
|
||||
/// the comps that stay.
|
||||
fn usage_of(project: &Project, items: &[ItemId]) -> (usize, usize) {
|
||||
let mut layers = 0;
|
||||
let mut comps = 0;
|
||||
for (_, comp) in project.comps().filter(|(cid, _)| !items.contains(cid)) {
|
||||
let n = comp.layers.iter().filter(|l| l.source.item().is_some_and(|i| items.contains(&i))).count();
|
||||
layers += n;
|
||||
comps += usize::from(n > 0);
|
||||
}
|
||||
(layers, comps)
|
||||
}
|
||||
|
||||
/// What deleting the items (default: selected) would delete: the items (with folder contents)
|
||||
/// and the layers in other compositions that use them. The Project panel asks before deleting
|
||||
/// items in use, as After Effects does.
|
||||
fn usage(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = items_p(s, p, "project.usage")?;
|
||||
let doomed = doomed_items(&s.project, &ids);
|
||||
let (layers, comps) = usage_of(&s.project, &doomed);
|
||||
Ok(json!({"items": doomed.len(), "layers": layers, "comps": comps}))
|
||||
}
|
||||
|
||||
/// Delete project items (folders with their contents). Layers that use a deleted item and Render
|
||||
/// Queue items of deleted comps go too, like After Effects' Edit ▸ Clear in the Project panel.
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "project.delete";
|
||||
let ids = items_p(s, p, c)?;
|
||||
let doomed = doomed_items(&s.project, &ids);
|
||||
let mut doomed: Vec<ItemId> = vec![];
|
||||
for id in s.project.items.keys() {
|
||||
if ids.iter().any(|d| is_within(&s.project, *id, *d)) {
|
||||
doomed.push(*id);
|
||||
}
|
||||
}
|
||||
let n = doomed.len();
|
||||
s.edit("Delete Items", None, |proj, st| {
|
||||
for id in &doomed {
|
||||
|
|
@ -265,7 +242,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
always,
|
||||
delete
|
||||
),
|
||||
query!("project.usage", "Project Item Usage", "{items?: [id|name] (default: selected)} → {items, layers, comps} that deleting them removes", usage),
|
||||
cmd!("project.duplicate", "Duplicate Project Items", [], None, "{items?: [id|name] (default: selected)} → {items}", always, duplicate),
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
//! Properties, keyframes and expressions (Animation menu + timeline/Effect Controls gestures).
|
||||
|
||||
use effectcraft_keyframe::{Ease, Interp, Keyframe, easy_ease, key_at, set_key};
|
||||
use effectcraft_project::{GroupKind, ItemId, Layer, LayerId, Property, Uid};
|
||||
use effectcraft_project::{ItemId, LayerId, Property, Uid};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, b_p, bad, f_p, has_comp, has_keys, has_layers, layer_mut, layer_p, layers_p, merge_p, str_p};
|
||||
use crate::{EngineError, KeyRef, Result, Session, VertexRef, cmd, query};
|
||||
use crate::{EngineError, KeyRef, Result, Session, cmd, query};
|
||||
|
||||
/// Resolve `{layer, path}` (or `{layer, prop: uid}`) to (comp, layer, prop uid).
|
||||
pub(crate) fn prop_ref(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, LayerId, Uid)> {
|
||||
|
|
@ -118,14 +118,8 @@ fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
"keys": keys, "expression": pr.expr.as_ref().map(|e| e.text.clone()),
|
||||
});
|
||||
// With an expression, `value` is the keyframed (pre-expression) value; also report what the
|
||||
// expression makes of it (what renders), or why it fails. A disabled one renders `value`; one
|
||||
// disabled by its syntax error (prop.setExpression) reports that error.
|
||||
if let Some(e) = pr.expr.as_ref().filter(|e| !e.enabled && !e.text.trim().is_empty()) {
|
||||
out["evaluated"] = raw.to_json();
|
||||
if let Some(err) = s.expr_check.and_then(|check| check(&e.text).err()) {
|
||||
out["expressionError"] = json!(err);
|
||||
}
|
||||
} else if pr.has_expression()
|
||||
// expression makes of it (what renders), or why it fails.
|
||||
if pr.has_expression()
|
||||
&& let (Some(h), Some(comp)) = (s.expr.as_deref(), s.project.comp(cid))
|
||||
{
|
||||
let mut ctx = crate::render::EvalCtx::new(&s.project, cid, comp, t);
|
||||
|
|
@ -144,7 +138,7 @@ fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
fn toggle_anim(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, lid, uid) = prop_ref(s, p, "prop.toggleAnimation")?;
|
||||
let on = b_p(p, "value");
|
||||
with_prop(s, "Toggle Animation", merge_p(p), cid, lid, uid, |pr, lt| {
|
||||
with_prop(s, "Toggle Animation", None, cid, lid, uid, |pr, lt| {
|
||||
if pr.static_only {
|
||||
return Err(bad("prop.toggleAnimation", "this property can't be animated"));
|
||||
}
|
||||
|
|
@ -271,40 +265,13 @@ fn set_expr(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(r)
|
||||
}
|
||||
|
||||
/// A property's default value, when it has a known one: an effect parameter's, or a Transform
|
||||
/// property's (not a camera's or light's).
|
||||
fn default_value(s: &Session, cid: ItemId, lid: LayerId, uid: Uid) -> Option<effectcraft_keyframe::Value> {
|
||||
let comp = s.project.comp(cid)?;
|
||||
let layer = comp.layer(lid)?;
|
||||
let pr = layer.props.find(uid)?;
|
||||
let effect = layer.props.node_chain(uid)?.into_iter().find_map(|n| match n {
|
||||
effectcraft_project::Node::Group(g) => match &g.kind {
|
||||
GroupKind::Effect { effect } => Some(effect.clone()),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
});
|
||||
if let Some(effect) = effect {
|
||||
let ps = effectcraft_effects::find(&effect)?.params.iter().find(|ps| ps.id == pr.match_id)?;
|
||||
return Some(effectcraft_effects::default_value(ps, effectcraft_render::effect_bounds(&s.project, comp, layer).0));
|
||||
}
|
||||
if matches!(layer.source, effectcraft_project::LayerSource::Camera | effectcraft_project::LayerSource::Light { .. })
|
||||
|| !layer.transform()?.props().any(|t| t.uid == uid)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let (w, h) = effectcraft_render::source_size(&s.project, layer);
|
||||
super::layer::transform_default(&pr.match_id, (w as f64, h as f64), (comp.width as f64, comp.height as f64))
|
||||
}
|
||||
|
||||
fn reset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, lid, uid) = prop_ref(s, p, "prop.reset")?;
|
||||
let def = p.get("default").cloned();
|
||||
let own = default_value(s, cid, lid, uid);
|
||||
with_prop(s, "Reset Property", None, cid, lid, uid, |pr, _| {
|
||||
pr.keys.clear();
|
||||
pr.expr = None;
|
||||
if let Some(d) = def.as_ref().and_then(|d| pr.value.coerce_json(d)).or(own) {
|
||||
if let Some(d) = def.as_ref().and_then(|d| pr.value.coerce_json(d)) {
|
||||
pr.value = d;
|
||||
}
|
||||
Ok(Value::Null)
|
||||
|
|
@ -320,19 +287,11 @@ fn select_prop(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let add = b_p(p, "add").unwrap_or(false);
|
||||
if !add {
|
||||
s.state.selected_props.clear();
|
||||
s.state.selected_vertices.clear();
|
||||
}
|
||||
if !s.state.selected_layers.contains(&lid) {
|
||||
s.state.selected_layers = vec![lid];
|
||||
}
|
||||
s.state.selected_props.push((lid, uid));
|
||||
// Selecting a mask (or its Mask Path, or a shape's Path) selects all its points, so the
|
||||
// viewer drags the whole mask (as in AE, #203).
|
||||
let t = s.time();
|
||||
if let Some((mask, n)) = s.active_comp().and_then(|c| c.layer(lid)).and_then(|l| path_points(l, uid, l.layer_time(t))) {
|
||||
s.state.selected_vertices.retain(|v| !(v.layer == lid && v.mask == mask));
|
||||
s.state.selected_vertices.extend((0..n).map(|index| VertexRef { layer: lid, mask, index }));
|
||||
}
|
||||
// Selecting a property selects all its keys (as in AE).
|
||||
if b_p(p, "selectKeys").unwrap_or(true)
|
||||
&& let Some(pr) = s.active_comp().and_then(|c| c.layer(lid)).and_then(|l| l.props.find(uid))
|
||||
|
|
@ -346,23 +305,6 @@ fn select_prop(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// The mask or shape Path item that `uid` (the group, or its Path property) is, with its number
|
||||
/// of points at layer time `lt`.
|
||||
fn path_points(l: &Layer, uid: Uid, lt: Tick) -> Option<(Uid, usize)> {
|
||||
let owner = match l.props.find_group(uid) {
|
||||
Some(g) => g,
|
||||
None => l.props.parent_of(uid).filter(|g| g.get("path").is_some_and(|p| p.uid == uid))?,
|
||||
};
|
||||
let in_contents = owner.match_id == "path" && l.props.sub("contents").is_some_and(|c| c.find_group(owner.uid).is_some());
|
||||
if !matches!(owner.kind, GroupKind::Mask { .. }) && !in_contents {
|
||||
return None;
|
||||
}
|
||||
match owner.get("path")?.value_at(lt) {
|
||||
effectcraft_keyframe::Value::Path(p) => Some((owner.uid, p.vertices.len())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- keyframes
|
||||
|
||||
fn select_keys(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
|
@ -414,24 +356,6 @@ fn select_keys(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
} else {
|
||||
s.state.selected_keys = sel;
|
||||
}
|
||||
// From the Timeline, selected keys select their properties and layers too, as in After
|
||||
// Effects, so the Graph Editor shows them (#252).
|
||||
if b_p(p, "selectProperties").unwrap_or(false) && !s.state.selected_keys.is_empty() {
|
||||
let extend = b_p(p, "add").unwrap_or(false) || b_p(p, "toggle").unwrap_or(false);
|
||||
let st = &mut s.state;
|
||||
if !extend {
|
||||
st.selected_props.clear();
|
||||
st.selected_layers.clear();
|
||||
}
|
||||
for k in &st.selected_keys {
|
||||
if !st.selected_props.contains(&(k.layer, k.prop)) {
|
||||
st.selected_props.push((k.layer, k.prop));
|
||||
}
|
||||
if !st.selected_layers.contains(&k.layer) {
|
||||
st.selected_layers.push(k.layer);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!(s.state.selected_keys.len()))
|
||||
}
|
||||
|
||||
|
|
@ -616,8 +540,7 @@ fn interpolation(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let roving = b_p(p, "roving");
|
||||
edit_keys(s, "Keyframe Interpolation", None, move |keys, i, _| {
|
||||
if let Some(sp) = spatial.as_deref() {
|
||||
// (Bezier keeps the current handles, with handles where the path left straight.)
|
||||
let (tin, tout) = effectcraft_keyframe::spatial_handles(keys, i);
|
||||
let (tin, tout) = effectcraft_keyframe::spatial_tangents(keys, i);
|
||||
let k = &mut keys[i];
|
||||
match sp {
|
||||
"linear" => {
|
||||
|
|
@ -797,7 +720,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
vec![
|
||||
query!("prop.get", "Get Property", "{layer?, path|prop, time? (comp s)}", get),
|
||||
cmd!("prop.set", "Set Property Value", [], None, "{layer?, path|prop, value, time?, merge?}", has_layers, set),
|
||||
cmd!("prop.toggleAnimation", "Toggle Stopwatch", [], None, "{layer?, path|prop, value?, merge?}", has_layers, toggle_anim),
|
||||
cmd!("prop.toggleAnimation", "Toggle Stopwatch", [], None, "{layer?, path|prop, value?}", has_layers, toggle_anim),
|
||||
cmd!("prop.addKey", "Add Keyframe", [], None, "{layer?, path|prop, time?, value?}", has_layers, add_key),
|
||||
cmd!("prop.toggleKey", "Add or Remove Keyframe at Current Time", [], None, "{layer?, path|prop}", has_layers, toggle_key),
|
||||
cmd!(
|
||||
|
|
@ -810,15 +733,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
toggle_transform_key
|
||||
),
|
||||
cmd!("prop.setExpression", "Add Expression", ["Animation"], Some("Alt+Shift+="), "{layer?, path|prop, expression?, enabled?}", has_layers, set_expr),
|
||||
cmd!(
|
||||
"prop.reset",
|
||||
"Reset Property",
|
||||
[],
|
||||
None,
|
||||
"{layer?, path|prop, default?} → removes keyframes and expression; the value becomes `default`, else the property's own (an effect parameter's, a Transform property's)",
|
||||
has_layers,
|
||||
reset
|
||||
),
|
||||
cmd!("prop.reset", "Reset Property", [], None, "{layer?, path|prop, default?}", has_layers, reset),
|
||||
cmd!("prop.select", "Select Property", [], None, "{layer?, path|prop, add?, selectKeys?}", has_layers, select_prop),
|
||||
cmd!(
|
||||
"prop.convertExpressionToKeyframes",
|
||||
|
|
@ -834,7 +749,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Select Keyframes",
|
||||
[],
|
||||
None,
|
||||
"{keys: [{layer, prop: uid | path (or `path`), time (layer s)}], add?, toggle?: bool (Shift+click: in or out of the selection), selectProperties?: bool (their properties and layers too, as a Timeline click does)}",
|
||||
"{keys: [{layer, prop: uid | path (or `path`), time (layer s)}], add?, toggle?: bool (Shift+click: in or out of the selection)}",
|
||||
has_comp,
|
||||
select_keys
|
||||
),
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@
|
|||
//! effect, mask, shape item or text animator (what scripting's `PropertyGroup.name`, `enabled`,
|
||||
//! `remove()`, `duplicate()` and `moveTo()` do, and what agents need without a UI selection).
|
||||
|
||||
use effectcraft_project::{GroupKind, ItemId, LayerId, LayerSource, Node, PropGroup, Uid};
|
||||
use effectcraft_project::{GroupKind, ItemId, LayerId, Node, PropGroup, Uid};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, b_p, bad, has_layers, layer_mut, layer_p, layers_p, str_p};
|
||||
use super::{CommandSpec, b_p, bad, has_layers, layer_mut, layer_p, str_p};
|
||||
use crate::{EngineError, Result, Session, cmd};
|
||||
|
||||
/// Groups that can be renamed, removed, duplicated and reordered: instances in indexed lists.
|
||||
|
|
@ -51,153 +51,17 @@ fn set_enabled(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
}
|
||||
|
||||
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, lid, uid) = group_ref(s, p, "prop.removeGroup", true)?;
|
||||
remove_groups(s, cid, &[(lid, uid)], "Delete")?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// Remove groups of comp `cid` in one undo step.
|
||||
fn remove_groups(s: &mut Session, cid: ItemId, groups: &[(LayerId, Uid)], label: &str) -> Result<()> {
|
||||
s.edit(label, None, |proj, st| {
|
||||
for (lid, uid) in groups {
|
||||
// A group inside one removed before it went with it.
|
||||
if let Some(parent) = layer_mut(proj, cid, *lid)?.props.parent_of_mut(*uid) {
|
||||
parent.children.retain(|n| n.uid() != *uid);
|
||||
}
|
||||
}
|
||||
st.selected_props.retain(|(_, u)| !groups.iter().any(|(_, g)| g == u));
|
||||
Ok(())
|
||||
let c = "prop.removeGroup";
|
||||
let (cid, lid, uid) = group_ref(s, p, c, true)?;
|
||||
s.edit("Delete", None, |proj, st| {
|
||||
let l = layer_mut(proj, cid, lid)?;
|
||||
let parent = l.props.parent_of_mut(uid).ok_or_else(|| bad(c, "group vanished"))?;
|
||||
parent.children.retain(|n| n.uid() != uid);
|
||||
st.selected_props.retain(|(_, u)| *u != uid);
|
||||
Ok(Value::Null)
|
||||
})
|
||||
}
|
||||
|
||||
/// Edit ▸ Clear with masks, shape items, text animators or trackers selected in the active comp:
|
||||
/// removes those groups (not their layers) in one undo step. None when none are selected.
|
||||
pub(crate) fn remove_selected(s: &mut Session) -> Result<Option<Value>> {
|
||||
let (Some(cid), Some(c)) = (s.active_comp_id(), s.active_comp()) else { return Ok(None) };
|
||||
let groups: Vec<(LayerId, Uid)> =
|
||||
s.state.selected_props.iter().filter(|(lid, uid)| c.layer(*lid).and_then(|l| l.props.find_group(*uid)).is_some_and(is_instance)).copied().collect();
|
||||
if groups.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
remove_groups(s, cid, &groups, "Delete")?;
|
||||
Ok(Some(json!(groups.len())))
|
||||
}
|
||||
|
||||
/// Shape items (groups, paths, paints and path operations) selected in the Contents of the
|
||||
/// active comp's shape layers, top to bottom. An item inside another selected one goes with it,
|
||||
/// so it isn't listed.
|
||||
pub(crate) fn selected_contents(s: &Session) -> Vec<(LayerId, Uid)> {
|
||||
let mut out = vec![];
|
||||
let Some(c) = s.active_comp().filter(|_| !s.state.selected_props.is_empty()) else { return out };
|
||||
for l in c.layers.iter().filter(|l| matches!(l.source, LayerSource::Shape)) {
|
||||
if let Some(contents) = l.props.sub("contents") {
|
||||
let selected = |u: Uid| s.state.selected_props.contains(&(l.id, u));
|
||||
let mut uids = vec![];
|
||||
collect_selected(contents, &selected, &mut uids, 0);
|
||||
out.extend(uids.into_iter().map(|u| (l.id, u)));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn collect_selected(g: &PropGroup, selected: &dyn Fn(Uid) -> bool, out: &mut Vec<Uid>, depth: usize) {
|
||||
if depth > super::shape_stroke::MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
for c in g.groups() {
|
||||
if is_instance(c) && selected(c.uid) {
|
||||
out.push(c.uid);
|
||||
} else {
|
||||
collect_selected(c, selected, out, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Copies of the selected shape items (Edit ▸ Copy), top to bottom.
|
||||
pub(crate) fn copy_contents(s: &Session) -> Vec<PropGroup> {
|
||||
let Some(c) = s.active_comp() else { return vec![] };
|
||||
selected_contents(s).into_iter().filter_map(|(lid, uid)| c.layer(lid)?.props.find_group(uid).cloned()).collect()
|
||||
}
|
||||
|
||||
/// Edit ▸ Cut's removal of the selected shape items (once copied).
|
||||
pub(crate) fn cut_contents(s: &mut Session, items: &[(LayerId, Uid)]) -> Result<Value> {
|
||||
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
|
||||
remove_groups(s, cid, items, "Cut")?;
|
||||
Ok(json!({"contents": items.len()}))
|
||||
}
|
||||
|
||||
/// Edit ▸ Paste with shape items on the clipboard: copies go into each selected shape layer's
|
||||
/// Contents, above its topmost selected item (in that item's group), else on top. Names stay
|
||||
/// unique in their group ("Rectangle 2"…), and the copies are selected.
|
||||
pub(crate) fn paste_contents(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "edit.paste";
|
||||
let clip = s.state.contents_clipboard.clone();
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let targets: Vec<LayerId> =
|
||||
ids.into_iter().filter(|l| comp.layer(*l).is_some_and(|l| matches!(l.source, LayerSource::Shape) && !l.switches.locked)).collect();
|
||||
if targets.is_empty() {
|
||||
return Err(bad(c, "select a shape layer to paste shape items into"));
|
||||
}
|
||||
let sel = selected_contents(s);
|
||||
let label = match clip.as_slice() {
|
||||
[g] => format!("Paste {}", g.name),
|
||||
_ => "Paste".to_string(),
|
||||
};
|
||||
let pasted = s.edit(&label, None, |proj, st| {
|
||||
let mut next = proj.next_id;
|
||||
let mut out = vec![];
|
||||
for lid in &targets {
|
||||
let l = layer_mut(proj, cid, *lid)?;
|
||||
// Above the layer's topmost selected item, in its group.
|
||||
let spot = sel.iter().find(|(sl, _)| sl == lid).and_then(|(_, u)| {
|
||||
let parent = l.props.parent_of(*u)?;
|
||||
Some((parent.uid, parent.children.iter().position(|n| n.uid() == *u)?))
|
||||
});
|
||||
let (parent, at) = match spot {
|
||||
Some((pu, i)) => (l.props.find_group_mut(pu), i),
|
||||
None => (l.props.sub_mut("contents"), 0),
|
||||
};
|
||||
let parent = parent.ok_or_else(|| bad(c, "the layer has no contents"))?;
|
||||
for (k, g) in clip.iter().enumerate() {
|
||||
let mut g = g.clone();
|
||||
g.reassign_uids(&mut next);
|
||||
g.name = super::effect::unique_name(parent, &g.name);
|
||||
out.push((*lid, g.uid));
|
||||
parent.children.insert((at + k).min(parent.children.len()), g.into());
|
||||
}
|
||||
}
|
||||
proj.next_id = next + 1;
|
||||
st.selected_props = out.clone();
|
||||
Ok(out)
|
||||
})?;
|
||||
Ok(json!({"contents": pasted.iter().map(|(_, u)| *u).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
/// Edit ▸ Duplicate with shape items selected: each is copied in place, above the original and
|
||||
/// named as After Effects does ("Rectangle 1" → "Rectangle 2"), and the copies are selected.
|
||||
pub(crate) fn duplicate_contents(s: &mut Session, items: &[(LayerId, Uid)]) -> Result<Value> {
|
||||
let c = "edit.duplicate";
|
||||
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
|
||||
let copies = s.edit("Duplicate", None, |proj, st| {
|
||||
let mut next = proj.next_id;
|
||||
let mut out = vec![];
|
||||
for (lid, uid) in items {
|
||||
let parent = layer_mut(proj, cid, *lid)?.props.parent_of_mut(*uid).ok_or_else(|| bad(c, "the shape item is gone"))?;
|
||||
let Some((i, Node::Group(g))) = parent.children.iter().enumerate().find(|(_, n)| n.uid() == *uid) else { continue };
|
||||
let mut g = g.clone();
|
||||
g.reassign_uids(&mut next);
|
||||
g.name = super::effect::unique_name(parent, &g.name);
|
||||
out.push((*lid, g.uid));
|
||||
parent.children.insert(i, Node::Group(g));
|
||||
}
|
||||
proj.next_id = next + 1;
|
||||
st.selected_props = out.clone();
|
||||
Ok(out)
|
||||
})?;
|
||||
Ok(json!({"contents": copies.iter().map(|(_, u)| *u).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "prop.duplicateGroup";
|
||||
let (cid, lid, uid) = group_ref(s, p, c, true)?;
|
||||
|
|
|
|||
|
|
@ -40,11 +40,8 @@ fn has_proxy(s: &Session) -> std::result::Result<(), String> {
|
|||
|
||||
/// Probe a proxy file through the importer.
|
||||
fn probe(s: &Session, path: &str, cmd: &str) -> Result<Footage> {
|
||||
if s.importer.is_none() {
|
||||
return Err(EngineError::Other("media import is not available in this build".into()));
|
||||
}
|
||||
// The first frame of a proxy sequence (Create Proxy ▸ Movie renders one) brings the rest.
|
||||
let f = s.probe_footage(path).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
|
||||
let importer = s.importer.clone().ok_or_else(|| EngineError::Other("media import is not available in this build".into()))?;
|
||||
let f = importer.probe(path).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
|
||||
if !f.has_video {
|
||||
return Err(bad(cmd, format!("{path} has no video to use as a proxy")));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
//! selected pins, not their layer). Follow-Through (`puppet.follow`) makes pins trail a leader pin
|
||||
//! with a delay, for hair, cloth and overlapping action.
|
||||
//!
|
||||
//! Pin positions are in effect space. A new pin is attached to the mesh under it: the click is
|
||||
//! Pin positions are layer space. A new pin is attached to the mesh under it: the click is
|
||||
//! mapped back through the current deformation to the rest mesh (its hidden rest position), and
|
||||
//! Position / Advanced pins get a Position keyframe at the current time, as in After Effects.
|
||||
|
||||
|
|
@ -26,9 +26,8 @@ fn pt(v: Option<&Value>) -> Option<[f64; 2]> {
|
|||
Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?])
|
||||
}
|
||||
|
||||
/// The Puppet effect's input pixels and evaluated parameters at comp time `t`. The input comes
|
||||
/// from the layer cache, shared: moving pins doesn't change it (#212).
|
||||
pub fn puppet_eval(s: &Session, cid: ItemId, lid: LayerId, fx_uid: Uid, t: Tick) -> Option<(std::sync::Arc<Buf>, Params)> {
|
||||
/// The Puppet effect's input pixels and evaluated parameters at comp time `t`.
|
||||
pub fn puppet_eval(s: &Session, cid: ItemId, lid: LayerId, fx_uid: Uid, t: Tick) -> Option<(Buf, Params)> {
|
||||
let comp = s.project.comp(cid)?;
|
||||
let layer = comp.layer(lid)?;
|
||||
let fx = layer.effects()?;
|
||||
|
|
@ -38,7 +37,7 @@ pub fn puppet_eval(s: &Session, cid: ItemId, lid: LayerId, fx_uid: Uid, t: Tick)
|
|||
let mut r = Renderer::new(&s.project, &*s.footage, RenderOpts::default());
|
||||
r.expr = s.expr.as_deref();
|
||||
r.cache = Some(&s.layer_cache);
|
||||
let buf = r.layer_input(&ctx, layer, index)?;
|
||||
let buf = (*r.layer_input(&ctx, layer, index)?).clone();
|
||||
let params = effectcraft_effects::flatten_params(g, &mut |p| ctx.value(layer, p));
|
||||
Some((buf, params))
|
||||
}
|
||||
|
|
@ -64,7 +63,7 @@ fn add_pin(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Some(k) => PinKind::from_name(k).ok_or_else(|| bad(cmd, format!("unknown kind `{k}` (position | advanced | bend | starch | overlap)")))?,
|
||||
None => PinKind::Position,
|
||||
};
|
||||
let at = pt(p.get("position")).ok_or_else(|| bad(cmd, "missing `position` [x, y] (effect space)"))?;
|
||||
let at = pt(p.get("position")).ok_or_else(|| bad(cmd, "missing `position` [x, y] (layer space)"))?;
|
||||
let t = snapped(s, cid, p)?;
|
||||
let lt = layer_time(s, cid, lid, t)?;
|
||||
{
|
||||
|
|
@ -87,7 +86,11 @@ fn add_pin(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
if want_mesh.is_some_and(|w| w != *uid) || mesh.tris.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let inside = puppet::contains(mesh, def, at);
|
||||
let inside = mesh.tris.iter().any(|tr| {
|
||||
let (a, b, c) = (def[tr[0]], def[tr[1]], def[tr[2]]);
|
||||
let cr = |o: [f64; 2], u: [f64; 2], v: [f64; 2]| (u[0] - o[0]) * (v[1] - o[1]) - (u[1] - o[1]) * (v[0] - o[0]);
|
||||
cr(a, b, at) >= 0.0 && cr(b, c, at) >= 0.0 && cr(c, a, at) >= 0.0
|
||||
});
|
||||
let near = def.iter().map(|v| (v[0] - at[0]).hypot(v[1] - at[1])).fold(f64::MAX, f64::min) < 12.0;
|
||||
if inside || near || want_mesh.is_some() {
|
||||
target = Some((*uid, puppet::unmap(mesh, def, at)));
|
||||
|
|
@ -108,7 +111,8 @@ fn add_pin(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let size = {
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let l = comp.layer(lid).ok_or(EngineError::NoComp)?;
|
||||
effectcraft_render::effect_bounds(&s.project, comp, l).0
|
||||
let (w, h) = effectcraft_render::source_size(&s.project, l);
|
||||
if w == 0 { [comp.width as f64, comp.height as f64] } else { [w as f64, h as f64] }
|
||||
};
|
||||
let label = match kind {
|
||||
PinKind::Starch => "Add Starch Pin",
|
||||
|
|
@ -277,7 +281,7 @@ fn with_pin<T>(s: &mut Session, p: &Value, cmd: &str, label: &str, f: impl FnOnc
|
|||
|
||||
fn move_pin(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "puppet.movePin";
|
||||
let to = pt(p.get("position")).ok_or_else(|| bad(cmd, "missing `position` [x, y] (effect space)"))?;
|
||||
let to = pt(p.get("position")).ok_or_else(|| bad(cmd, "missing `position` [x, y] (layer space)"))?;
|
||||
with_pin(s, p, cmd, "Move Puppet Pin", |g, lt| {
|
||||
let kind = PinKind::from_index(g.get("kind").map(|k| k.value.as_enum()).unwrap_or(0));
|
||||
let pos = g.get_mut("position").ok_or_else(|| bad(cmd, "Bend pins have no position (use puppet.setPin rotation/scale)"))?;
|
||||
|
|
@ -386,12 +390,12 @@ fn mesh_opts(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// Parse recorded samples `[[t, x, y]…]` (t = seconds since the drag began, effect space).
|
||||
/// Parse recorded samples `[[t, x, y]…]` (t = seconds since the drag began, layer space).
|
||||
fn samples(p: &Value, cmd: &str) -> Result<Vec<(f64, [f64; 2])>> {
|
||||
let arr = p
|
||||
.get("samples")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or_else(|| bad(cmd, "missing `samples`: [[t, x, y]…] (t = seconds since the drag began, effect space)"))?;
|
||||
.ok_or_else(|| bad(cmd, "missing `samples`: [[t, x, y]…] (t = seconds since the drag began, layer space)"))?;
|
||||
let mut rec = Vec::with_capacity(arr.len());
|
||||
for (i, v) in arr.iter().enumerate() {
|
||||
let a: Vec<f64> = v.as_array().map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default();
|
||||
|
|
@ -601,7 +605,7 @@ fn record_options(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(json!({"speed": o.record_speed, "smoothing": o.record_smoothing, "useDraftDeformation": o.record_draft, "showMesh": o.record_show_mesh}))
|
||||
}
|
||||
|
||||
/// Meshes, pins and the deformed mesh at the current time (effect space).
|
||||
/// Meshes, pins and the deformed mesh at the current time (layer space).
|
||||
fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, lid) = layer_p(s, p, "puppet.info")?;
|
||||
let Some(fx) = find_puppet(s, cid, lid) else { return Ok(json!({"meshes": []})) };
|
||||
|
|
@ -631,7 +635,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Add Puppet Pin",
|
||||
[],
|
||||
None,
|
||||
"{layer?, kind?: position|advanced|bend|starch|overlap, position: [x,y] (effect space), time? (s) | frame?, mesh?: uid, newMesh?, density?, expansion?, triangles?}",
|
||||
"{layer?, kind?: position|advanced|bend|starch|overlap, position: [x,y] (layer space), time? (s) | frame?, mesh?: uid, newMesh?, density?, expansion?, triangles?}",
|
||||
has_comp,
|
||||
add_pin
|
||||
),
|
||||
|
|
@ -670,7 +674,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Record Puppet Pin",
|
||||
[],
|
||||
None,
|
||||
"{layer?, pin: uid | name, samples: [[t, x, y]…] (t = seconds since the drag began, effect space), pins?: [uid | name…] (more pins that move by the same displacement), start? (s, default current time), speed? (%, Record Options), smoothing? (Record Options)}",
|
||||
"{layer?, pin: uid | name, samples: [[t, x, y]…] (t = seconds since the drag began, layer space), pins?: [uid | name…] (more pins that move by the same displacement), start? (s, default current time), speed? (%, Record Options), smoothing? (Record Options)}",
|
||||
has_comp,
|
||||
record_pin
|
||||
),
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
use effectcraft_project::render_queue::{
|
||||
AlphaMode, AudioFormat, AudioOutput, Channels, ColorDepth, CurrentOrOff, DEFAULT_SEQUENCE_TEMPLATE, DEFAULT_TEMPLATE, EffectsMode, FieldRender,
|
||||
OutputFormat, OutputModule, PostRenderAction, ProResProfile, Pulldown, RESIZE_PRESETS, RenderLog, RenderQuality, RenderQueueItem, RenderSettings,
|
||||
RenderStatus, ResizeQuality, SwitchOverride, TimeSpan, post_render_parse, without_project_extension,
|
||||
RenderStatus, ResizeQuality, SwitchOverride, TimeSpan, post_render_parse,
|
||||
};
|
||||
use effectcraft_project::render_templates::{RenderTemplates, TemplateKind, TemplateSlot};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
|
|
@ -19,7 +19,7 @@ use crate::{EngineError, Result, Session, cmd, query};
|
|||
|
||||
fn can_add(s: &Session) -> std::result::Result<(), String> {
|
||||
not_rendering(s)?;
|
||||
if s.comp_for_queue(&Value::Null).is_ok() { Ok(()) } else { Err("select or open a composition".into()) }
|
||||
if s.comp_for_queue(&Value::Null).is_some() { Ok(()) } else { Err("select or open a composition".into()) }
|
||||
}
|
||||
fn not_rendering(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.is_rendering() { Err("the render queue is rendering".into()) } else { Ok(()) }
|
||||
|
|
@ -202,9 +202,6 @@ fn apply_om_template(om: &mut OutputModule, t: &OutputModule) {
|
|||
om.name = t.name.clone();
|
||||
}
|
||||
|
||||
/// The `format` parameter's values.
|
||||
const FORMATS: &str = "format: h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff";
|
||||
|
||||
/// Apply Output Module parameters (`template` first). `roi`: the viewer's region of interest
|
||||
/// (captured by `crop.useRoi`).
|
||||
fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option<[f64; 4]>, p: &Value, cmd: &str) -> Result<bool> {
|
||||
|
|
@ -216,12 +213,9 @@ fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option<
|
|||
}
|
||||
let before = om.clone();
|
||||
if let Some(f) = str_p(p, "format") {
|
||||
let f = OutputFormat::from_name(f).ok_or_else(|| bad(cmd, FORMATS))?;
|
||||
let f = OutputFormat::from_name(f).ok_or_else(|| bad(cmd, "format: h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff"))?;
|
||||
om.set_format(f);
|
||||
}
|
||||
// Before Channels: the WebM codec decides whether alpha can be written, so an explicit
|
||||
// `channels: rgba` with `webmCodec: av1` is refused rather than dropped (#166).
|
||||
any |= apply_codec_options(om, p, cmd)?;
|
||||
if let Some(c) = str_p(p, "channels") {
|
||||
om.channels = match c.to_ascii_lowercase().replace([' ', '+'], "").as_str() {
|
||||
"rgb" => Channels::Rgb,
|
||||
|
|
@ -230,11 +224,7 @@ fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option<
|
|||
_ => return Err(bad(cmd, "channels: rgb|rgba|alpha")),
|
||||
};
|
||||
if om.channels == Channels::Rgba && !om.supports_alpha() {
|
||||
let what = match om.format {
|
||||
OutputFormat::WebM => format!("{} WebM has no alpha channel (VP9 WebM has)", om.webm_codec.label()),
|
||||
f => format!("{} has no alpha channel", f.label()),
|
||||
};
|
||||
return Err(bad(cmd, what));
|
||||
return Err(bad(cmd, format!("{} has no alpha channel", om.format.label())));
|
||||
}
|
||||
}
|
||||
if let Some(c) = str_p(p, "color").or(str_p(p, "alphaMode")) {
|
||||
|
|
@ -244,6 +234,7 @@ fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option<
|
|||
_ => return Err(bad(cmd, "color: straight|premultiplied")),
|
||||
};
|
||||
}
|
||||
any |= apply_codec_options(om, p, cmd)?;
|
||||
if let Some(q) = f_p(p, "quality") {
|
||||
om.quality = q.clamp(1.0, 100.0) as u8;
|
||||
}
|
||||
|
|
@ -364,24 +355,16 @@ fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option<
|
|||
if let Some(l) = b_p(p, "includeProjectLink") {
|
||||
om.include_project_link = l;
|
||||
}
|
||||
// (Without the `.ecproj` a save dialog filtered to projects appended, #293.)
|
||||
if let Some(o) = str_p(p, "output").or(str_p(p, "path")).map(without_project_extension) {
|
||||
if let Some(o) = str_p(p, "output").or(str_p(p, "path")) {
|
||||
om.output = o.to_string();
|
||||
// Only an extension the format doesn't write changes it: `.mp4` is H.264's, HEVC's and AV1's.
|
||||
if let Some(f) = OutputFormat::from_path(o).filter(|f| f.extension() != om.format.extension() && !o.contains("[fileExtension]")) {
|
||||
if p.get("format").is_some() {
|
||||
// An explicit format wins and the extension follows it, as choosing a format in
|
||||
// the Output Module renames Output To (#154: `--format hevc --out x.mp4`).
|
||||
om.set_format(om.format);
|
||||
} else {
|
||||
// `out.mov` with an H.264 module → ProRes, like picking a file type in Output To.
|
||||
let keep = om.output.clone();
|
||||
om.set_format(f);
|
||||
om.output = keep;
|
||||
}
|
||||
if p.get("format").is_none()
|
||||
&& let Some(f) = OutputFormat::from_path(o).filter(|f| *f != om.format && !o.contains("[fileExtension]"))
|
||||
{
|
||||
// `out.mov` with an H.264 module → ProRes, like picking a file type in Output To.
|
||||
let keep = om.output.clone();
|
||||
om.set_format(f);
|
||||
om.output = keep;
|
||||
}
|
||||
// The format's extension exactly once (`Comp 2.mov.mov` → `Comp 2.mov`).
|
||||
om.output = om.format.with_extension(&om.output);
|
||||
}
|
||||
if *om != before {
|
||||
// Output To alone doesn't make the module custom.
|
||||
|
|
@ -513,20 +496,24 @@ fn item_json(s: &Session, it: &RenderQueueItem, index: usize) -> Value {
|
|||
}
|
||||
|
||||
fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = s.comp_for_queue(p)?;
|
||||
let cid = s.comp_for_queue(p).ok_or(EngineError::NoComp)?;
|
||||
let mut it = RenderQueueItem::new(0, cid);
|
||||
// New items start from the Movie Default templates (Edit ▸ Templates).
|
||||
let templates = s.project.render_templates.clone();
|
||||
it.settings = templates.default_render_settings(TemplateSlot::Movie);
|
||||
it.output = templates.default_output_module(TemplateSlot::Movie);
|
||||
if let Some(f) = str_p(p, "format") {
|
||||
let f = OutputFormat::from_name(f).ok_or_else(|| bad("renderQueue.add", FORMATS))?;
|
||||
let f = OutputFormat::from_name(f).ok_or_else(|| bad("renderQueue.add", "format: h264|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff"))?;
|
||||
if f != it.output.format {
|
||||
it.output = OutputModule::for_format(f);
|
||||
}
|
||||
}
|
||||
apply_settings(&mut it.settings, &templates, p, "renderQueue.add")?;
|
||||
apply_output(&mut it.output, &templates, s.state.region_of_interest, p, "renderQueue.add")?;
|
||||
let mut op = p.clone();
|
||||
if let Some(o) = op.as_object_mut() {
|
||||
o.remove("format");
|
||||
}
|
||||
apply_output(&mut it.output, &templates, s.state.region_of_interest, &op, "renderQueue.add")?;
|
||||
if let Some(l) = enum_p(p, "log", "renderQueue.add", RenderLog::parse, "errorsOnly|plusSettings|plusPerFrameInfo")? {
|
||||
it.log = l;
|
||||
}
|
||||
|
|
@ -717,7 +704,7 @@ fn add_output_module(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let n = it.extra_outputs.len() + 2;
|
||||
let mut om = it.output.clone();
|
||||
if let Some(f) = str_p(p, "format") {
|
||||
let f = OutputFormat::from_name(f).ok_or_else(|| bad("render.addOutputModule", FORMATS))?;
|
||||
let f = OutputFormat::from_name(f).ok_or_else(|| bad("render.addOutputModule", "format: h264|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff"))?;
|
||||
om = OutputModule::for_format(f);
|
||||
}
|
||||
if str_p(p, "output").is_none() && str_p(p, "path").is_none() {
|
||||
|
|
@ -727,7 +714,11 @@ fn add_output_module(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
None => format!("{}_{n}", om.output),
|
||||
};
|
||||
}
|
||||
apply_output(&mut om, &s.project.render_templates, s.state.region_of_interest, p, "render.addOutputModule")?;
|
||||
let mut op = p.clone();
|
||||
if let Some(o) = op.as_object_mut() {
|
||||
o.remove("format");
|
||||
}
|
||||
apply_output(&mut om, &s.project.render_templates, s.state.region_of_interest, &op, "render.addOutputModule")?;
|
||||
s.edit("Add Output Module", None, |proj, _| {
|
||||
let it = &mut proj.render_queue[i];
|
||||
it.extra_outputs.push(om);
|
||||
|
|
@ -742,7 +733,7 @@ fn add_output_module(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
/// Composition ▸ Pre-render…: queue the comp with a lossless-with-alpha module whose post-render
|
||||
/// action imports the result and replaces the comp's uses.
|
||||
fn pre_render(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = s.comp_for_queue(p)?;
|
||||
let cid = s.comp_for_queue(p).ok_or(EngineError::NoComp)?;
|
||||
let mut it = RenderQueueItem::new(0, cid);
|
||||
let templates = s.project.render_templates.clone();
|
||||
it.settings = templates.default_render_settings(TemplateSlot::PreRender);
|
||||
|
|
|
|||
|
|
@ -3,15 +3,11 @@
|
|||
//! * **Installed scripts** live in the settings store under `Scripts/` (File ▸ Scripts ▸ Install
|
||||
//! Script File…) and `Scripts/ScriptUI Panels/` (Install ScriptUI Panel…), next to the
|
||||
//! settings on the desktop, so dropping `.jsx` files in those folders works too. File ▸ Scripts
|
||||
//! lists them with the bundled sample scripts and extensions ([`BUNDLED`]) and runs them by name; ScriptUI
|
||||
//! lists them with the bundled sample scripts ([`SAMPLES`]) and runs them by name; ScriptUI
|
||||
//! panels appear at the bottom of the Window menu and open as dockable panels
|
||||
//! (`window.scriptPanel`), running with `this` = the panel as in After Effects.
|
||||
//! * **ScriptUI windows** (`scriptui.*`): list the open script windows, read their control
|
||||
//! trees, click buttons and set values like a user, close them (see [`crate::scriptui`]).
|
||||
//! * **Script settings** (`script.settings.*`): After Effects' `app.settings`, string values by
|
||||
//! section and key that last between runs ([`SCRIPT_SETTINGS_FILE`]).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
|
|
@ -22,90 +18,14 @@ use crate::{EngineError, Event, Result, Session, cmd, scriptui};
|
|||
/// The settings-store folders of installed scripts.
|
||||
pub const SCRIPTS_DIR: &str = "Scripts";
|
||||
pub const PANELS_DIR: &str = "Scripts/ScriptUI Panels";
|
||||
/// The ScriptUI panels open when EffectCraft last quit (settings store), reopened at launch.
|
||||
pub const OPEN_PANELS_FILE: &str = "scriptui_panels.json";
|
||||
/// Scripts' `app.settings` in the settings store: `{section: {key: value}}`, as After Effects
|
||||
/// keeps them in its preferences.
|
||||
pub const SCRIPT_SETTINGS_FILE: &str = "script_settings.json";
|
||||
/// User presets of the core Ease Presets panel of v0.6.0 (#254) and where the Ease Presets
|
||||
/// ScriptUI panel that replaced it keeps them: (file, section, key). The file's text moves there
|
||||
/// unchanged (the panel reads the same JSON) the first time settings load.
|
||||
const LEGACY_EASE_PRESETS: (&str, &str, &str) = ("ease_presets.json", "Ease Presets", "userPresets");
|
||||
/// Longest section, key and value a script may store (characters / bytes).
|
||||
const MAX_SETTING_NAME: usize = 256;
|
||||
const MAX_SETTING_VALUE: usize = 1 << 20;
|
||||
|
||||
/// Scripts' `app.settings`: values by section and key.
|
||||
pub type ScriptSettings = BTreeMap<String, BTreeMap<String, String>>;
|
||||
|
||||
/// Load scripts' settings from the settings store (a corrupt file gives none), moving user ease
|
||||
/// presets saved before Ease Presets became a ScriptUI panel into its section.
|
||||
pub(crate) fn load_script_settings(s: &mut Session) {
|
||||
let Some(cfg) = s.config.clone() else { return };
|
||||
s.script_settings = cfg
|
||||
.read(SCRIPT_SETTINGS_FILE)
|
||||
.and_then(|t| serde_json::from_str(&t).map_err(|e| log::warn!("{SCRIPT_SETTINGS_FILE} is not valid ({e}); scripts start without their settings")).ok())
|
||||
.unwrap_or_default();
|
||||
let (file, section, key) = LEGACY_EASE_PRESETS;
|
||||
if let Some(text) = cfg.read(file)
|
||||
&& !s.script_settings.get(section).is_some_and(|m| m.contains_key(key))
|
||||
{
|
||||
s.script_settings.entry(section.into()).or_default().insert(key.into(), text);
|
||||
if let Err(e) = store_script_settings(s) {
|
||||
log::warn!("cannot move {file} into the Ease Presets panel's settings: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn store_script_settings(s: &Session) -> Result<()> {
|
||||
let Some(cfg) = &s.config else { return Ok(()) };
|
||||
let text = serde_json::to_string_pretty(&s.script_settings).map_err(|e| EngineError::Other(format!("script settings: {e}")))?;
|
||||
cfg.write(SCRIPT_SETTINGS_FILE, &text).map_err(|e| EngineError::Other(format!("cannot save script settings: {e}")))
|
||||
}
|
||||
|
||||
fn setting_name<'a>(p: &'a Value, k: &str, c: &str) -> Result<&'a str> {
|
||||
let v = str_p(p, k).ok_or_else(|| bad(c, format!("missing `{k}`")))?;
|
||||
if v.chars().count() > MAX_SETTING_NAME {
|
||||
return Err(bad(c, format!("`{k}` is longer than {MAX_SETTING_NAME} characters")));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// `script.settings.get {section, key}` → `{have, value}` (`value` "" when there is none).
|
||||
fn settings_get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "script.settings.get";
|
||||
let (section, key) = (setting_name(p, "section", C)?, setting_name(p, "key", C)?);
|
||||
let v = s.script_settings.get(section).and_then(|m| m.get(key));
|
||||
Ok(json!({"have": v.is_some(), "value": v.cloned().unwrap_or_default()}))
|
||||
}
|
||||
|
||||
/// `script.settings.save {section, key, value}`: kept in the settings store.
|
||||
fn settings_save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "script.settings.save";
|
||||
let (section, key) = (setting_name(p, "section", C)?, setting_name(p, "key", C)?);
|
||||
let value = match p.get("value") {
|
||||
Some(Value::String(v)) => v.clone(),
|
||||
Some(v) if !v.is_null() => v.to_string(),
|
||||
_ => return Err(bad(C, "missing `value`")),
|
||||
};
|
||||
if value.len() > MAX_SETTING_VALUE {
|
||||
return Err(bad(C, format!("`value` is larger than {MAX_SETTING_VALUE} bytes")));
|
||||
}
|
||||
s.script_settings.entry(section.into()).or_default().insert(key.into(), value);
|
||||
store_script_settings(s)?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// Scripts that ship with EffectCraft (original work): (file name, ScriptUI panel, source, code).
|
||||
/// `sample`s show how to script EffectCraft; `extension`s (in `extensions/`) are optional tools
|
||||
/// built on the public scripting API, outside the core, like third-party scripts in After Effects.
|
||||
pub const BUNDLED: &[(&str, bool, &str, &str)] = &[
|
||||
("Create Null at Selected Layers.jsx", false, "sample", include_str!("../../scripts/Create Null at Selected Layers.jsx")),
|
||||
("Rename Layers.jsx", false, "sample", include_str!("../../scripts/Rename Layers.jsx")),
|
||||
("Render Queue Batch.jsx", false, "sample", include_str!("../../scripts/Render Queue Batch.jsx")),
|
||||
("Sort Layers by In Point.jsx", false, "sample", include_str!("../../scripts/Sort Layers by In Point.jsx")),
|
||||
("Layer Tools.jsx", true, "sample", include_str!("../../scripts/ScriptUI Panels/Layer Tools.jsx")),
|
||||
("Ease Presets.jsx", true, "extension", include_str!("../../../../extensions/scriptui-panels/Ease Presets.jsx")),
|
||||
/// Sample scripts that ship with EffectCraft (original work): (file name, ScriptUI panel, code).
|
||||
pub const SAMPLES: &[(&str, bool, &str)] = &[
|
||||
("Create Null at Selected Layers.jsx", false, include_str!("../../scripts/Create Null at Selected Layers.jsx")),
|
||||
("Rename Layers.jsx", false, include_str!("../../scripts/Rename Layers.jsx")),
|
||||
("Render Queue Batch.jsx", false, include_str!("../../scripts/Render Queue Batch.jsx")),
|
||||
("Sort Layers by In Point.jsx", false, include_str!("../../scripts/Sort Layers by In Point.jsx")),
|
||||
("Layer Tools.jsx", true, include_str!("../../scripts/ScriptUI Panels/Layer Tools.jsx")),
|
||||
];
|
||||
|
||||
/// A script File ▸ Scripts (or the Window menu, for panels) offers.
|
||||
|
|
@ -115,7 +35,7 @@ pub struct ScriptEntry {
|
|||
pub name: String,
|
||||
/// In the ScriptUI Panels folder: opens as a dockable panel.
|
||||
pub panel: bool,
|
||||
/// `installed`, `sample` or `extension` (bundled, see [`BUNDLED`]).
|
||||
/// `installed` or `sample`.
|
||||
pub source: &'static str,
|
||||
}
|
||||
|
||||
|
|
@ -124,7 +44,7 @@ fn is_script(name: &str) -> bool {
|
|||
l.ends_with(".jsx") || l.ends_with(".js")
|
||||
}
|
||||
|
||||
/// Installed scripts and panels, then the bundled ones not shadowed by an installed one, by name.
|
||||
/// Installed scripts and panels, then the samples not shadowed by an installed one, by name.
|
||||
pub fn scripts(s: &Session) -> Vec<ScriptEntry> {
|
||||
let mut v: Vec<ScriptEntry> = vec![];
|
||||
if let Some(cfg) = &s.config {
|
||||
|
|
@ -134,16 +54,16 @@ pub fn scripts(s: &Session) -> Vec<ScriptEntry> {
|
|||
}
|
||||
}
|
||||
}
|
||||
for (name, panel, source, _) in BUNDLED {
|
||||
for (name, panel, _) in SAMPLES {
|
||||
if !v.iter().any(|e| e.name == *name && e.panel == *panel) {
|
||||
v.push(ScriptEntry { name: name.to_string(), panel: *panel, source });
|
||||
v.push(ScriptEntry { name: name.to_string(), panel: *panel, source: "sample" });
|
||||
}
|
||||
}
|
||||
v.sort_by(|a, b| a.panel.cmp(&b.panel).then(a.name.to_lowercase().cmp(&b.name.to_lowercase())));
|
||||
v
|
||||
}
|
||||
|
||||
/// The code of an installed or bundled script / panel.
|
||||
/// The code of an installed or sample script / panel.
|
||||
pub fn script_code(s: &Session, name: &str, panel: Option<bool>) -> Option<(bool, String)> {
|
||||
if let Some(cfg) = &s.config {
|
||||
for (dir, is_panel) in [(SCRIPTS_DIR, false), (PANELS_DIR, true)] {
|
||||
|
|
@ -155,7 +75,7 @@ pub fn script_code(s: &Session, name: &str, panel: Option<bool>) -> Option<(bool
|
|||
}
|
||||
}
|
||||
}
|
||||
BUNDLED.iter().find(|(n, p, ..)| *n == name && panel.is_none_or(|w| w == *p)).map(|(_, p, _, c)| (*p, c.to_string()))
|
||||
SAMPLES.iter().find(|(n, p, _)| *n == name && panel.is_none_or(|w| w == *p)).map(|(_, p, c)| (*p, c.to_string()))
|
||||
}
|
||||
|
||||
fn install(s: &mut Session, p: &Value, panel: bool) -> Result<Value> {
|
||||
|
|
@ -198,10 +118,10 @@ fn uninstall(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
if removed { Ok(json!({"removed": name})) } else { Err(bad(c, format!("`{name}` is not installed"))) }
|
||||
}
|
||||
|
||||
/// Run an installed or bundled script by name (File ▸ Scripts ▸ <name>). A ScriptUI panel run
|
||||
/// Run an installed or sample script by name (File ▸ Scripts ▸ <name>). A ScriptUI panel run
|
||||
/// this way gets `this` = a floating palette-like panel window.
|
||||
pub(crate) fn run_named(s: &mut Session, name: &str) -> Result<Value> {
|
||||
let (panel, code) = script_code(s, name, None).ok_or_else(|| bad("file.runScript", format!("no installed or bundled script `{name}`")))?;
|
||||
let (panel, code) = script_code(s, name, None).ok_or_else(|| bad("file.runScript", format!("no installed or sample script `{name}`")))?;
|
||||
if panel {
|
||||
return open_panel(s, &json!({"name": name}));
|
||||
}
|
||||
|
|
@ -214,64 +134,6 @@ pub fn panel_wrapper(title: &str, code: &str) -> String {
|
|||
format!("(function () {{ {code}\n}}).call(__uiDockPanel({}));", Value::String(title.to_string()))
|
||||
}
|
||||
|
||||
/// The ScriptUI panels open now (script names).
|
||||
pub(crate) fn open_panel_names(s: &Session) -> Vec<String> {
|
||||
s.script_ui.windows.iter().filter(|w| w.kind == scriptui::WindowKind::Panel).map(|w| w.script.clone()).collect()
|
||||
}
|
||||
|
||||
/// The panels open when EffectCraft last quit (`{"open": [names]}`); a missing or unreadable
|
||||
/// file is none.
|
||||
fn remembered_panels(s: &Session) -> Vec<String> {
|
||||
let text = s.config.as_ref().and_then(|c| c.read(OPEN_PANELS_FILE)).unwrap_or_default();
|
||||
let doc: Value = serde_json::from_str(&text).unwrap_or(Value::Null);
|
||||
doc.get("open").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).filter(|n| is_script(n)).map(str::to_string).collect()
|
||||
}
|
||||
|
||||
/// Change the remembered panels with `f` (written only when it changed). The list is kept so
|
||||
/// the next launch reopens them, as After Effects reopens the panels of its workspace; quitting
|
||||
/// leaves it as it is.
|
||||
fn edit_remembered_panels(s: &Session, f: impl FnOnce(&mut Vec<String>)) {
|
||||
let Some(cfg) = &s.config else { return };
|
||||
let before = remembered_panels(s);
|
||||
let mut list = before.clone();
|
||||
f(&mut list);
|
||||
if list != before
|
||||
&& let Err(e) = cfg.write(OPEN_PANELS_FILE, &json!({"open": list}).to_string())
|
||||
{
|
||||
log::warn!("cannot remember the open ScriptUI panels: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Forget the panels of `before` (open before a script window event) that are closed now.
|
||||
pub(crate) fn forget_closed_panels(s: &Session, before: &[String]) {
|
||||
let now = open_panel_names(s);
|
||||
let closed: Vec<&String> = before.iter().filter(|n| !now.contains(n)).collect();
|
||||
if !closed.is_empty() {
|
||||
edit_remembered_panels(s, |l| l.retain(|n| !closed.contains(&n)));
|
||||
}
|
||||
}
|
||||
|
||||
/// `window.restoreScriptPanels`: open the ScriptUI panels that were open when EffectCraft last
|
||||
/// quit (frontends call it once at launch). Panels whose script is gone or fails are reported
|
||||
/// and forgotten; the others still open.
|
||||
fn restore_panels(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let (mut opened, mut failed) = (vec![], vec![]);
|
||||
for name in remembered_panels(s) {
|
||||
let r = match script_code(s, &name, Some(true)) {
|
||||
Some(_) => open_panel(s, &json!({"name": name})).map_err(|e| e.to_string()),
|
||||
None => Err("no such ScriptUI panel".to_string()),
|
||||
};
|
||||
match r {
|
||||
Ok(_) => opened.push(name),
|
||||
Err(error) => {
|
||||
edit_remembered_panels(s, |l| l.retain(|n| *n != name));
|
||||
failed.push(json!({"name": name, "error": error}));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({"opened": opened, "failed": failed}))
|
||||
}
|
||||
|
||||
/// Window ▸ <ScriptUI panel>: run the panel script (or bring its panel forward).
|
||||
fn open_panel(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "window.scriptPanel";
|
||||
|
|
@ -288,13 +150,6 @@ fn open_panel(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
if let Some(id) = id {
|
||||
s.events.push(Event::Frontend { command: c.into(), params: json!({"window": id}) });
|
||||
}
|
||||
if id.is_some() {
|
||||
edit_remembered_panels(s, |l| {
|
||||
if !l.contains(&name) {
|
||||
l.push(name.clone());
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(json!({"window": id, "result": out}))
|
||||
}
|
||||
|
||||
|
|
@ -319,7 +174,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
|s, p| install(s, p, true)
|
||||
),
|
||||
cmd!("file.uninstallScript", "Uninstall Script", [], None, "{name}", always, uninstall),
|
||||
crate::query!("file.scripts.list", "List Scripts", "{} → [{name, panel, source: installed|sample|extension}]", list),
|
||||
crate::query!("file.scripts.list", "List Scripts", "{} → [{name, panel, source: installed|sample}]", list),
|
||||
cmd!(
|
||||
"window.scriptPanel",
|
||||
"ScriptUI Panel",
|
||||
|
|
@ -329,25 +184,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
always,
|
||||
open_panel
|
||||
),
|
||||
cmd!(
|
||||
"window.restoreScriptPanels",
|
||||
"Restore ScriptUI Panels",
|
||||
[],
|
||||
None,
|
||||
"{} → reopens the ScriptUI panels open when EffectCraft last quit (frontends call it at launch) → {opened: [name], failed: [{name, error}]}",
|
||||
always,
|
||||
restore_panels
|
||||
),
|
||||
crate::query!("script.settings.get", "Get Script Setting", "{section, key} → {have, value} (app.settings.haveSetting / getSetting)", settings_get),
|
||||
cmd!(
|
||||
"script.settings.save",
|
||||
"Save Script Setting",
|
||||
[],
|
||||
None,
|
||||
"{section, key, value (text)} → kept in the settings store (app.settings.saveSetting)",
|
||||
always,
|
||||
settings_save
|
||||
),
|
||||
crate::query!("scriptui.list", "List Script Windows", "{} → [{window, title, kind: dialog|palette|window|panel, script, modal, size}]", scriptui::list),
|
||||
crate::query!(
|
||||
"scriptui.get",
|
||||
|
|
|
|||
|
|
@ -239,12 +239,11 @@ fn auto_save(s: &mut Session, _: &Value) -> Result<Value> {
|
|||
|
||||
fn recovery_info(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let root = s.default_autosave_root();
|
||||
let prefs = s.autosave_prefs();
|
||||
let latest = crate::autosave::latest_in(s.file_ops(), &prefs, s.path.as_deref(), root.as_deref()).map(|p| p.to_string_lossy().to_string());
|
||||
let latest = crate::autosave::latest_in(s.file_ops(), &s.prefs, s.path.as_deref(), root.as_deref()).map(|p| p.to_string_lossy().to_string());
|
||||
Ok(json!({
|
||||
"lastAutoSave": s.autosave.last_path,
|
||||
"latestOnDisk": latest,
|
||||
"folder": crate::autosave::folder(&prefs, s.path.as_deref(), root.as_deref()).map(|p| p.to_string_lossy().to_string()),
|
||||
"folder": crate::autosave::folder(&s.prefs, s.path.as_deref(), root.as_deref()).map(|p| p.to_string_lossy().to_string()),
|
||||
"recentProjects": s.prefs.recent_projects,
|
||||
}))
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue