Merge upstream/main into gap-object-matte
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
4acea86d4f
|
|
@ -2,15 +2,19 @@
|
|||
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
|
||||
name: FreeBSD
|
||||
|
||||
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every push
|
||||
# or dependency change: a drifted dependency still shows up within a day, and always before a
|
||||
# release ships. PRs that change the FreeBSD packaging itself still run it.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "47 2 * * *"
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [release]
|
||||
pull_request:
|
||||
paths:
|
||||
- Cargo.toml
|
||||
- Cargo.lock
|
||||
- "**/Cargo.toml"
|
||||
- .github/workflows/freebsd.yml
|
||||
- packaging/freebsd/**
|
||||
- packaging/env.sh
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
|
|
@ -24,29 +28,5 @@ permissions:
|
|||
|
||||
jobs:
|
||||
freebsd:
|
||||
name: FreeBSD 14 (build + test)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 300
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Build and test in a FreeBSD VM
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
|
||||
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
|
||||
# The host tests build examples/plugins/posterize-bands for wasm32 and load it.
|
||||
"$HOME/.cargo/bin/rustup" target add wasm32-unknown-unknown
|
||||
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
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
|
||||
|
|
@ -20,63 +20,5 @@ concurrency:
|
|||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ACTIONLINT_VERSION: 1.7.12
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml
|
||||
curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
|
||||
| tar -xz -C "$RUNNER_TEMP" actionlint
|
||||
- name: actionlint
|
||||
run: |
|
||||
"$RUNNER_TEMP/actionlint" -color
|
||||
- name: shellcheck
|
||||
run: shellcheck packaging/*.sh packaging/*/*.sh
|
||||
- name: PowerShell
|
||||
shell: pwsh
|
||||
run: |
|
||||
foreach ($f in Get-ChildItem packaging/windows/*.ps1) {
|
||||
$errors = $null
|
||||
[System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null
|
||||
if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 }
|
||||
Write-Output "ok $($f.Name)"
|
||||
}
|
||||
- name: XML, desktop entry, AppStream, Flatpak manifest
|
||||
run: |
|
||||
xmllint --noout packaging/windows/*.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
|
||||
|
||||
windows-msi:
|
||||
name: Windows MSI install folder (stub binaries)
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install WiX
|
||||
shell: pwsh
|
||||
run: dotnet tool install --global wix --version 5.0.2
|
||||
- name: Install, upgrade, repair and uninstall
|
||||
shell: pwsh
|
||||
run: ./packaging/windows/test-msi.ps1
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
|
||||
|
|
|
|||
|
|
@ -1,12 +1,3 @@
|
|||
# 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.
|
||||
name: Release
|
||||
|
||||
on:
|
||||
|
|
@ -20,425 +11,32 @@ on:
|
|||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
# Never cancel a run mid-notarization or mid-upload; a newer push waits its turn.
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
RUST_BACKTRACE: 1
|
||||
EFFECTCRAFT_BUILD_SHA: ${{ github.sha }}
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
|
||||
jobs:
|
||||
version:
|
||||
name: version
|
||||
runs-on: ubuntu-latest
|
||||
# No `environment: release`: it reads Cargo.toml only (see the header).
|
||||
outputs:
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
date: ${{ steps.v.outputs.date }}
|
||||
prerelease: ${{ steps.v.outputs.prerelease }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- id: v
|
||||
name: Resolve version
|
||||
run: |
|
||||
. packaging/env.sh
|
||||
v="${INPUT_VERSION#v}"
|
||||
v="${v:-$VERSION}"
|
||||
if ! printf '%s' "$v" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then
|
||||
echo "::error::'$v' is not a version like 1.2.3 or 1.2.3-rc.1"
|
||||
exit 1
|
||||
fi
|
||||
case "$v" in *-*) pre=true ;; *) pre=false ;; esac
|
||||
{
|
||||
echo "version=$v"
|
||||
echo "date=$(date -u +%Y-%m-%d)"
|
||||
echo "prerelease=$pre"
|
||||
} >>"$GITHUB_OUTPUT"
|
||||
echo "### EffectCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
|
||||
|
||||
macos:
|
||||
name: macOS universal
|
||||
needs: version
|
||||
runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend)
|
||||
environment: release
|
||||
env:
|
||||
EFFECTCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
EFFECTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: aarch64-apple-darwin, x86_64-apple-darwin
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-macos-universal
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$EFFECTCRAFT_VERSION"
|
||||
- name: Import signing certificate
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
run: packaging/macos/import-cert.sh
|
||||
# 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 }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: packaging/macos/package.sh --arch universal
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-macos
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
- name: Remove temporary keychain
|
||||
if: always()
|
||||
run: |
|
||||
if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi
|
||||
|
||||
windows:
|
||||
name: Windows ${{ matrix.arch }}
|
||||
needs: version
|
||||
runs-on: windows-latest
|
||||
environment: release
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x64, target: x86_64-pc-windows-msvc }
|
||||
- { arch: x86, target: i686-pc-windows-msvc }
|
||||
# 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 }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-windows-${{ matrix.arch }}
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
shell: bash
|
||||
run: cargo xtask version set "$EFFECTCRAFT_VERSION"
|
||||
- name: Install WiX
|
||||
shell: pwsh
|
||||
run: dotnet tool install --global wix --version 5.0.2
|
||||
- name: Build, sign, package
|
||||
shell: pwsh
|
||||
env:
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-windows-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
linux:
|
||||
name: Linux ${{ matrix.arch }}
|
||||
needs: version
|
||||
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+,
|
||||
# Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md.
|
||||
runs-on: ${{ matrix.runner }}
|
||||
# No `environment: release`: this job signs nothing, so it gets no release secrets.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x86_64, runner: ubuntu-22.04, deb: amd64 }
|
||||
- { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 }
|
||||
env:
|
||||
EFFECTCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
EFFECTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
NFPM_VERSION: 2.47.0
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install build and packaging tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev libasound2-dev \
|
||||
desktop-file-utils appstream python3-yaml zsync
|
||||
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
|
||||
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
|
||||
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-linux-${{ matrix.arch }}
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$EFFECTCRAFT_VERSION"
|
||||
- name: Build and package (AppImage, deb, rpm, tar.gz)
|
||||
run: packaging/linux/package.sh
|
||||
- name: Check the packages
|
||||
run: |
|
||||
dpkg-deb --info dist/release/*.deb
|
||||
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/effectcraft$|metainfo|applications'
|
||||
ldd target/release/effectcraft
|
||||
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.effectcraft.yml')); assert m['id'] == 'ai.storyteller.effectcraft'"
|
||||
for appimage in dist/release/*.AppImage; do
|
||||
chmod +x "$appimage"
|
||||
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
|
||||
# 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@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
linux-riscv64:
|
||||
name: Linux riscv64
|
||||
needs: version
|
||||
# Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release
|
||||
# from ports.ubuntu.com, so the binaries need that release's glibc (24.04: 2.39). 22.04's
|
||||
# riscv64 ports lag its amd64 updates, which breaks the multiarch install. tar.gz only; the
|
||||
# CLI is smoke-tested under QEMU.
|
||||
runs-on: ubuntu-24.04
|
||||
# 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: riscv64gc-unknown-linux-gnu
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-linux-riscv64
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$EFFECTCRAFT_VERSION"
|
||||
- name: Install cross-compilation dependencies
|
||||
run: |
|
||||
. /etc/os-release
|
||||
sudo dpkg --add-architecture riscv64
|
||||
# Pin the existing (archive.ubuntu.com) sources to amd64; riscv64 comes from ports.
|
||||
if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then
|
||||
sudo sed -i '/^Types: deb/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources
|
||||
fi
|
||||
if [ -f /etc/apt/sources.list ]; then
|
||||
sudo sed -i 's/^deb /deb [arch=amd64] /g' /etc/apt/sources.list
|
||||
sudo sed -i 's/^deb-src /deb-src [arch=amd64] /g' /etc/apt/sources.list
|
||||
fi
|
||||
for suite in "$VERSION_CODENAME" "$VERSION_CODENAME-updates" "$VERSION_CODENAME-security"; do
|
||||
echo "deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports $suite main universe restricted multiverse"
|
||||
done | sudo tee /etc/apt/sources.list.d/riscv64.list
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu libc6-dev-riscv64-cross libc6-dev:riscv64 qemu-user \
|
||||
libxkbcommon-dev:riscv64 libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 \
|
||||
libxi-dev:riscv64 libgl1-mesa-dev:riscv64 libasound2-dev:riscv64 \
|
||||
desktop-file-utils appstream
|
||||
- name: Build and package (tar.gz)
|
||||
env:
|
||||
CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc
|
||||
PKG_CONFIG_SYSROOT_DIR: /
|
||||
PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig
|
||||
PKG_CONFIG_ALLOW_CROSS: "1"
|
||||
CROSS_ARCH: riscv64
|
||||
CROSS_TARGET: riscv64gc-unknown-linux-gnu
|
||||
CROSS_COMPILE: riscv64-linux-gnu-
|
||||
EMULATOR: qemu-riscv64
|
||||
QEMU_LD_PREFIX: /usr/riscv64-linux-gnu
|
||||
run: packaging/linux/package.sh --formats tar
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-linux-riscv64
|
||||
path: dist/release/*.tar.gz
|
||||
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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
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@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- 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 # v1.5.9
|
||||
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@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
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.
|
||||
env:
|
||||
EFFECTCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
EFFECTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
WASM_BINDGEN_VERSION: 0.2.129
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-web
|
||||
- name: Install wasm-bindgen CLI
|
||||
run: |
|
||||
# Must match the wasm-bindgen version in Cargo.lock exactly (cargo xtask web checks it).
|
||||
if ! { command -v wasm-bindgen >/dev/null && wasm-bindgen --version | grep -q "$WASM_BINDGEN_VERSION"; }; then
|
||||
cargo install wasm-bindgen-cli --version "$WASM_BINDGEN_VERSION" --locked
|
||||
fi
|
||||
wasm-bindgen --version
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$EFFECTCRAFT_VERSION"
|
||||
- name: Build and zip
|
||||
run: packaging/web/package.sh
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-web
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
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, linux-riscv64, flatpak, freebsd, web]
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
VERSION: ${{ needs.version.outputs.version }}
|
||||
PRERELEASE: ${{ needs.version.outputs.prerelease }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
steps:
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
pattern: release-*
|
||||
path: artifacts
|
||||
merge-multiple: true
|
||||
- name: Checksums
|
||||
working-directory: artifacts
|
||||
run: |
|
||||
sha256sum -- * | tee SHA256SUMS.txt
|
||||
- name: Create or update the draft release
|
||||
working-directory: artifacts
|
||||
run: |
|
||||
tag="v$VERSION"
|
||||
title="EffectCraft v$VERSION"
|
||||
pre=()
|
||||
if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi
|
||||
if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then
|
||||
if [ "$draft" != "true" ]; then
|
||||
echo "::error::Release $tag is already published. Bump the version (cargo xtask version set) before pushing to release."
|
||||
exit 1
|
||||
fi
|
||||
echo "Updating draft $tag: replacing its assets"
|
||||
gh release view "$tag" --json assets --jq '.assets[].name' | while read -r asset; do
|
||||
gh release delete-asset "$tag" "$asset" --yes
|
||||
done
|
||||
gh release edit "$tag" --draft --title "$title" --target "$GITHUB_SHA" --prerelease="$PRERELEASE"
|
||||
gh release upload "$tag" --clobber -- *
|
||||
else
|
||||
echo "Creating draft $tag"
|
||||
gh release create "$tag" --draft --title "$title" --target "$GITHUB_SHA" --generate-notes "${pre[@]}" -- *
|
||||
fi
|
||||
{
|
||||
echo "Draft release **$title** at \`${GITHUB_SHA::9}\`:"
|
||||
echo
|
||||
sed 's/^/ /' SHA256SUMS.txt
|
||||
} >>"$GITHUB_STEP_SUMMARY"
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
|
||||
with:
|
||||
app-name: effectcraft
|
||||
app-title: EffectCraft
|
||||
version: ${{ inputs.version }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
|
|
|
|||
|
|
@ -4,19 +4,17 @@
|
|||
# out for now: PdfCraft saw an intermittent access violation in egui UI tests there.)
|
||||
name: Windows ARM64
|
||||
|
||||
# Nightly on main and on pushes to `release` (dependency and runner-image drift shows up within a
|
||||
# day, and always before a release ships), and on PRs that change the Windows packaging itself.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "13 5 * * *"
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [release]
|
||||
pull_request:
|
||||
paths:
|
||||
- Cargo.toml
|
||||
- Cargo.lock
|
||||
- "**/Cargo.toml"
|
||||
- "**/build.rs"
|
||||
- .github/workflows/windows-arm64.yml
|
||||
- packaging/windows/**
|
||||
schedule:
|
||||
- cron: "41 4 * * 1" # weekly, so drift in the runner images shows up
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
|
|
@ -28,70 +26,10 @@ concurrency:
|
|||
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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: aarch64-pc-windows-msvc
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
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@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
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@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
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'
|
||||
windows-arm64:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
|
||||
with:
|
||||
app-name: effectcraft
|
||||
app-dir: EffectCraft
|
||||
|
|
|
|||
|
|
@ -58,13 +58,15 @@ Screenshots of EffectCraft itself, rendered headlessly, showing only procedurall
|
|||
|
||||
## Code-drawn assets (original work, MIT OR Apache-2.0)
|
||||
|
||||
- UI icons drawn in code: `crates/ui-egui/src/icons.rs`.
|
||||
- UI icons drawn in code: `crates/ui-egui/src/icons.rs`, including the original filled favorite star (2026-10-11, EffectCraft contributors).
|
||||
- Procedural demo content (solids, shapes, gradients, text animations): `crates/engine/src/demo.rs`.
|
||||
- Built-in project templates (Lower Third, Title Card, Logo Reveal, Kinetic Type, Social Square, Vertical Story,
|
||||
Slideshow, 3D Text Orbit) built from engine commands, and their thumbnails rendered by our own renderer:
|
||||
`crates/engine/src/templates.rs`.
|
||||
- Text animator presets (Typewriter, Fade Up Characters, Bounce In Words, Tracking In, Scramble, Blur In, Jitter,
|
||||
Drop In Lines): `crates/engine/presets/text_animators.json` (sidecar `.attribution`).
|
||||
- Transition animation presets (23: wipes, clock and fan reveals, blinds, dissolves, card flips, CC wipes, fades):
|
||||
`crates/engine/presets/transitions.json` (sidecar `.attribution`).
|
||||
- Sample scripts (File ▸ Scripts) and ScriptUI panel: `crates/engine/scripts/Rename Layers.jsx`,
|
||||
`crates/engine/scripts/Sort Layers by In Point.jsx`, `crates/engine/scripts/Create Null at Selected Layers.jsx`,
|
||||
`crates/engine/scripts/Render Queue Batch.jsx`, `crates/engine/scripts/ScriptUI Panels/Layer Tools.jsx`
|
||||
|
|
|
|||
|
|
@ -1143,7 +1143,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft"
|
||||
version = "0.8.0"
|
||||
version = "0.9.2"
|
||||
dependencies = [
|
||||
"cpal",
|
||||
"effectcraft-engine",
|
||||
|
|
@ -1159,6 +1159,7 @@ dependencies = [
|
|||
"objc2-foundation 0.3.2",
|
||||
"pollster 0.4.0",
|
||||
"rfd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"winit",
|
||||
"winresource",
|
||||
|
|
@ -1167,7 +1168,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-automation"
|
||||
version = "0.8.0"
|
||||
version = "0.9.2"
|
||||
dependencies = [
|
||||
"effectcraft-engine",
|
||||
"effectcraft-host",
|
||||
|
|
@ -1179,14 +1180,14 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-av1enc"
|
||||
version = "0.8.0"
|
||||
version = "0.9.2"
|
||||
dependencies = [
|
||||
"filmcraft-av1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-cli"
|
||||
version = "0.8.0"
|
||||
version = "0.9.2"
|
||||
dependencies = [
|
||||
"cpu-time",
|
||||
"effectcraft-automation",
|
||||
|
|
@ -1202,7 +1203,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-color"
|
||||
version = "0.8.0"
|
||||
version = "0.9.2"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
|
|
@ -1210,7 +1211,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-effects"
|
||||
version = "0.8.0"
|
||||
version = "0.9.2"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-geom",
|
||||
|
|
@ -1227,7 +1228,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-engine"
|
||||
version = "0.8.0"
|
||||
version = "0.9.2"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-effects",
|
||||
|
|
@ -1260,7 +1261,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-export"
|
||||
version = "0.8.0"
|
||||
version = "0.9.2"
|
||||
dependencies = [
|
||||
"effectcraft-av1enc",
|
||||
"effectcraft-color",
|
||||
|
|
@ -1290,7 +1291,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-expr"
|
||||
version = "0.8.0"
|
||||
version = "0.9.2"
|
||||
dependencies = [
|
||||
"boa_engine",
|
||||
"effectcraft-color",
|
||||
|
|
@ -1307,7 +1308,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-geom"
|
||||
version = "0.8.0"
|
||||
version = "0.9.2"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
|
|
@ -1315,7 +1316,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-gpu"
|
||||
version = "0.8.0"
|
||||
version = "0.9.2"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"effectcraft-color",
|
||||
|
|
@ -1337,14 +1338,14 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-hevcenc"
|
||||
version = "0.8.0"
|
||||
version = "0.9.2"
|
||||
dependencies = [
|
||||
"filmcraft-hevc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-host"
|
||||
version = "0.8.0"
|
||||
version = "0.9.2"
|
||||
dependencies = [
|
||||
"effectcraft-engine",
|
||||
"effectcraft-export",
|
||||
|
|
@ -1368,7 +1369,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-interchange"
|
||||
version = "0.8.0"
|
||||
version = "0.9.2"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-keyframe",
|
||||
|
|
@ -1387,7 +1388,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-keyframe"
|
||||
version = "0.8.0"
|
||||
version = "0.9.2"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-geom",
|
||||
|
|
@ -1399,7 +1400,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-lottie"
|
||||
version = "0.8.0"
|
||||
version = "0.9.2"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-effects",
|
||||
|
|
@ -1416,7 +1417,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-media"
|
||||
version = "0.8.0"
|
||||
version = "0.9.2"
|
||||
dependencies = [
|
||||
"effectcraft-model",
|
||||
"effectcraft-pdf",
|
||||
|
|
@ -1430,6 +1431,7 @@ dependencies = [
|
|||
"filmcraft-codecs",
|
||||
"filmcraft-color",
|
||||
"filmcraft-frame",
|
||||
"filmcraft-isobmff",
|
||||
"filmcraft-media",
|
||||
"filmcraft-time",
|
||||
"gif",
|
||||
|
|
@ -1442,10 +1444,11 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-model"
|
||||
version = "0.8.0"
|
||||
version = "0.9.2"
|
||||
dependencies = [
|
||||
"effectcraft-geom",
|
||||
"image",
|
||||
"miniz_oxide 0.8.9",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
|
|
@ -1453,14 +1456,14 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-opusenc"
|
||||
version = "0.8.0"
|
||||
version = "0.9.2"
|
||||
dependencies = [
|
||||
"filmcraft-opus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-path"
|
||||
version = "0.8.0"
|
||||
version = "0.9.2"
|
||||
dependencies = [
|
||||
"effectcraft-geom",
|
||||
"effectcraft-keyframe",
|
||||
|
|
@ -1472,7 +1475,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-pdf"
|
||||
version = "0.8.0"
|
||||
version = "0.9.2"
|
||||
dependencies = [
|
||||
"effectcraft-raster",
|
||||
"effectcraft-svg",
|
||||
|
|
@ -1486,7 +1489,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-plugin"
|
||||
version = "0.8.0"
|
||||
version = "0.9.2"
|
||||
dependencies = [
|
||||
"effectcraft-effects",
|
||||
"effectcraft-keyframe",
|
||||
|
|
@ -1498,7 +1501,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-project"
|
||||
version = "0.8.0"
|
||||
version = "0.9.2"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-geom",
|
||||
|
|
@ -1511,14 +1514,14 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-psd"
|
||||
version = "0.8.0"
|
||||
version = "0.9.2"
|
||||
dependencies = [
|
||||
"miniz_oxide 0.8.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-raster"
|
||||
version = "0.8.0"
|
||||
version = "0.9.2"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-geom",
|
||||
|
|
@ -1527,7 +1530,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-render"
|
||||
version = "0.8.0"
|
||||
version = "0.9.2"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-effects",
|
||||
|
|
@ -1549,7 +1552,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-script"
|
||||
version = "0.8.0"
|
||||
version = "0.9.2"
|
||||
dependencies = [
|
||||
"boa_engine",
|
||||
"effectcraft-engine",
|
||||
|
|
@ -1557,14 +1560,16 @@ dependencies = [
|
|||
"effectcraft-keyframe",
|
||||
"effectcraft-path",
|
||||
"effectcraft-project",
|
||||
"effectcraft-psd",
|
||||
"effectcraft-render",
|
||||
"effectcraft-time",
|
||||
"serde_json",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-segment"
|
||||
version = "0.8.0"
|
||||
version = "0.9.2"
|
||||
dependencies = [
|
||||
"miniz_oxide 0.8.9",
|
||||
"ndarray",
|
||||
|
|
@ -1573,7 +1578,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-svg"
|
||||
version = "0.8.0"
|
||||
version = "0.9.2"
|
||||
dependencies = [
|
||||
"effectcraft-geom",
|
||||
"effectcraft-path",
|
||||
|
|
@ -1585,7 +1590,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-text"
|
||||
version = "0.8.0"
|
||||
version = "0.9.2"
|
||||
dependencies = [
|
||||
"effectcraft-keyframe",
|
||||
"harfrust",
|
||||
|
|
@ -1597,7 +1602,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-time"
|
||||
version = "0.8.0"
|
||||
version = "0.9.2"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
|
|
@ -1605,7 +1610,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-track"
|
||||
version = "0.8.0"
|
||||
version = "0.9.2"
|
||||
dependencies = [
|
||||
"effectcraft-raster",
|
||||
"effectcraft-segment",
|
||||
|
|
@ -1617,7 +1622,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-ui-egui"
|
||||
version = "0.8.0"
|
||||
version = "0.9.2"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"effectcraft-color",
|
||||
|
|
@ -1649,11 +1654,11 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-vp9enc"
|
||||
version = "0.8.0"
|
||||
version = "0.9.2"
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-web"
|
||||
version = "0.8.0"
|
||||
version = "0.9.2"
|
||||
dependencies = [
|
||||
"effectcraft-engine",
|
||||
"effectcraft-export",
|
||||
|
|
@ -6463,7 +6468,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
|||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.8.0"
|
||||
version = "0.9.2"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
|||
default-members = ["crates/*", "apps/*"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.8.0"
|
||||
version = "0.9.2"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ UI strings translated out of ≈ 3,900 (detail: [localization-parity.md](docs/lo
|
|||
| Korean | none | 0% |
|
||||
| Vietnamese | none | 0% |
|
||||
|
||||
Also shipped: Traditional Chinese (partial, 28%) and Ukrainian (partial, 26%). No language has had
|
||||
Also shipped: Traditional Chinese (partial, 28%) and Ukrainian (partial, 29%). No language has had
|
||||
native-speaker review.
|
||||
|
||||
## Upcoming
|
||||
|
|
@ -117,6 +117,13 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft).
|
|||
|
||||
## Progress log
|
||||
|
||||
- 2026-10-11: Ukrainian panels caught up with the Chinese catalogs. The 95 rows the panel
|
||||
catalog gained since 9 October are translated: the Home screen (recent files, their Kind and
|
||||
Opened columns, the empty and no-match states), the viewer's toolbar tooltips, magnification,
|
||||
resolution, grid, channel and Fast Previews popups, the Appearance settings, the solid dialog
|
||||
and the project, import, export and colour-management forms. `uk` now carries 495 of the 496
|
||||
panel rows — `RGB` reads the same in Ukrainian, so it falls back to the English source by
|
||||
design — alongside its 618/618 menus.
|
||||
- 2026-10-10: Full parity re-measure against After Effects 2026 26.5 (installed bundle, 26.2–26.5
|
||||
release notes, repository counts): breadth ≈ 91%, ready ≈ 45%, stage alpha. Progress docs
|
||||
follow the shared standard: `docs/parity.md` became `docs/target-app-parity.md`; new
|
||||
|
|
|
|||
|
|
@ -16,7 +16,11 @@ effectcraft-ui-egui = { workspace = true }
|
|||
egui = { workspace = true }
|
||||
# `links`: links (Help menu, Discord, Settings) open in the system browser.
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "links", "wayland", "x11"] }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
# The window's monitors, size and position (src/window.rs), and macOS event-loop options (no app
|
||||
# activation when driven by an agent). The same winit eframe runs on.
|
||||
winit = { version = "0.30", default-features = false }
|
||||
rfd = { workspace = true }
|
||||
pollster = { workspace = true }
|
||||
cpal = { workspace = true }
|
||||
|
|
@ -32,8 +36,6 @@ zbus = "5.19"
|
|||
winresource = "0.1"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
# Same winit as eframe: macOS event-loop options (no app activation when driven by an agent).
|
||||
winit = { version = "0.30", default-features = false }
|
||||
# Keep the control channel responsive when the window is covered (App Nap / occlusion throttling).
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSProcessInfo", "NSString", "NSObject"] }
|
||||
# The native menu bar (NSMenu). MIT OR Apache-2.0; macOS only, never in the web build.
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ fn run<T: SizedSample + FromSample<f32>>(
|
|||
let dev_lat = ts.playback.duration_since(&ts.callback).map(|d| d.as_secs_f64()).unwrap_or(0.0);
|
||||
latency.store((dev_lat * rate) as u64 + frames as u64, Ordering::Relaxed);
|
||||
},
|
||||
|e| eprintln!("effectcraft: audio output: {e}"),
|
||||
|e| log::warn!("audio output: {e}"),
|
||||
None,
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
|
|
@ -187,7 +187,7 @@ impl AudioDevice for CpalOut {
|
|||
return;
|
||||
}
|
||||
if let Err(e) = stream.play() {
|
||||
eprintln!("effectcraft: audio output: {e}");
|
||||
log::warn!("audio output: {e}");
|
||||
return;
|
||||
}
|
||||
let _ = stop_rx.recv();
|
||||
|
|
|
|||
|
|
@ -27,11 +27,11 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
|||
let listener = match TcpListener::bind(("127.0.0.1", port)) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
eprintln!("effectcraft: control server failed to bind 127.0.0.1:{port}: {e}");
|
||||
log::error!("control server failed to bind 127.0.0.1:{port}: {e}");
|
||||
return rx;
|
||||
}
|
||||
};
|
||||
eprintln!("effectcraft: control server listening on 127.0.0.1:{port}");
|
||||
log::info!("control server listening on 127.0.0.1:{port}");
|
||||
std::thread::spawn(move || accept_loop(listener, tx, ctx));
|
||||
rx
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ mod native_menu;
|
|||
// Built everywhere so it stays checked; only the macOS app uses it.
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
mod sheet_dialogs;
|
||||
mod window;
|
||||
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use serde_json::json;
|
||||
|
|
@ -88,13 +89,24 @@ fn parse_args(args: impl IntoIterator<Item = String>, env_port: Option<String>)
|
|||
}
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
// Help ▸ Enable Logging writes through this logger; warnings feed the compatibility report.
|
||||
// First, so the panic hook and every start-up warning are recorded. Records go to stderr
|
||||
// and the session log; Help ▸ Enable Logging writes through this logger too, and warnings
|
||||
// feed the compatibility report.
|
||||
effectcraft_engine::logging::install();
|
||||
effectcraft_engine::logging::install_panic_hook();
|
||||
let Some(Args { control_port, files, demo, home }) = parse_args(std::env::args().skip(1), std::env::var("EFFECTCRAFT_CONTROL_PORT").ok()) else {
|
||||
println!("effectcraft {}", env!("CARGO_PKG_VERSION"));
|
||||
return Ok(());
|
||||
};
|
||||
// The session log lives in the settings folder (`Logs/effectcraft.log`, the previous two runs
|
||||
// kept): a launch from the Dock or a menu has no terminal. Opened after the arguments, so
|
||||
// `--version` leaves no file behind; records logged until now are written to it first.
|
||||
if let Some(dir) = effectcraft_host::config_dir() {
|
||||
match effectcraft_engine::logging::attach_session_log(&effectcraft_engine::logging::log_dir(Some(&dir))) {
|
||||
Ok(path) => log::info!("effectcraft {}, log file {}", env!("CARGO_PKG_VERSION"), path.display()),
|
||||
Err(e) => log::warn!("no log file: {e}"),
|
||||
}
|
||||
}
|
||||
// The font menus list the installed fonts: read their names while the window opens.
|
||||
effectcraft_engine::text::fonts::scan_system_in_background();
|
||||
// Wayland shows the window's icon from its desktop entry: an AppImage brings its own.
|
||||
|
|
@ -108,8 +120,8 @@ fn main() -> eframe::Result {
|
|||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("EffectCraft")
|
||||
.with_inner_size([1680.0, 1020.0])
|
||||
.with_min_inner_size([960.0, 600.0])
|
||||
.with_inner_size(window::DEFAULT_SIZE)
|
||||
.with_min_inner_size(window::MIN_SIZE)
|
||||
.with_drag_and_drop(true)
|
||||
// Started for an agent (`--control`): open without taking the user's keyboard focus.
|
||||
.with_active(control_port.is_none())
|
||||
|
|
@ -120,8 +132,8 @@ fn main() -> eframe::Result {
|
|||
wgpu_options: wgpu_options(launch.backends),
|
||||
..Default::default()
|
||||
};
|
||||
// Start-up milestones: with `RUST_LOG=info` they go to stderr, so a window that never
|
||||
// appears shows how far start-up got (#234).
|
||||
// Start-up milestones go to stderr and the session log, so a window that never appears
|
||||
// shows how far start-up got (#234).
|
||||
log::info!("effectcraft {}: opening the window", env!("CARGO_PKG_VERSION"));
|
||||
// Registered before the event loop starts, so it catches the Finder event that launched us as
|
||||
// well as later ones. Lives until the event loop returns; the app creator only borrows it.
|
||||
|
|
@ -170,11 +182,14 @@ fn main() -> eframe::Result {
|
|||
session.config = Some(std::sync::Arc::new(effectcraft_engine::config::DirConfig::new(dir)));
|
||||
}
|
||||
session.load_settings();
|
||||
// Fit the window to its monitor, or put it back where it was (still hidden).
|
||||
let saved_window = session.config.as_deref().and_then(window::load);
|
||||
let opened = cc.winit_window().map(|w| window::restore(w, saved_window));
|
||||
let recovery = if control_port.is_none() { session.begin_recovery() } else { None };
|
||||
let project = files.iter().find(|f| is_project_file(f)).cloned();
|
||||
if let Some(p) = project {
|
||||
if let Err(e) = session.execute("file.open", json!({"path": p})) {
|
||||
eprintln!("effectcraft: {e}");
|
||||
log::warn!("{p}: {e}");
|
||||
}
|
||||
} else if demo {
|
||||
let _ = session.execute("file.openDemoProject", json!({}));
|
||||
|
|
@ -183,7 +198,7 @@ fn main() -> eframe::Result {
|
|||
if !media.is_empty()
|
||||
&& let Err(e) = session.execute("file.import", json!({"paths": media}))
|
||||
{
|
||||
eprintln!("effectcraft: {e}");
|
||||
log::warn!("import: {e}");
|
||||
}
|
||||
let show_home = home.unwrap_or(session.prefs.startup.show_home_on_launch && files.is_empty() && control_port.is_none());
|
||||
// The ScriptUI panels open when the app last quit open again (a failing one is
|
||||
|
|
@ -264,6 +279,7 @@ fn main() -> eframe::Result {
|
|||
#[cfg(target_os = "macos")]
|
||||
apple_events: apple_events.connect(&cc.egui_ctx),
|
||||
app,
|
||||
window: window::Tracker::new(opened, saved_window, control_port.is_none()),
|
||||
launch,
|
||||
drawn: false,
|
||||
}))
|
||||
|
|
@ -315,8 +331,12 @@ fn startup_failed(e: &eframe::Error, gl_next: bool) {
|
|||
let message = format!(
|
||||
"EffectCraft couldn't start its window: {e}\n\n{next}Updating the graphics driver often helps. If it keeps happening, please report it at https://github.com/storytold/effectcraft/issues with this message."
|
||||
);
|
||||
log::error!("{message}");
|
||||
eprintln!("effectcraft: {message}");
|
||||
// Standard error and the session log; standard error alone when RUST_LOG turned errors off.
|
||||
if log::log_enabled!(log::Level::Error) {
|
||||
log::error!("{message}");
|
||||
} else {
|
||||
eprintln!("effectcraft: {message}");
|
||||
}
|
||||
let _ = rfd::MessageDialog::new()
|
||||
.set_level(rfd::MessageLevel::Error)
|
||||
.set_title("EffectCraft")
|
||||
|
|
@ -333,6 +353,8 @@ struct Desktop {
|
|||
/// Documents opened from Finder, the Dock or `open` (macOS).
|
||||
#[cfg(target_os = "macos")]
|
||||
apple_events: apple_events::Opened,
|
||||
/// The window's size and position, saved for the next launch.
|
||||
window: window::Tracker,
|
||||
launch: launch_guard::Launch,
|
||||
/// The first frame was drawn (logged once).
|
||||
drawn: bool,
|
||||
|
|
@ -347,6 +369,7 @@ impl eframe::App for Desktop {
|
|||
|
||||
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
|
||||
self.app.ui(ui, frame);
|
||||
self.window.frame(ui.ctx(), self.app.session.config.as_deref());
|
||||
if !self.drawn {
|
||||
self.drawn = true;
|
||||
log::info!("first frame drawn");
|
||||
|
|
@ -360,6 +383,7 @@ impl eframe::App for Desktop {
|
|||
|
||||
fn on_exit(&mut self) {
|
||||
self.launch.exited();
|
||||
self.window.save(self.app.session.config.as_deref());
|
||||
self.app.on_exit();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,371 @@
|
|||
//! The main window's size and position. The first launch fits the window to its monitor, opening
|
||||
//! it maximized where the default size leaves no room for a taskbar or Dock. Later launches restore
|
||||
//! the size, position and maximized state saved in the config directory (`window.json`), moved
|
||||
//! onto a monitor that is still connected.
|
||||
|
||||
use effectcraft_engine::config::ConfigStore;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use winit::dpi::{LogicalSize, PhysicalPosition, PhysicalSize};
|
||||
|
||||
const FILE: &str = "window.json";
|
||||
const MAX_BYTES: usize = 4096;
|
||||
/// Seconds a window opened maximized gets to say so before its size is tracked.
|
||||
const MAXIMIZE_WAIT: f64 = 2.0;
|
||||
|
||||
/// The window's geometry between launches.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct WindowGeometry {
|
||||
/// The outer top-left corner in physical pixels; `None` where the system doesn't say (Wayland).
|
||||
pub pos: Option<[i32; 2]>,
|
||||
/// The inner size in points; while maximized, the size to return to.
|
||||
pub size: [f32; 2],
|
||||
pub maximized: bool,
|
||||
}
|
||||
|
||||
/// The first window's size, in points.
|
||||
pub const DEFAULT_SIZE: [f32; 2] = [1680.0, 1020.0];
|
||||
/// The smallest window, in points.
|
||||
pub const MIN_SIZE: [f32; 2] = [960.0, 600.0];
|
||||
/// Room the first window leaves around itself for a taskbar, Dock or menu bar (the system doesn't
|
||||
/// say how much of the screen they take): a monitor with less room opens it maximized.
|
||||
const SCREEN_ROOM: [f32; 2] = [64.0, 128.0];
|
||||
|
||||
/// A monitor: its top-left corner and size in physical pixels, and its scale factor.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Monitor {
|
||||
pos: [i64; 2],
|
||||
size: [i64; 2],
|
||||
scale: f32,
|
||||
}
|
||||
|
||||
impl Monitor {
|
||||
pub fn new(pos: PhysicalPosition<i32>, size: PhysicalSize<u32>, scale: f64) -> Self {
|
||||
let scale = scale as f32;
|
||||
let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
|
||||
Self { pos: [pos.x.into(), pos.y.into()], size: [size.width.into(), size.height.into()], scale }
|
||||
}
|
||||
|
||||
/// Its size in points.
|
||||
fn points(&self) -> [f32; 2] {
|
||||
self.size.map(|s| s as f32 / self.scale)
|
||||
}
|
||||
|
||||
/// Its size in points less room for a taskbar, Dock or menu bar.
|
||||
fn usable(&self) -> [f32; 2] {
|
||||
let points = self.points();
|
||||
std::array::from_fn(|i| points[i] - SCREEN_ROOM[i])
|
||||
}
|
||||
|
||||
/// A window of `size` points on this monitor, in physical pixels.
|
||||
fn pixels(&self, size: [f32; 2]) -> [i64; 2] {
|
||||
size.map(|s| (s * self.scale) as i64)
|
||||
}
|
||||
|
||||
/// How much of a window at `pos` of `size` points this monitor shows, in square pixels.
|
||||
fn overlap(&self, pos: [i32; 2], size: [f32; 2]) -> i64 {
|
||||
let px = self.pixels(size);
|
||||
let span = |i: usize| {
|
||||
let start = i64::from(pos[i]).max(self.pos[i]);
|
||||
let end = i64::from(pos[i]).saturating_add(px[i]).min(self.pos[i] + self.size[i]);
|
||||
(end - start).max(0)
|
||||
};
|
||||
span(0).saturating_mul(span(1))
|
||||
}
|
||||
|
||||
/// The top-left corner nearest to `pos` (centred when none) that keeps a window of `size`
|
||||
/// points on this monitor.
|
||||
fn keep_on(&self, pos: Option<[i32; 2]>, size: [f32; 2]) -> [i32; 2] {
|
||||
let px = self.pixels(size);
|
||||
std::array::from_fn(|i| {
|
||||
let lo = self.pos[i];
|
||||
let hi = lo + self.size[i].saturating_sub(px[i]).max(0);
|
||||
let want = pos.map_or(lo + (hi - lo) / 2, |p| i64::from(p[i]));
|
||||
i32::try_from(want.max(lo).min(hi)).unwrap_or_default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the window opens: `saved` (the last session's geometry) kept on a connected monitor, else
|
||||
/// the default size on `home` (the monitor the system opens it on). Without monitors (the system
|
||||
/// doesn't list them, so nothing to check a saved geometry against) the default size.
|
||||
pub fn place(saved: Option<WindowGeometry>, monitors: &[Monitor], home: Option<usize>) -> WindowGeometry {
|
||||
let default = WindowGeometry { pos: None, size: DEFAULT_SIZE, maximized: false };
|
||||
let Some(home) = home.and_then(|i| monitors.get(i)).or(monitors.first()) else {
|
||||
return default;
|
||||
};
|
||||
let Some(saved) = saved else {
|
||||
let usable = home.usable();
|
||||
if (0..2).all(|i| DEFAULT_SIZE[i] <= usable[i]) {
|
||||
return default;
|
||||
}
|
||||
// A small screen: maximized, with a size that fits once un-maximized.
|
||||
let size = fit(DEFAULT_SIZE, usable);
|
||||
return WindowGeometry { pos: Some(home.keep_on(None, size)), size, maximized: true };
|
||||
};
|
||||
// The monitor showing most of the window; none when it was on a monitor that is gone.
|
||||
let shown = saved.pos.and_then(|p| {
|
||||
let (area, m) = monitors.iter().map(|m| (m.overlap(p, saved.size), m)).max_by_key(|(area, _)| *area)?;
|
||||
(area > 0).then_some(m)
|
||||
});
|
||||
let m = shown.unwrap_or(home);
|
||||
// Bigger than its monitor (a smaller screen than last time): maximized, like the first window.
|
||||
let room = m.points();
|
||||
let too_big = (0..2).any(|i| saved.size[i] > room[i]);
|
||||
let size = fit(saved.size, if too_big { m.usable() } else { room });
|
||||
WindowGeometry {
|
||||
// Moved fully onto its monitor, or centred on the home monitor.
|
||||
pos: saved.pos.map(|p| m.keep_on(shown.map(|_| p), size)),
|
||||
size,
|
||||
maximized: saved.maximized || too_big,
|
||||
}
|
||||
}
|
||||
|
||||
/// `size` no bigger than `room`, and no smaller than the smallest window unless `room` is.
|
||||
fn fit(size: [f32; 2], room: [f32; 2]) -> [f32; 2] {
|
||||
std::array::from_fn(|i| size[i].min(room[i]).max(MIN_SIZE[i].min(room[i])).max(1.0))
|
||||
}
|
||||
|
||||
/// Size and place the new window (eframe shows it after drawing the first frame) and return its
|
||||
/// geometry.
|
||||
pub fn restore(window: &winit::window::Window, saved: Option<WindowGeometry>) -> WindowGeometry {
|
||||
let handles: Vec<_> = window.available_monitors().collect();
|
||||
let monitors: Vec<Monitor> = handles.iter().map(|m| Monitor::new(m.position(), m.size(), m.scale_factor())).collect();
|
||||
let home = window.current_monitor().or_else(|| window.primary_monitor()).and_then(|c| handles.iter().position(|m| *m == c));
|
||||
let g = place(saved, &monitors, home);
|
||||
if let Some([x, y]) = g.pos {
|
||||
window.set_outer_position(PhysicalPosition::new(x, y));
|
||||
}
|
||||
// Returns the new size when it applies at once; either way the window gets it.
|
||||
let _ = window.request_inner_size(LogicalSize::new(g.size[0], g.size[1]));
|
||||
if g.maximized {
|
||||
window.set_maximized(true);
|
||||
}
|
||||
g
|
||||
}
|
||||
|
||||
/// Per frame: remember the window's geometry for the next launch. Minimized or full screen keeps
|
||||
/// the last one; maximized only sets its flag, so un-maximizing next time restores the last size.
|
||||
pub fn track(ctx: &egui::Context, geometry: &mut Option<WindowGeometry>) {
|
||||
let zoom = ctx.zoom_factor();
|
||||
ctx.input(|i| {
|
||||
let v = i.viewport();
|
||||
if v.minimized == Some(true) || v.fullscreen == Some(true) {
|
||||
return;
|
||||
}
|
||||
if v.maximized == Some(true) {
|
||||
if let Some(g) = geometry {
|
||||
g.maximized = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some(inner) = v.inner_rect else { return };
|
||||
let pos = v.outer_rect.map(|r| [r.min.x, r.min.y].map(|c| (c * i.pixels_per_point).round() as i32));
|
||||
*geometry = Some(WindowGeometry { pos, size: [inner.width() * zoom, inner.height() * zoom], maximized: false });
|
||||
});
|
||||
}
|
||||
|
||||
/// The geometry saved by the last session; `None` when there is none or it can't be read.
|
||||
pub fn load(config: &dyn ConfigStore) -> Option<WindowGeometry> {
|
||||
let text = config.read_bounded(FILE, MAX_BYTES).ok()??;
|
||||
serde_json::from_str(&text).ok()
|
||||
}
|
||||
|
||||
/// Follows the window and saves its geometry for the next launch: when it has changed (at most
|
||||
/// once a second, so a session that ends badly still has it) and on exit.
|
||||
pub struct Tracker {
|
||||
geometry: Option<WindowGeometry>,
|
||||
saved: Option<WindowGeometry>,
|
||||
last_attempt: f64,
|
||||
/// Opened maximized, and the system hasn't reported it so yet (when the wait ends): the
|
||||
/// window's first frames report the maximized size as an ordinary one, which would replace
|
||||
/// the size to return to.
|
||||
maximizing: Option<f64>,
|
||||
/// Agent-driven runs (`--control`) leave the user's saved window alone.
|
||||
save: bool,
|
||||
}
|
||||
|
||||
impl Tracker {
|
||||
/// `geometry`: what the window opened with.
|
||||
pub fn new(geometry: Option<WindowGeometry>, saved: Option<WindowGeometry>, save: bool) -> Self {
|
||||
let maximizing = geometry.is_some_and(|g| g.maximized).then_some(f64::INFINITY);
|
||||
Self { geometry, saved, last_attempt: f64::NEG_INFINITY, maximizing, save }
|
||||
}
|
||||
|
||||
/// Per frame.
|
||||
pub fn frame(&mut self, ctx: &egui::Context, config: Option<&dyn ConfigStore>) {
|
||||
let (now, maximized) = ctx.input(|i| (i.time, i.viewport().maximized == Some(true)));
|
||||
if let Some(until) = self.maximizing {
|
||||
// A system that never maximizes the window is believed after a moment.
|
||||
let until = until.min(now + MAXIMIZE_WAIT);
|
||||
self.maximizing = (!maximized && now < until).then_some(until);
|
||||
}
|
||||
if self.maximizing.is_none() {
|
||||
track(ctx, &mut self.geometry);
|
||||
}
|
||||
if now - self.last_attempt >= 1.0 {
|
||||
self.last_attempt = now;
|
||||
self.save(config);
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the geometry when it differs from the saved one.
|
||||
pub fn save(&mut self, config: Option<&dyn ConfigStore>) {
|
||||
let (Some(config), Some(g)) = (config, self.geometry) else { return };
|
||||
if !self.save || self.saved == Some(g) {
|
||||
return;
|
||||
}
|
||||
match serde_json::to_string(&g).map_err(|e| e.to_string()).and_then(|text| config.write(FILE, &text).map_err(|e| e.to_string())) {
|
||||
Ok(()) => self.saved = Some(g),
|
||||
Err(e) => log::warn!("could not save the window geometry: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use effectcraft_engine::config::MemoryConfig;
|
||||
|
||||
fn monitor(x: i32, y: i32, w: u32, h: u32, scale: f64) -> Monitor {
|
||||
Monitor::new(PhysicalPosition::new(x, y), PhysicalSize::new(w, h), scale)
|
||||
}
|
||||
|
||||
fn saved(pos: Option<[i32; 2]>, size: [f32; 2], maximized: bool) -> Option<WindowGeometry> {
|
||||
Some(WindowGeometry { pos, size, maximized })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_window_fits_its_screen() {
|
||||
// 2560 × 1440: the default size, where the system puts it.
|
||||
let big = [monitor(0, 0, 2560, 1440, 1.0)];
|
||||
assert_eq!(place(None, &big, Some(0)), WindowGeometry { pos: None, size: DEFAULT_SIZE, maximized: false });
|
||||
// 1920 × 1080, 1600 × 900, 1366 × 768 and 1920 × 1080 at 150% (1280 × 720 points):
|
||||
// maximized, and a size that fits the screen with room for the taskbar once un-maximized.
|
||||
for (m, size) in [
|
||||
(monitor(0, 0, 1920, 1080, 1.0), [1680.0, 952.0]),
|
||||
(monitor(0, 0, 1600, 900, 1.0), [1536.0, 772.0]),
|
||||
(monitor(0, 0, 1366, 768, 1.0), [1302.0, 640.0]),
|
||||
(monitor(0, 0, 1920, 1080, 1.5), [1216.0, 592.0]),
|
||||
] {
|
||||
let g = place(None, &[m], Some(0));
|
||||
assert!(g.maximized, "{m:?}");
|
||||
assert_eq!(g.size, size, "{m:?}");
|
||||
let [x, y] = g.pos.unwrap();
|
||||
let px = m.pixels(g.size);
|
||||
assert!(x >= 0 && y >= 0 && i64::from(x) + px[0] <= m.size[0] && i64::from(y) + px[1] <= m.size[1], "{m:?}: {g:?}");
|
||||
}
|
||||
// No monitor information: the default size, whatever was saved.
|
||||
assert_eq!(place(None, &[], None), WindowGeometry { pos: None, size: DEFAULT_SIZE, maximized: false });
|
||||
assert_eq!(place(saved(Some([9000, 9000]), [1e9, 1e9], true), &[], None).size, DEFAULT_SIZE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_saved_window_comes_back_where_it_was() {
|
||||
let two = [monitor(0, 0, 2560, 1440, 1.0), monitor(2560, 0, 1920, 1080, 1.0)];
|
||||
for g in [saved(Some([100, 80]), [1200.0, 800.0], false), saved(Some([2700, 50]), [1000.0, 700.0], true), saved(None, [1000.0, 700.0], false)] {
|
||||
assert_eq!(Some(place(g, &two, Some(0))), g);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_window_stays_on_a_connected_monitor() {
|
||||
let laptop = [monitor(0, 0, 1600, 900, 1.0)];
|
||||
// Last time on a second monitor that is gone: centred on the remaining one.
|
||||
let g = place(saved(Some([2700, 50]), [1000.0, 700.0], false), &laptop, Some(0));
|
||||
assert_eq!(g, WindowGeometry { pos: Some([300, 100]), size: [1000.0, 700.0], maximized: false });
|
||||
// Half off the monitor's right and bottom edges: moved fully onto it.
|
||||
let g = place(saved(Some([1200, 600]), [1000.0, 700.0], false), &laptop, Some(0));
|
||||
assert_eq!(g.pos, Some([600, 200]));
|
||||
// Bigger than the screen now (it was on a larger one): it fits, and opens maximized.
|
||||
let g = place(saved(Some([0, 0]), [2400.0, 1300.0], false), &laptop, Some(0));
|
||||
assert_eq!(g, WindowGeometry { pos: Some([0, 0]), size: [1536.0, 772.0], maximized: true });
|
||||
// A monitor left of the primary one (negative coordinates) still counts.
|
||||
let left = [monitor(0, 0, 1920, 1080, 1.0), monitor(-1920, 0, 1920, 1080, 1.0)];
|
||||
assert_eq!(place(saved(Some([-1800, 40]), [1000.0, 700.0], false), &left, Some(0)).pos, Some([-1800, 40]));
|
||||
// Nonsense sizes from a damaged preferences file are kept within the screen.
|
||||
let g = place(saved(Some([i32::MAX, i32::MIN]), [-5.0, f32::MAX], false), &laptop, Some(0));
|
||||
assert_eq!(g, WindowGeometry { pos: Some([320, 64]), size: [960.0, 772.0], maximized: true });
|
||||
let g = place(saved(Some([5, 5]), [f32::NEG_INFINITY, f32::INFINITY], false), &laptop, Some(0));
|
||||
assert_eq!(g, WindowGeometry { pos: Some([320, 64]), size: [960.0, 772.0], maximized: true });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_window_geometry_is_tracked_for_the_next_launch() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut geometry = None;
|
||||
let mut frame = |info: egui::ViewportInfo| {
|
||||
let mut input = egui::RawInput::default();
|
||||
input.viewports.insert(egui::ViewportId::ROOT, info);
|
||||
ctx.run_ui(input, |ui| track(ui.ctx(), &mut geometry)).textures_delta.clear();
|
||||
geometry
|
||||
};
|
||||
let rect = egui::Rect::from_min_size(egui::pos2(20.0, 30.0), egui::vec2(1000.0, 600.0));
|
||||
let normal = egui::ViewportInfo {
|
||||
native_pixels_per_point: Some(1.5),
|
||||
inner_rect: Some(rect),
|
||||
outer_rect: Some(rect),
|
||||
maximized: Some(false),
|
||||
..Default::default()
|
||||
};
|
||||
// Position in physical pixels, size in points.
|
||||
let g = WindowGeometry { pos: Some([30, 45]), size: [1000.0, 600.0], maximized: false };
|
||||
assert_eq!(frame(normal.clone()), Some(g));
|
||||
// Maximized or minimized: the size to restore stays.
|
||||
let big = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 700.0));
|
||||
let maximized = egui::ViewportInfo { inner_rect: Some(big), outer_rect: Some(big), maximized: Some(true), ..normal.clone() };
|
||||
assert_eq!(frame(maximized), Some(WindowGeometry { maximized: true, ..g }));
|
||||
let minimized = egui::ViewportInfo { inner_rect: None, outer_rect: None, minimized: Some(true), ..normal.clone() };
|
||||
assert_eq!(frame(minimized), Some(WindowGeometry { maximized: true, ..g }));
|
||||
assert_eq!(frame(normal), Some(g));
|
||||
}
|
||||
|
||||
/// A window restored maximized keeps the size it returns to, though its first frames report
|
||||
/// the maximized size as an ordinary one.
|
||||
#[test]
|
||||
fn opening_maximized_keeps_the_size_to_return_to() {
|
||||
let ctx = egui::Context::default();
|
||||
let g = WindowGeometry { pos: Some([40, 30]), size: [1000.0, 600.0], maximized: true };
|
||||
let mut tracker = Tracker::new(Some(g), Some(g), true);
|
||||
let big = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1728.0, 1052.0));
|
||||
let small = egui::Rect::from_min_size(egui::pos2(40.0, 30.0), egui::vec2(900.0, 500.0));
|
||||
let frame = |tracker: &mut Tracker, time: f64, rect: egui::Rect, maximized: bool| {
|
||||
let info = egui::ViewportInfo { inner_rect: Some(rect), outer_rect: Some(rect), maximized: Some(maximized), ..Default::default() };
|
||||
let mut input = egui::RawInput { time: Some(time), ..Default::default() };
|
||||
input.viewports.insert(egui::ViewportId::ROOT, info);
|
||||
ctx.run_ui(input, |ui| tracker.frame(ui.ctx(), None)).textures_delta.clear();
|
||||
tracker.geometry
|
||||
};
|
||||
assert_eq!(frame(&mut tracker, 0.0, big, false), Some(g));
|
||||
assert_eq!(frame(&mut tracker, 0.5, big, true), Some(g));
|
||||
// Un-maximized by the user afterwards: tracked as usual.
|
||||
let restored = WindowGeometry { pos: Some([40, 30]), size: [900.0, 500.0], maximized: false };
|
||||
assert_eq!(frame(&mut tracker, 0.6, small, false), Some(restored));
|
||||
// A system that doesn't maximize: the window's real size is tracked after the wait.
|
||||
let mut tracker = Tracker::new(Some(g), Some(g), true);
|
||||
assert_eq!(frame(&mut tracker, 10.0, small, false), Some(g));
|
||||
assert_eq!(frame(&mut tracker, 10.0 + MAXIMIZE_WAIT, small, false), Some(restored));
|
||||
}
|
||||
|
||||
/// The reported bug: the window opened at the default size and place on every launch.
|
||||
#[test]
|
||||
fn the_window_geometry_is_saved_and_loaded() {
|
||||
let config = MemoryConfig::default();
|
||||
assert_eq!(load(&config), None);
|
||||
let g = WindowGeometry { pos: Some([30, 45]), size: [1000.0, 600.0], maximized: true };
|
||||
let mut tracker = Tracker::new(Some(g), None, true);
|
||||
tracker.save(Some(&config));
|
||||
assert_eq!(load(&config), Some(g));
|
||||
// Positions the system doesn't report (Wayland) round-trip too.
|
||||
tracker.geometry = Some(WindowGeometry { pos: None, ..g });
|
||||
tracker.save(Some(&config));
|
||||
assert_eq!(load(&config), Some(WindowGeometry { pos: None, ..g }));
|
||||
// An agent-driven run leaves the saved window alone.
|
||||
Tracker::new(Some(g), None, false).save(Some(&config));
|
||||
assert_eq!(load(&config).and_then(|g| g.pos), None);
|
||||
// A damaged or oversized file is ignored: the window opens at its default.
|
||||
for text in ["{", "null", "{\"pos\":[1],\"size\":[1,2],\"maximized\":false}", &" ".repeat(MAX_BYTES + 1)] {
|
||||
config.write(FILE, text).unwrap();
|
||||
assert_eq!(load(&config), None, "{text:.20}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -54,3 +54,13 @@ sync_github_name = false
|
|||
real_name = "Vikram Bhaskaran"
|
||||
display_name = "Vikram Bhaskaran"
|
||||
sync_github_name = false
|
||||
|
||||
[people.vangel76]
|
||||
real_name = "Valentin Klein"
|
||||
display_name = "Valentin Klein"
|
||||
|
||||
[people.Losminek]
|
||||
sync_github_name = true
|
||||
|
||||
[people.webdevtodayjason]
|
||||
display_name = "Jason Brashear"
|
||||
|
|
|
|||
|
|
@ -681,6 +681,12 @@ fn list_fonts_lists_families_with_styles() {
|
|||
assert!(matched.len() < fams.len(), "the query filters: {one}");
|
||||
let mono = matched.iter().find(|f| f["family"] == "JetBrains Mono").unwrap_or_else(|| panic!("{one}"));
|
||||
assert!(mono["styles"].as_array().unwrap().contains(&json!("Regular")), "{one}");
|
||||
assert_eq!(mono["variable"], false, "{one}");
|
||||
// `variable` keeps only the variable fonts (the font menu's filter); the static bundled
|
||||
// families drop out.
|
||||
let var = call_json(&mut s, "list_fonts", json!({"variable": true}));
|
||||
let listed = var["families"].as_array().unwrap();
|
||||
assert!(listed.iter().all(|f| f["variable"] == true && f["family"] != "Inter"), "{var}");
|
||||
}
|
||||
|
||||
/// Trained models over MCP (Roto Brush and face tracking): listed with their authors and
|
||||
|
|
|
|||
|
|
@ -210,7 +210,7 @@ fn list_effects(b: &mut Backend, a: &Value) -> Result<Reply> {
|
|||
}
|
||||
|
||||
fn list_fonts(b: &mut Backend, a: &Value) -> Result<Reply> {
|
||||
json_reply(b.exec("text.fonts", obj(&[("query", get(a, "query")), ("rescan", get(a, "rescan"))]))?)
|
||||
json_reply(b.exec("text.fonts", obj(&[("query", get(a, "query")), ("variable", get(a, "variable")), ("rescan", get(a, "rescan"))]))?)
|
||||
}
|
||||
|
||||
/// `effect.apply` on one layer, then set parameters on the new instance by param id: one batch,
|
||||
|
|
@ -703,12 +703,13 @@ static TOOLS: &[ToolDef] = &[
|
|||
},
|
||||
ToolDef {
|
||||
name: "list_fonts",
|
||||
description: "The font families text layers can use, bundled and installed on this machine: family, styles, origin (bundled / system / user) and the name in the font's own language. Narrow with `query`; `rescan` picks up fonts installed since launch. Set one with execute_command `layer.setText {\"layer\", \"font\", \"style\"}`.",
|
||||
description: "The font families text layers can use, bundled and installed on this machine: family, styles, origin (bundled / system / user), whether it is a variable font and the name in the font's own language. Narrow with `query` and `variable`; `rescan` picks up fonts installed since launch. Set one with execute_command `layer.setText {\"layer\", \"font\", \"style\"}`.",
|
||||
bridge_only: false,
|
||||
schema: || {
|
||||
schema(
|
||||
json!({
|
||||
"query": {"type": "string", "description": "Only families whose name (English or own-language) contains this text."},
|
||||
"variable": {"type": "boolean", "description": "Only variable fonts (families with variation axes)."},
|
||||
"rescan": {"type": "boolean", "description": "Look for fonts installed since launch first."}
|
||||
}),
|
||||
&[],
|
||||
|
|
|
|||
|
|
@ -323,11 +323,40 @@ fn wild(pat: &str, name: &str) -> bool {
|
|||
}
|
||||
}
|
||||
|
||||
/// Parse a selection: comma-separated names (optionally quoted) or `<hex>` hashes / wildcards.
|
||||
/// Split a selection at its commas. A name in double or single quotes stays whole, commas
|
||||
/// included (Blender allows `Green, Glossy`), and a backslash takes the next character
|
||||
/// literally (`Green\, Glossy`, `\"`). Unquoted text is trimmed; the quotes are dropped.
|
||||
fn selection_names(sel: &str) -> Vec<String> {
|
||||
let mut out = vec![];
|
||||
let mut cur = String::new();
|
||||
let mut quote: Option<char> = None;
|
||||
let mut chars = sel.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
match (c, quote) {
|
||||
('\\', _) => {
|
||||
if let Some(next) = chars.next() {
|
||||
cur.push(next);
|
||||
}
|
||||
}
|
||||
(c, Some(q)) if c == q => quote = None,
|
||||
('"' | '\'', None) if cur.trim().is_empty() => {
|
||||
cur.clear();
|
||||
quote = Some(c);
|
||||
}
|
||||
(',', None) => out.push(std::mem::take(&mut cur).trim().to_string()),
|
||||
(c, _) => cur.push(c),
|
||||
}
|
||||
}
|
||||
out.push(cur.trim().to_string());
|
||||
out
|
||||
}
|
||||
|
||||
/// Parse a selection: comma-separated names (optionally quoted, see [`selection_names`]) or
|
||||
/// `<hex>` hashes / wildcards.
|
||||
pub fn selection_hashes(sel: &str, manifest: Option<&[(String, u32)]>) -> Vec<u32> {
|
||||
let mut out = vec![];
|
||||
for tok in sel.split(',') {
|
||||
let t = tok.trim().trim_matches('"').trim_matches('\'');
|
||||
for t in selection_names(sel) {
|
||||
let t = t.as_str();
|
||||
if t.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -677,6 +706,25 @@ mod tests {
|
|||
assert_eq!(selection_hashes(&hex, None), vec![crypto_hash("left")]);
|
||||
}
|
||||
|
||||
/// #473: a name with a comma in it (Blender allows `Green, Glossy`) is selected when it is
|
||||
/// quoted or its comma escaped, instead of being cut into two names that match nothing.
|
||||
#[test]
|
||||
fn cryptomatte_selection_keeps_quoted_names_with_commas_whole() {
|
||||
let manifest = vec![("Green, Glossy".to_string(), 7), ("Green".to_string(), 1), ("Glossy".to_string(), 2), ("ring".to_string(), 3)];
|
||||
let m = Some(manifest.as_slice());
|
||||
assert_eq!(selection_hashes(r#""Green, Glossy""#, m), vec![7]);
|
||||
assert_eq!(selection_hashes("'Green, Glossy', ring", m), vec![7, 3]);
|
||||
assert_eq!(selection_hashes(r"Green\, Glossy", m), vec![7]);
|
||||
assert_eq!(selection_hashes(r#"ring , "Green, Glossy""#, m), vec![3, 7]);
|
||||
// Without a manifest the whole name is hashed.
|
||||
assert_eq!(selection_hashes(r#""Green, Glossy""#, None), vec![crypto_hash("Green, Glossy")]);
|
||||
// Plain lists, apostrophes inside names and an unclosed quote still parse.
|
||||
assert_eq!(selection_hashes("Green, Glossy", m), vec![1, 2]);
|
||||
assert_eq!(selection_names("Bob's mat, ring"), vec!["Bob's mat", "ring"]);
|
||||
assert_eq!(selection_names(r#""Green, Glossy"#), vec!["Green, Glossy"]);
|
||||
assert_eq!(selection_names(r#"say \"hi\""#), vec![r#"say "hi""#]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fog_and_depth_of_field_follow_depth() {
|
||||
let h = scene();
|
||||
|
|
|
|||
|
|
@ -43,16 +43,27 @@ fn tritone(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|||
b
|
||||
}
|
||||
|
||||
/// Brightness & Contrast without Use Legacy: tone curves that keep black and white fixed (no
|
||||
/// clipping). Brightness bends the curve with a power (`b` in -1.5..1.5), contrast is an S
|
||||
/// curve around mid grey (`c` in -1..1). Values above 1 pass through. The GPU kernel mirrors it.
|
||||
/// Brightness & Contrast without Use Legacy: pin black and white, then a mild S-curve.
|
||||
///
|
||||
/// Fitted to After Effects non-legacy grey ramps (#681, kasper9n oracles). Brightness is a
|
||||
/// power toward white (`1-(1-v)^e`) or black (`v^e`); contrast uses `g = 1 + 0.53 c` instead of
|
||||
/// the old `2^(2c)` (that crushed 0.25 to ~8/255 at Contrast 100; AE is ~40/255). Values above 1
|
||||
/// pass through. The GPU kernel mirrors it.
|
||||
pub fn bc_modern(v: f32, b: f32, c: f32) -> f32 {
|
||||
if v >= 1.0 {
|
||||
return v;
|
||||
}
|
||||
let v = v.max(0.0).powf(2f32.powf(-b));
|
||||
let g = 2f32.powf(2.0 * c);
|
||||
if v < 0.5 { 0.5 * (2.0 * v).powf(g) } else { 1.0 - 0.5 * (2.0 - 2.0 * v).max(0.0).powf(g) }
|
||||
let mut v = v.max(0.0);
|
||||
if b > 0.0 {
|
||||
v = 1.0 - (1.0 - v).powf(2f32.powf(1.24 * b));
|
||||
} else if b < 0.0 {
|
||||
v = v.powf(2f32.powf(-1.32 * b));
|
||||
}
|
||||
if c != 0.0 {
|
||||
let g = (1.0 + 0.53 * c).max(0.05);
|
||||
v = if v < 0.5 { 0.5 * (2.0 * v).powf(g) } else { 1.0 - 0.5 * (2.0 - 2.0 * v).max(0.0).powf(g) };
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn brightness_contrast(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
|
|
@ -1181,6 +1192,34 @@ mod tests {
|
|||
assert!((bc(&legacy, 0.0) - 0.5).abs() < 1e-5);
|
||||
}
|
||||
|
||||
/// After Effects non-legacy ramps (kasper9n, #681). B0 C-100 matched B0 C-50 and is omitted.
|
||||
#[test]
|
||||
fn brightness_contrast_modern_matches_ae_ramp_samples() {
|
||||
let u8 = |v: f32| (v * 255.0).round() as i32;
|
||||
let go = |b: f64, c: f64, x: u8| {
|
||||
u8(px("ec.color.brightnesscontrast", &[("brightness", num(b)), ("contrast", num(c))], [x as f32 / 255.0, x as f32 / 255.0, x as f32 / 255.0, 1.0])
|
||||
[0])
|
||||
};
|
||||
// (brightness, contrast, input, AE 8-bit)
|
||||
let samples: &[(f64, f64, u8, u8)] = &[
|
||||
(0.0, 100.0, 64, 40),
|
||||
(0.0, 100.0, 128, 129),
|
||||
(0.0, 100.0, 192, 217),
|
||||
(0.0, 50.0, 64, 53),
|
||||
(0.0, 50.0, 192, 205),
|
||||
(0.0, -50.0, 64, 77),
|
||||
(100.0, 0.0, 128, 209),
|
||||
(50.0, 0.0, 128, 171),
|
||||
(106.0, -18.0, 128, 214),
|
||||
];
|
||||
for &(b, c, x, ae) in samples {
|
||||
let got = go(b, c, x);
|
||||
assert!((got - ae as i32).abs() <= 10, "B{b} C{c} in {x}: got {got} AE {ae}");
|
||||
}
|
||||
// Old g=4 S-curve put Contrast 100 at ~8/255 here.
|
||||
assert!(go(0.0, 100.0, 64) >= 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn levels_clips_black_and_white_independently() {
|
||||
let lv = |vals: &[(&str, Value)], v: f32| px("ec.color.levels", vals, [v, v, v, 1.0])[0];
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ pub use noise::remove_grain_level;
|
|||
pub use noise3::{FractalGpu, fractal_gpu};
|
||||
pub use stylize3::{BumpLight, bump_layer_id};
|
||||
pub use textfx::{TextLayer, text_layer};
|
||||
pub use time_fx::{PixelMotion, TimewarpPlan, TwStep, posterized_time, time_frames, timewarp_crop, timewarp_plan};
|
||||
pub use time_fx::{PixelMotion, TimewarpPlan, TwStep, pmb_plan, posterized_time, time_frames, timewarp_crop, timewarp_plan};
|
||||
// effectcraft-gpu fx_tone.
|
||||
pub use ocio::color_stabilizer_maps;
|
||||
// GPU effects, part B (3D Channel, Immersive Video, colour management, simulation render passes).
|
||||
|
|
@ -265,6 +265,21 @@ impl Buf {
|
|||
pub fn to_px(&self, p: [f64; 2]) -> (f64, f64) {
|
||||
(p[0] * self.scale + self.offset[0], p[1] * self.scale + self.offset[1])
|
||||
}
|
||||
/// Grow the buffer so it covers a `size`-layer-pixel region starting at its offset (see
|
||||
/// [`cover_plan`]): the whole layer rectangle a sized source's buffer already is. Content
|
||||
/// keeps its place; the new area is transparent. No-op when the buffer already covers it.
|
||||
pub fn cover(&mut self, size: [f64; 2]) {
|
||||
let Some((dx, dy, w, h)) = cover_plan(self.offset, self.scale, size, self.img.width, self.img.height) else { return };
|
||||
let src = std::mem::replace(&mut self.img, Image::new(w, h));
|
||||
let (sw, sh) = (src.width as usize, src.height as usize);
|
||||
for y in 0..sh {
|
||||
let s = y * sw;
|
||||
let d = (y + dy as usize) * w as usize + dx as usize;
|
||||
self.img.data[d..d + sw].copy_from_slice(&src.data[s..s + sw]);
|
||||
}
|
||||
self.offset[0] += dx as f64;
|
||||
self.offset[1] += dy as f64;
|
||||
}
|
||||
/// Measure from `origin` (in the current coordinates): a point `p` becomes `p − origin`.
|
||||
/// Moves between layer space and effect space (the renderer's `effect_bounds`).
|
||||
pub fn rebase(&mut self, origin: [f64; 2]) {
|
||||
|
|
@ -282,6 +297,27 @@ impl Buf {
|
|||
}
|
||||
}
|
||||
|
||||
/// Where [`Buf::cover`] would take a buffer: shift its pixels by `(dx, dy)` and resize it to
|
||||
/// `width` × `height` so it spans the `size`-layer-pixel region that starts at its offset — the
|
||||
/// whole layer rectangle, which a sized source's buffer already is. `None` when the buffer
|
||||
/// already covers that region unchanged (footage, solids, the comp below an adjustment).
|
||||
pub fn cover_plan(offset: [f64; 2], scale: f64, size: [f64; 2], width: u32, height: u32) -> Option<(u32, u32, u32, u32)> {
|
||||
if scale <= 0.0 || !scale.is_finite() || !offset[0].is_finite() || !offset[1].is_finite() || !size[0].is_finite() || !size[1].is_finite() {
|
||||
return None;
|
||||
}
|
||||
// Region and content, both as pixel ranges in the buffer's own coordinates.
|
||||
let (rw, rh) = (offset[0] + (size[0] * scale).ceil(), offset[1] + (size[1] * scale).ceil());
|
||||
let (lo_x, lo_y) = (offset[0].min(0.0).floor(), offset[1].min(0.0).floor());
|
||||
let (hi_x, hi_y) = (rw.max(width as f64).ceil(), rh.max(height as f64).ceil());
|
||||
let (nw_f, nh_f) = (hi_x - lo_x, hi_y - lo_y);
|
||||
if !(nw_f <= 16384.0 && nh_f <= 16384.0) {
|
||||
return None;
|
||||
}
|
||||
let (dx, dy) = ((-lo_x) as u32, (-lo_y) as u32);
|
||||
let (nw, nh) = (nw_f.round().max(1.0) as u32, nh_f.round().max(1.0) as u32);
|
||||
(dx != 0 || dy != 0 || nw != width || nh != height).then_some((dx, dy, nw, nh))
|
||||
}
|
||||
|
||||
/// A layer mask at the current time, flattened to a polyline in layer coordinates.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct MaskShape {
|
||||
|
|
|
|||
|
|
@ -429,7 +429,8 @@ pub fn overflow(v: f32, mode: u32) -> f32 {
|
|||
}
|
||||
|
||||
/// Composite grey noise `val` over the original pixel by Blending Mode, Opacity and (ours) Blend
|
||||
/// With Original.
|
||||
/// With Original. The noise stays inside the layer's alpha, so a masked solid shows it only within
|
||||
/// its masks (#939).
|
||||
pub(crate) fn composite(orig: [f32; 4], val: f32, opacity: f32, mode: u32, blend_orig: f32) -> [f32; 4] {
|
||||
let src = [val * opacity, val * opacity, val * opacity, opacity];
|
||||
let out = match mode {
|
||||
|
|
@ -440,7 +441,7 @@ pub(crate) fn composite(orig: [f32; 4], val: f32, opacity: f32, mode: u32, blend
|
|||
}
|
||||
};
|
||||
let k = 1.0 - blend_orig;
|
||||
[0, 1, 2, 3].map(|c| orig[c] * blend_orig + out[c] * k)
|
||||
[0, 1, 2, 3].map(|c| orig[c] * blend_orig + out[c] * orig[3] * k)
|
||||
}
|
||||
|
||||
fn render(ctx: &EffectCtx, mut b: Buf, turbulent: bool) -> Buf {
|
||||
|
|
@ -574,6 +575,28 @@ mod tests {
|
|||
assert!(mul.data.iter().any(|p| p[2] > p[0] + 0.01));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noise_stays_inside_the_layer_alpha() {
|
||||
// #939: a masked solid (transparent outside the mask, a soft edge) filled the whole layer.
|
||||
let mut img = Image::filled(40, 24, [0.2, 0.4, 0.6, 1.0]);
|
||||
for (i, p) in img.data.iter_mut().enumerate() {
|
||||
let a = match i % 40 {
|
||||
0..10 => 0.0,
|
||||
10..20 => 0.5,
|
||||
_ => 1.0,
|
||||
};
|
||||
*p = p.map(|c| c * a);
|
||||
}
|
||||
for mode in [BLEND_NONE, BLEND_NORMAL, 3] {
|
||||
let out = run_fx("ec.noise.fractal", &[("blendingMode", Value::Enum(mode))], img.clone(), 0.0, EffectEnv::default()).img;
|
||||
for (o, p) in img.data.iter().zip(&out.data) {
|
||||
assert!((o[3] - p[3]).abs() < 1e-6, "mode {mode}: alpha {} became {}", o[3], p[3]);
|
||||
assert!(p[..3].iter().all(|&c| c <= p[3] + 1e-6), "mode {mode}: premultiplied colour above alpha");
|
||||
}
|
||||
assert!(out.data.iter().any(|p| p[3] == 1.0 && (p[0] - 0.2).abs() > 0.01), "mode {mode}: noise drawn inside the mask");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sub_settings_and_fractal_types_change_the_result() {
|
||||
let base = run("ec.noise.fractal", &[]);
|
||||
|
|
|
|||
|
|
@ -219,28 +219,63 @@ fn cylinder(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|||
let (_, _, w, h) = rect;
|
||||
let r = (ctx.params.f("radius") / 100.0 * w / std::f64::consts::TAU).max(0.5);
|
||||
let c = b.to_px(ctx.params.v2("position"));
|
||||
// Keep the pre-existing Rotation control as an additional texture spin.
|
||||
let spin = ctx.params.f("rotation").to_radians();
|
||||
let (rx, ry, rz) = (ctx.params.f("rotationX"), ctx.params.f("rotationY"), ctx.params.f("rotationZ"));
|
||||
let tilted = rx != 0.0 || ry != 0.0 || rz != 0.0;
|
||||
let m = rot_xyz(rx, ry, rz);
|
||||
// The cylinder's local Y axis after rotation, in viewer coordinates.
|
||||
let axis = [m[0][1], m[1][1], m[2][1]];
|
||||
let a = 1.0 - axis[2] * axis[2];
|
||||
let mode = ctx.params.e("render");
|
||||
let sh = Shading::from(ctx);
|
||||
let src = b.img.clone();
|
||||
b.img.rows_mut().for_each(|(y, row)| {
|
||||
let v = (y as f64 + 0.5 - c.1) / h + 0.5;
|
||||
let dy = y as f64 + 0.5 - c.1;
|
||||
for (x, px) in row.iter_mut().enumerate() {
|
||||
let s = (x as f64 + 0.5 - c.0) / r;
|
||||
if s.abs() >= 1.0 {
|
||||
let dx = x as f64 + 0.5 - c.0;
|
||||
if !tilted {
|
||||
// Preserve exactly the mapping of projects saved before axis rotations.
|
||||
let v = dy / h + 0.5;
|
||||
let s = dx / r;
|
||||
if s.abs() >= 1.0 {
|
||||
*px = [0.0; 4];
|
||||
continue;
|
||||
}
|
||||
let th = s.asin();
|
||||
let cz = th.cos();
|
||||
let front = || sh.shade(texel(&src, rect, (th + spin) / std::f64::consts::TAU + 0.5, v), [s, 0.0, cz]);
|
||||
let back = || sh.shade(texel(&src, rect, (std::f64::consts::PI - th + spin) / std::f64::consts::TAU + 0.5, v), [-s, 0.0, cz]);
|
||||
*px = match mode {
|
||||
1 => front(),
|
||||
2 => back(),
|
||||
_ => over(front(), back()),
|
||||
};
|
||||
continue;
|
||||
}
|
||||
// Intersect the viewing ray (dx,dy,z) with the rotated cylindrical
|
||||
// side wall: |q - dot(q,axis)*axis|^2 = radius^2.
|
||||
let da = dx * axis[0] + dy * axis[1];
|
||||
let bq = -da * axis[2];
|
||||
let discriminant = bq * bq - a * (dx * dx + dy * dy - da * da - r * r);
|
||||
if a <= 1e-10 || !discriminant.is_finite() || discriminant <= 0.0 {
|
||||
*px = [0.0; 4];
|
||||
continue;
|
||||
}
|
||||
let th = s.asin();
|
||||
let cz = th.cos();
|
||||
let front = || {
|
||||
let u = (th + spin) / std::f64::consts::TAU + 0.5;
|
||||
sh.shade(texel(&src, rect, u, v), [s, 0.0, cz])
|
||||
};
|
||||
let back = || {
|
||||
let u = (std::f64::consts::PI - th + spin) / std::f64::consts::TAU + 0.5;
|
||||
sh.shade(texel(&src, rect, u, v), [-s, 0.0, cz])
|
||||
let root = discriminant.sqrt();
|
||||
let face = |z: f64, back: bool| {
|
||||
// Rᵀ turns the intersection into cylinder-local coordinates.
|
||||
let local = [m[0][0] * dx + m[1][0] * dy + m[2][0] * z, m[0][1] * dx + m[1][1] * dy + m[2][1] * z, m[0][2] * dx + m[1][2] * dy + m[2][2] * z];
|
||||
let u = (local[0].atan2(local[2]) + spin) / std::f64::consts::TAU + 0.5;
|
||||
let v = local[1] / h + 0.5;
|
||||
let sign = if back { -1.0 } else { 1.0 };
|
||||
let nx = local[0] / r * sign;
|
||||
let nz = local[2] / r * sign;
|
||||
let normal = [m[0][0] * nx + m[0][2] * nz, m[1][0] * nx + m[1][2] * nz, m[2][0] * nx + m[2][2] * nz];
|
||||
sh.shade(texel(&src, rect, u, v), normal)
|
||||
};
|
||||
let front = || face((-bq + root) / a, false);
|
||||
let back = || face((-bq - root) / a, true);
|
||||
*px = match mode {
|
||||
1 => front(),
|
||||
2 => back(),
|
||||
|
|
@ -362,6 +397,9 @@ pub fn specs() -> Vec<EffectSpec> {
|
|||
p("radius", "Radius", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)),
|
||||
p("position", "Position", pt(0.5, 0.5), ParamUi::Point),
|
||||
p("rotation", "Rotation", num(0.0), ParamUi::Angle),
|
||||
p("rotationX", "Rotation X", num(0.0), ParamUi::Angle),
|
||||
p("rotationY", "Rotation Y", num(0.0), ParamUi::Angle),
|
||||
p("rotationZ", "Rotation Z", num(0.0), ParamUi::Angle),
|
||||
p("render", "Render", Value::Enum(0), render()),
|
||||
];
|
||||
cyl.extend(shading());
|
||||
|
|
@ -527,6 +565,31 @@ mod tests {
|
|||
assert_eq!(out.get(50 + 18, 10)[3], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_axis_rotations_affect_geometry_and_texture() {
|
||||
let img = Image::filled(100, 100, [0.5, 0.5, 0.5, 1.0]);
|
||||
let default = run("ec.perspective.cccylinder", &img, &[]).img;
|
||||
let x_tilt = run("ec.perspective.cccylinder", &img, &[("rotationX", num(70.0))]).img;
|
||||
// Tilting the finite cylinder shortens its vertical projection.
|
||||
assert!(default.get(50, 10)[3] > 0.99);
|
||||
assert_eq!(x_tilt.get(50, 10)[3], 0.0);
|
||||
assert!(x_tilt.get(50, 50)[3] > 0.99);
|
||||
let z_roll = run("ec.perspective.cccylinder", &img, &[("rotationZ", num(90.0))]).img;
|
||||
// A quarter turn moves the side wall from vertical to horizontal.
|
||||
assert_eq!(default.get(5, 50)[3], 0.0);
|
||||
assert!(z_roll.get(5, 50)[3] > 0.99);
|
||||
|
||||
let mut striped = Image::filled(100, 100, [0.0, 0.0, 0.0, 1.0]);
|
||||
for y in 0..100 {
|
||||
for x in 0..100 {
|
||||
striped.set(x, y, [x as f32 / 100.0, 0.2, 0.3, 1.0]);
|
||||
}
|
||||
}
|
||||
let baseline = run("ec.perspective.cccylinder", &striped, &[]).img;
|
||||
let spun = run("ec.perspective.cccylinder", &striped, &[("rotationY", num(90.0))]).img;
|
||||
assert!((baseline.get(50, 50)[0] - spun.get(50, 50)[0]).abs() > 0.05);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spotlight_darkens_outside_cone() {
|
||||
let img = Image::filled(80, 80, [1.0, 1.0, 1.0, 1.0]);
|
||||
|
|
|
|||
|
|
@ -583,6 +583,13 @@ fn smooth_flow(flow: &mut Flow, pm: &PixelMotion) {
|
|||
|
||||
/// The motion field from frame `a` to frame `b` (or from the warp layer's frames).
|
||||
pub fn timewarp_flow(a: &Image, b: &Image, pm: &PixelMotion) -> Flow {
|
||||
timewarp_flow_search(a, b, pm, 4)
|
||||
}
|
||||
|
||||
/// [`timewarp_flow`] with an explicit coarse search radius: Timewarp's own resampling needs
|
||||
/// little (motion between adjacent source frames at near-100% speed), but Pixel Motion Blur
|
||||
/// must find fast full-frame displacements (#535).
|
||||
fn timewarp_flow_search(a: &Image, b: &Image, pm: &PixelMotion, radius: i32) -> Flow {
|
||||
let ga = motion_grey(&crop_edges(a, pm.crops), pm.weights, 1.0);
|
||||
let gain = if pm.correct_luminance {
|
||||
let (ma, mb) = (mean_grey(&ga), mean_grey(&motion_grey(b, pm.weights, 1.0)));
|
||||
|
|
@ -591,7 +598,7 @@ pub fn timewarp_flow(a: &Image, b: &Image, pm: &PixelMotion) -> Flow {
|
|||
1.0
|
||||
};
|
||||
let gb = motion_grey(&crop_edges(b, pm.crops), pm.weights, gain);
|
||||
let mut flow = effectcraft_raster::flow::block_flow_window(&ga, &gb, pm.tile, pm.window, 4);
|
||||
let mut flow = effectcraft_raster::flow::block_flow_window(&ga, &gb, pm.tile, pm.window, radius);
|
||||
smooth_flow(&mut flow, pm);
|
||||
flow
|
||||
}
|
||||
|
|
@ -923,12 +930,71 @@ fn wide_time(ctx: &EffectCtx, b: Buf) -> Buf {
|
|||
mean_of(ctx, b, ×)
|
||||
}
|
||||
|
||||
fn pixel_motion_blur(ctx: &EffectCtx, b: Buf) -> Buf {
|
||||
fn shutter_times(ctx: &EffectCtx) -> Vec<f64> {
|
||||
let angle = if ctx.params.e("shutterControl") == 1 { ctx.params.f("shutterAngle") } else { 180.0 };
|
||||
let n = ctx.params.f("shutterSamples").round().clamp(1.0, 64.0) as usize;
|
||||
let span = angle.clamp(0.0, 720.0) / 360.0 / ctx.fps();
|
||||
let times: Vec<f64> = (0..n).map(|i| ctx.time - span * 0.5 + if n > 1 { span * i as f64 / (n - 1) as f64 } else { span * 0.5 }).collect();
|
||||
mean_of(ctx, b, ×)
|
||||
(0..n).map(|i| ctx.time - span * 0.5 + if n > 1 { span * i as f64 / (n - 1) as f64 } else { span * 0.5 }).collect()
|
||||
}
|
||||
|
||||
fn pixel_motion_blur(ctx: &EffectCtx, b: Buf) -> Buf {
|
||||
match pmb_plan(ctx).as_ref().map(TimewarpPlan::render_cpu) {
|
||||
Some(blur) => blur,
|
||||
None => mean_of(ctx, b, &shutter_times(ctx)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Pixel Motion Blur as a [`TimewarpPlan`]: each shutter instant is decoded between the two
|
||||
/// source frames it falls between along a measured motion field (the same synthesis Timewarp
|
||||
/// uses), and the instants average into the blur. Vector Detail sets the spacing of the motion
|
||||
/// vectors, so a coarse grid follows one big motion and a fine grid separates neighbouring
|
||||
/// ones (#535). `None` without a host — the caller then falls back. Shared by the CPU effect
|
||||
/// and the GPU compositor (`fx_time`), which renders the plan like Timewarp's.
|
||||
pub fn pmb_plan(ctx: &EffectCtx) -> Option<TimewarpPlan> {
|
||||
ctx.env.host?;
|
||||
let fps = ctx.fps();
|
||||
let samples = shutter_times(ctx);
|
||||
let n = samples.len();
|
||||
let mut times = Vec::new();
|
||||
for &s in &samples {
|
||||
let (f0, _) = tw_split(s, fps);
|
||||
times.push(f0 / fps);
|
||||
times.push((f0 + 1.0) / fps);
|
||||
}
|
||||
let fr = fetch(ctx, ×)?;
|
||||
let idx = |t: f64| times.iter().position(|q| (q - t).abs() < 1e-9).unwrap_or(0);
|
||||
let mut pm = PixelMotion::from(ctx, fr.grid.scale);
|
||||
// PMB's own Vector Detail: 0 → one vector per 32 px, 100 → one per 2 px.
|
||||
let detail = (ctx.params.f("vectorDetail") / 100.0).clamp(0.0, 1.0);
|
||||
pm.tile = (32.0 * (1.0f64 / 16.0).powf(detail) * fr.grid.scale).round().max(2.0) as u32;
|
||||
let mut flows: Vec<FlowEntry> = vec![];
|
||||
let mut steps = vec![];
|
||||
let w = 1.0 / n as f32;
|
||||
for &s in &samples {
|
||||
let (f0, frac) = tw_split(s, fps);
|
||||
let (i0, i1) = (idx(f0 / fps), idx((f0 + 1.0) / fps));
|
||||
let step = if frac < 1e-4 {
|
||||
TwStep::Whole(i0)
|
||||
} else {
|
||||
let flow = match flows.iter().position(|fl| fl.key == f0 as i64) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
let reach = (fr.grid.img.width.max(fr.grid.img.height) / 4).clamp(8, 64);
|
||||
flows.push(FlowEntry { key: f0 as i64, flow: timewarp_flow_search(&fr.imgs[i0], &fr.imgs[i1], &pm, reach as i32) });
|
||||
flows.len() - 1
|
||||
}
|
||||
};
|
||||
TwStep::Motion { a: i0, b: i1, w: frac, flow }
|
||||
};
|
||||
steps.push((step, w));
|
||||
}
|
||||
Some(TimewarpPlan { grid: fr.grid, frames: fr.imgs, steps, flows: flows.into_iter().map(|f| f.flow).collect(), pm })
|
||||
}
|
||||
|
||||
/// A measured flow with the source frame pair it came from (plans cache one per pair).
|
||||
struct FlowEntry {
|
||||
key: i64,
|
||||
flow: Flow,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,255 @@
|
|||
{
|
||||
"format": "effectcraft.animationPresets/1",
|
||||
"notes": "Original EffectCraft transition presets (MIT OR Apache-2.0), written from scratch on EffectCraft's own Transition effects. Key times are seconds from the current time (layer time); effect paths are the effect's parameter ids; layer paths are property paths such as transform/opacity.",
|
||||
"presets": [
|
||||
{
|
||||
"id": "fadeIn",
|
||||
"name": "Fade In",
|
||||
"category": "Transitions",
|
||||
"direction": "in",
|
||||
"description": "The layer fades up from transparent over half a second.",
|
||||
"layer": { "keys": { "transform/opacity": [[0, 0], [0.5, 100]] }, "ease": true }
|
||||
},
|
||||
{
|
||||
"id": "fadeOut",
|
||||
"name": "Fade Out",
|
||||
"category": "Transitions",
|
||||
"direction": "out",
|
||||
"description": "The layer fades down to transparent over half a second.",
|
||||
"layer": { "keys": { "transform/opacity": [[0, 100], [0.5, 0]] }, "ease": true }
|
||||
},
|
||||
{
|
||||
"id": "wipeInFromLeft",
|
||||
"name": "Wipe In from Left",
|
||||
"category": "Transitions",
|
||||
"direction": "in",
|
||||
"description": "A soft edge sweeps across from the left and reveals the layer.",
|
||||
"effects": [
|
||||
{ "effect": "ec.transition.linearwipe", "set": { "angle": 270, "feather": 40 }, "keys": { "completion": [[0, 100], [1, 0]] }, "ease": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wipeInFromRight",
|
||||
"name": "Wipe In from Right",
|
||||
"category": "Transitions",
|
||||
"direction": "in",
|
||||
"description": "A soft edge sweeps across from the right and reveals the layer.",
|
||||
"effects": [
|
||||
{ "effect": "ec.transition.linearwipe", "set": { "angle": 90, "feather": 40 }, "keys": { "completion": [[0, 100], [1, 0]] }, "ease": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wipeInFromTop",
|
||||
"name": "Wipe In from Top",
|
||||
"category": "Transitions",
|
||||
"direction": "in",
|
||||
"description": "The layer is revealed from the top edge downwards.",
|
||||
"effects": [
|
||||
{ "effect": "ec.transition.linearwipe", "set": { "angle": 0, "feather": 30 }, "keys": { "completion": [[0, 100], [0.9, 0]] }, "ease": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wipeInFromBottom",
|
||||
"name": "Wipe In from Bottom",
|
||||
"category": "Transitions",
|
||||
"direction": "in",
|
||||
"description": "The layer is revealed from the bottom edge upwards.",
|
||||
"effects": [
|
||||
{ "effect": "ec.transition.linearwipe", "set": { "angle": 180, "feather": 30 }, "keys": { "completion": [[0, 100], [0.9, 0]] }, "ease": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "diagonalSweepIn",
|
||||
"name": "Diagonal Sweep In",
|
||||
"category": "Transitions",
|
||||
"direction": "in",
|
||||
"description": "A wide, very soft edge sweeps in from the bottom-left corner.",
|
||||
"effects": [
|
||||
{ "effect": "ec.transition.linearwipe", "set": { "angle": 45, "feather": 160 }, "keys": { "completion": [[0, 100], [1.2, 0]] }, "ease": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wipeOutToRight",
|
||||
"name": "Wipe Out to Right",
|
||||
"category": "Transitions",
|
||||
"direction": "out",
|
||||
"description": "A soft edge travels left to right and takes the layer away.",
|
||||
"effects": [
|
||||
{ "effect": "ec.transition.linearwipe", "set": { "angle": 90, "feather": 40 }, "keys": { "completion": [[0, 0], [1, 100]] }, "ease": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "clockReveal",
|
||||
"name": "Clock Reveal",
|
||||
"category": "Transitions",
|
||||
"direction": "in",
|
||||
"description": "A clock hand sweeps once around the centre and reveals the layer.",
|
||||
"effects": [
|
||||
{ "effect": "ec.transition.radialwipe", "set": { "wipe": 1, "feather": 6 }, "keys": { "completion": [[0, 100], [1.2, 0]] }, "ease": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "fanOpen",
|
||||
"name": "Fan Open",
|
||||
"category": "Transitions",
|
||||
"direction": "in",
|
||||
"description": "The layer opens like a fan, from the top down both sides.",
|
||||
"effects": [
|
||||
{ "effect": "ec.transition.radialwipe", "set": { "wipe": 2, "startAngle": 180, "feather": 10 }, "keys": { "completion": [[0, 100], [1, 0]] }, "ease": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "clockWipeOut",
|
||||
"name": "Clock Wipe Out",
|
||||
"category": "Transitions",
|
||||
"direction": "out",
|
||||
"description": "A clock hand sweeps once around the centre and removes the layer.",
|
||||
"effects": [
|
||||
{ "effect": "ec.transition.radialwipe", "set": { "feather": 6 }, "keys": { "completion": [[0, 0], [1.2, 100]] }, "ease": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "blindsOpen",
|
||||
"name": "Blinds Open",
|
||||
"category": "Transitions",
|
||||
"direction": "in",
|
||||
"description": "Vertical slats widen until the layer is whole.",
|
||||
"effects": [
|
||||
{ "effect": "ec.transition.venetian", "set": { "direction": 0, "width": 60, "feather": 4 }, "keys": { "completion": [[0, 100], [0.9, 0]] }, "ease": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "slatsDown",
|
||||
"name": "Slats Down",
|
||||
"category": "Transitions",
|
||||
"direction": "in",
|
||||
"description": "Horizontal slats thicken until the layer is whole.",
|
||||
"effects": [
|
||||
{ "effect": "ec.transition.venetian", "set": { "direction": 90, "width": 36, "feather": 3 }, "keys": { "completion": [[0, 100], [0.8, 0]] } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "blindsClose",
|
||||
"name": "Blinds Close",
|
||||
"category": "Transitions",
|
||||
"direction": "out",
|
||||
"description": "Vertical slats narrow until the layer is gone.",
|
||||
"effects": [
|
||||
{ "effect": "ec.transition.venetian", "set": { "direction": 0, "width": 60, "feather": 4 }, "keys": { "completion": [[0, 0], [0.9, 100]] }, "ease": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pixelDissolveIn",
|
||||
"name": "Pixel Dissolve In",
|
||||
"category": "Transitions",
|
||||
"direction": "in",
|
||||
"description": "Small hard-edged blocks pop in at random until the layer is whole.",
|
||||
"effects": [
|
||||
{
|
||||
"effect": "ec.transition.blockdissolve",
|
||||
"set": { "blockWidth": 6, "blockHeight": 6, "softEdges": false },
|
||||
"keys": { "completion": [[0, 100], [1.2, 0]] }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mosaicFadeIn",
|
||||
"name": "Mosaic Fade In",
|
||||
"category": "Transitions",
|
||||
"direction": "in",
|
||||
"description": "Large soft tiles fade in at random.",
|
||||
"effects": [
|
||||
{
|
||||
"effect": "ec.transition.blockdissolve",
|
||||
"set": { "blockWidth": 48, "blockHeight": 48, "feather": 16 },
|
||||
"keys": { "completion": [[0, 100], [1.2, 0]] },
|
||||
"ease": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pixelDissolveOut",
|
||||
"name": "Pixel Dissolve Out",
|
||||
"category": "Transitions",
|
||||
"direction": "out",
|
||||
"description": "Small hard-edged blocks drop out at random until the layer is gone.",
|
||||
"effects": [
|
||||
{
|
||||
"effect": "ec.transition.blockdissolve",
|
||||
"set": { "blockWidth": 6, "blockHeight": 6, "softEdges": false },
|
||||
"keys": { "completion": [[0, 0], [1.2, 100]] }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "cardFlipIn",
|
||||
"name": "Card Flip In",
|
||||
"category": "Transitions",
|
||||
"direction": "in",
|
||||
"description": "A grid of cards turns over, left to right, to show the layer.",
|
||||
"effects": [
|
||||
{
|
||||
"effect": "ec.transition.cardwipe",
|
||||
"set": { "rows": 4, "columns": 8, "flipAxis": 1, "flipOrder": 1, "transitionWidth": 70 },
|
||||
"keys": { "completion": [[0, 100], [1.4, 0]] },
|
||||
"ease": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "cardFlipOut",
|
||||
"name": "Card Flip Out",
|
||||
"category": "Transitions",
|
||||
"direction": "out",
|
||||
"description": "A grid of cards turns over, left to right, and the layer is gone.",
|
||||
"effects": [
|
||||
{
|
||||
"effect": "ec.transition.cardwipe",
|
||||
"set": { "rows": 4, "columns": 8, "flipAxis": 1, "transitionWidth": 70 },
|
||||
"keys": { "completion": [[0, 0], [1.4, 100]] },
|
||||
"ease": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "gridDoorsIn",
|
||||
"name": "Grid Doors In",
|
||||
"category": "Transitions",
|
||||
"direction": "in",
|
||||
"description": "Tiles open like small doors from the centre outwards.",
|
||||
"effects": [
|
||||
{ "effect": "ec.transition.ccgridwipe", "set": { "shape": 0, "tiles": 12, "reverse": true }, "keys": { "completion": [[0, 100], [1, 0]] }, "ease": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "lensPopIn",
|
||||
"name": "Lens Pop In",
|
||||
"category": "Transitions",
|
||||
"direction": "in",
|
||||
"description": "The layer grows out of a circle that stretches the picture outwards.",
|
||||
"effects": [
|
||||
{ "effect": "ec.transition.ccradialscalewipe", "set": { "reverse": true }, "keys": { "completion": [[0, 100], [1, 0]] }, "ease": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "lineSweepIn",
|
||||
"name": "Line Sweep In",
|
||||
"category": "Transitions",
|
||||
"direction": "in",
|
||||
"description": "A stepped, slanted edge sweeps in from the lower left.",
|
||||
"effects": [
|
||||
{ "effect": "ec.transition.cclinesweep", "set": { "direction": 90, "thickness": 30, "slant": 30 }, "keys": { "completion": [[0, 100], [1, 0]] } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "jawsCloseIn",
|
||||
"name": "Jaws Close In",
|
||||
"category": "Transitions",
|
||||
"direction": "in",
|
||||
"description": "Toothed edges close in from the top and bottom until the layer is whole.",
|
||||
"effects": [
|
||||
{ "effect": "ec.transition.ccjaws", "set": { "shape": 2, "height": 30, "width": 40 }, "keys": { "completion": [[0, 100], [1, 0]] }, "ease": true }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
asset: transitions.json
|
||||
title: EffectCraft Transitions animation presets (Fade In/Out, Wipe In from Left/Right/Top/Bottom, Diagonal Sweep In, Wipe Out to Right, Clock Reveal, Fan Open, Clock Wipe Out, Blinds Open/Close, Slats Down, Pixel Dissolve In/Out, Mosaic Fade In, Card Flip In/Out, Grid Doors In, Lens Pop In, Line Sweep In, Jaws Close In)
|
||||
author: EffectCraft contributors
|
||||
source: original work
|
||||
license: MIT OR Apache-2.0
|
||||
added: 2026-10-11 by vangel76 (Claude Code agent, #757)
|
||||
notes: Effect choices, values and key timings written from scratch for EffectCraft on its own Transition effects; not derived from any Adobe preset (.ffx) or its values.
|
||||
|
|
@ -0,0 +1,177 @@
|
|||
//! EffectCraft's own animation preset library (Effects & Presets ▸ * Animation Presets): the
|
||||
//! Transitions category, original presets built on our Transition effects and layer opacity.
|
||||
//!
|
||||
//! Each preset adds effects (with values and keyframes) and/or keys layer properties, starting
|
||||
//! at the current time, on every selected layer. The library is data
|
||||
//! (`presets/transitions.json`), written from scratch; nothing comes from After Effects' `.ffx`
|
||||
//! presets.
|
||||
|
||||
use effectcraft_keyframe::Keyframe;
|
||||
use effectcraft_project::build::Ids;
|
||||
use effectcraft_project::{Node, Property};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use super::text_anim::json_value;
|
||||
use super::{CommandSpec, bad, has_layers, layer_mut, layers_p, str_p};
|
||||
use crate::{EngineError, Result, Session, cmd, query};
|
||||
|
||||
/// The original transition presets (`presets/transitions.json`).
|
||||
pub const TRANSITIONS_JSON: &str = include_str!("../../presets/transitions.json");
|
||||
|
||||
/// Keys per animated property, so a hostile inline preset can't allocate without bound.
|
||||
const MAX_KEYS: usize = 256;
|
||||
|
||||
fn presets() -> Vec<Value> {
|
||||
serde_json::from_str::<Value>(TRANSITIONS_JSON).ok().and_then(|v| v.get("presets").and_then(Value::as_array).cloned()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn text<'a>(v: &'a Value, key: &str) -> Option<&'a str> {
|
||||
v.get(key).and_then(Value::as_str)
|
||||
}
|
||||
|
||||
/// (id, name, category) of every built-in animation preset, in library order.
|
||||
pub fn preset_names() -> Vec<(String, String, String)> {
|
||||
presets()
|
||||
.iter()
|
||||
.filter_map(|p| Some((text(p, "id")?.to_string(), text(p, "name")?.to_string(), text(p, "category").unwrap_or("Transitions").to_string())))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn find_preset(want: &str) -> Option<Value> {
|
||||
presets().into_iter().find(|x| text(x, "id") == Some(want) || text(x, "name").is_some_and(|n| n.eq_ignore_ascii_case(want)))
|
||||
}
|
||||
|
||||
/// Set `pr` to `v` (when given) and replace its keys with `keys` (`[[seconds, value], …]`,
|
||||
/// seconds from `at`). Returns false when nothing in the request fits the property.
|
||||
fn set_prop(pr: &mut Property, v: Option<&Value>, keys: Option<&Value>, at: Tick, ease: bool) -> bool {
|
||||
let mut ok = true;
|
||||
if let Some(v) = v {
|
||||
match json_value(v, &pr.value) {
|
||||
Some(val) => pr.value = val,
|
||||
None => ok = false,
|
||||
}
|
||||
}
|
||||
let Some(list) = keys else { return ok };
|
||||
let Some(list) = list.as_array() else { return false };
|
||||
let mut ks: Vec<Keyframe> = Vec::new();
|
||||
for k in list.iter().take(MAX_KEYS) {
|
||||
let t = k.get(0).and_then(Value::as_f64).filter(|t| t.is_finite() && t.abs() <= 86_400.0);
|
||||
let (Some(t), Some(val)) = (t, k.get(1).and_then(|x| json_value(x, &pr.value))) else { return false };
|
||||
let mut key = Keyframe::new(at + Tick::from_seconds_f64(t), val);
|
||||
if pr.hold_only {
|
||||
key = key.hold();
|
||||
} else if ease {
|
||||
key = key.eased();
|
||||
}
|
||||
ks.push(key);
|
||||
}
|
||||
ks.sort_by_key(|k| k.time);
|
||||
ks.dedup_by_key(|k| k.time);
|
||||
let Some(first) = ks.first() else { return false };
|
||||
pr.value = first.value.clone();
|
||||
pr.keys = ks;
|
||||
ok
|
||||
}
|
||||
|
||||
/// Apply `set` / `keys` maps to the properties under `root`; returns the paths that don't exist
|
||||
/// or don't take the given values.
|
||||
fn apply_maps(root: &mut effectcraft_project::PropGroup, spec: &Value, at: Tick) -> Vec<String> {
|
||||
let ease = spec.get("ease").and_then(Value::as_bool).unwrap_or(false);
|
||||
let empty = Map::new();
|
||||
let set = spec.get("set").and_then(Value::as_object).unwrap_or(&empty);
|
||||
let keys = spec.get("keys").and_then(Value::as_object).unwrap_or(&empty);
|
||||
let mut skipped = Vec::new();
|
||||
for (path, v) in set {
|
||||
if !root.prop_mut(path).is_some_and(|pr| set_prop(pr, Some(v), None, at, false)) {
|
||||
skipped.push(path.clone());
|
||||
}
|
||||
}
|
||||
for (path, list) in keys {
|
||||
if !root.prop_mut(path).is_some_and(|pr| !pr.static_only && set_prop(pr, None, Some(list), at, ease)) {
|
||||
skipped.push(path.clone());
|
||||
}
|
||||
}
|
||||
skipped
|
||||
}
|
||||
|
||||
/// `anim.applyBuiltinPreset`: add a built-in preset's effects and keyframes to the layers.
|
||||
fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "anim.applyBuiltinPreset";
|
||||
let want = str_p(p, "preset").ok_or_else(|| bad(C, "`preset` id or name required"))?;
|
||||
let preset = find_preset(want).ok_or_else(|| bad(C, format!("no animation preset `{want}`")))?;
|
||||
let name = text(&preset, "name").unwrap_or("Preset").to_string();
|
||||
let effects: Vec<(&'static effectcraft_effects::EffectSpec, Value)> = preset
|
||||
.get("effects")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|e| {
|
||||
let id = text(e, "effect").unwrap_or_default();
|
||||
effectcraft_effects::lookup(id).map(|spec| (spec, e.clone())).ok_or_else(|| bad(C, format!("preset `{name}` uses unknown effect `{id}`")))
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
let layer_spec = preset.get("layer").cloned();
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, "select a layer"));
|
||||
}
|
||||
let sizes: Vec<_> = {
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
ids.iter().filter_map(|id| comp.layer(*id)).map(|l| (l.id, effectcraft_render::effect_bounds(&s.project, comp, l).0)).collect()
|
||||
};
|
||||
let now = s.time();
|
||||
let (added, skipped) = s.edit(&format!("Apply Animation Preset {name}"), None, |proj, _| {
|
||||
let mut added: Vec<u64> = Vec::new();
|
||||
let mut skipped: Vec<String> = Vec::new();
|
||||
for (lid, size) in &sizes {
|
||||
let mut next = proj.next_id;
|
||||
let l = layer_mut(proj, cid, *lid)?;
|
||||
let at = l.layer_time(now);
|
||||
if !effects.is_empty() {
|
||||
let fx = l.props.sub_mut("effects").ok_or_else(|| bad(C, "this layer type has no effects"))?;
|
||||
for (spec, ej) in &effects {
|
||||
let same = fx.groups().filter(|g| g.match_id == spec.id).count();
|
||||
let inst = if same == 0 { spec.name.to_string() } else { format!("{} {}", spec.name, same.saturating_add(1)) };
|
||||
let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), &inst, *size);
|
||||
skipped.extend(apply_maps(&mut g, ej, at).into_iter().map(|path| format!("{}/{path}", spec.id)));
|
||||
added.push(g.uid);
|
||||
fx.children.push(Node::Group(g));
|
||||
}
|
||||
}
|
||||
if let Some(ls) = &layer_spec {
|
||||
skipped.extend(apply_maps(&mut l.props, ls, at));
|
||||
}
|
||||
proj.next_id = next;
|
||||
}
|
||||
Ok((added, skipped))
|
||||
})?;
|
||||
Ok(json!({"preset": text(&preset, "id"), "effects": added, "skipped": skipped}))
|
||||
}
|
||||
|
||||
/// `anim.builtinPresets`: the library, optionally one category.
|
||||
fn list(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cat = str_p(p, "category");
|
||||
Ok(Value::Array(
|
||||
presets()
|
||||
.iter()
|
||||
.filter(|x| cat.is_none_or(|c| text(x, "category").is_some_and(|k| k.eq_ignore_ascii_case(c))))
|
||||
.map(|x| {
|
||||
json!({
|
||||
"id": x.get("id"),
|
||||
"name": x.get("name"),
|
||||
"category": x.get("category"),
|
||||
"direction": x.get("direction"),
|
||||
"description": x.get("description"),
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("anim.applyBuiltinPreset", "Apply Built-in Animation Preset", [], None, "{preset: id|name (see anim.builtinPresets), layers?}", has_layers, apply),
|
||||
query!("anim.builtinPresets", "Built-in Animation Presets", "{category?}", list),
|
||||
]
|
||||
}
|
||||
|
|
@ -5,7 +5,8 @@
|
|||
use std::collections::BTreeMap;
|
||||
|
||||
use effectcraft_keyframe::{Interp, Keyframe, Value as KV};
|
||||
use effectcraft_project::{GroupKind, LayerId, Node, PropGroup, Property, Uid};
|
||||
use effectcraft_project::build::{self, Ids};
|
||||
use effectcraft_project::{GroupKind, Layer, LayerId, LayerSource, Node, Project, PropGroup, Property, Uid};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
|
|
@ -293,6 +294,9 @@ fn modified(pr: &Property, group: &str, default_anchor: [f64; 2], comp_center: [
|
|||
match (group, pr.match_id.as_str()) {
|
||||
("transform", "anchor") => !near(&v.as_vec2(), &default_anchor),
|
||||
("transform", "position") => !near(&v.as_vec2(), &comp_center) || v.as_vec3()[2].abs() > 1e-6,
|
||||
// Separate Dimensions: X and Y Position default to the comp centre.
|
||||
("transform", "positionX") => (v.as_f64() - comp_center[0]).abs() > 1e-6,
|
||||
("transform", "positionY") => (v.as_f64() - comp_center[1]).abs() > 1e-6,
|
||||
("transform", "scale") => !near(&v.as_vec3(), &[100.0; 3]),
|
||||
("transform", "opacity") => (v.as_f64() - 100.0).abs() > 1e-6,
|
||||
("transform", "orientation") => !near(&v.as_vec3(), &[0.0; 3]),
|
||||
|
|
@ -301,6 +305,50 @@ fn modified(pr: &Property, group: &str, default_anchor: [f64; 2], comp_center: [
|
|||
}
|
||||
}
|
||||
|
||||
/// The group a fresh layer of `l`'s kind (`fresh`) has in place of `top`, for the groups whose
|
||||
/// defaults are fixed: Audio, Material / Geometry / Camera / Light Options, a text layer's Path
|
||||
/// and More Options, and a camera's or light's Transform (whose defaults aren't a 2D layer's).
|
||||
fn default_group(top: &PropGroup, l: &Layer, fresh: &Layer) -> Option<PropGroup> {
|
||||
let mut next = 0;
|
||||
let mut ids = Ids(&mut next);
|
||||
let camera_or_light = matches!(l.source, LayerSource::Camera | LayerSource::Light { .. });
|
||||
let mut g = match top.match_id.as_str() {
|
||||
"transform" if !camera_or_light => return None,
|
||||
// Added to a layer after it is made (a source with audio, an Advanced 3D comp).
|
||||
"audio" => build::audio(&mut ids),
|
||||
"geometryOptions" if matches!(l.source, LayerSource::Text | LayerSource::Shape) => build::extrusion_geometry_options(&mut ids),
|
||||
"transform" | "cameraOptions" | "lightOptions" | "materialOptions" | "geometryOptions" | "text" => fresh.props.sub(&top.match_id)?.clone(),
|
||||
_ => return None,
|
||||
};
|
||||
match top.match_id.as_str() {
|
||||
// Source Text and the animators are the layer's content, not options with a default.
|
||||
"text" => g.children.retain(|c| matches!(c.match_id(), "pathOptions" | "moreOptions")),
|
||||
// A model's scale and clip come from its file.
|
||||
"geometryOptions" if matches!(l.source, LayerSource::Model { .. }) => g.children.retain(|c| !matches!(c.match_id(), "unitScale" | "animation")),
|
||||
_ => {}
|
||||
}
|
||||
Some(g)
|
||||
}
|
||||
|
||||
/// Static properties of `g` whose value differs from the same property of `default` (matched by
|
||||
/// match id; properties `default` lacks have no known default and are left out).
|
||||
fn changed_from(g: &PropGroup, default: &PropGroup, out: &mut Vec<Uid>) {
|
||||
for c in &g.children {
|
||||
match c {
|
||||
Node::Prop(pr) => {
|
||||
if default.get(&pr.match_id).is_some_and(|d| d.value != pr.value) && !out.contains(&pr.uid) {
|
||||
out.push(pr.uid);
|
||||
}
|
||||
}
|
||||
Node::Group(sg) => {
|
||||
if let Some(d) = default.sub(&sg.match_id) {
|
||||
changed_from(sg, d, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn reveal(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let kind = str_p(p, "kind").unwrap_or("keyframes").to_string();
|
||||
let cid = super::comp_id(s, p)?;
|
||||
|
|
@ -312,10 +360,27 @@ fn reveal(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let (w, h) = effectcraft_render::source_size(&s.project, l);
|
||||
let anchor = [w as f64 / 2.0, h as f64 / 2.0];
|
||||
let center = [comp.width as f64 / 2.0, comp.height as f64 / 2.0];
|
||||
// Properties at the top of the layer's tree (Time Remap): enabled time remapping is a
|
||||
// modification in itself.
|
||||
for pr in l.props.props() {
|
||||
let hit = match kind.as_str() {
|
||||
"keyframes" => pr.is_animated(),
|
||||
"animation" => pr.is_animated() || pr.has_expression(),
|
||||
_ => pr.match_id == "timeRemap" || modified(pr, "", anchor, center),
|
||||
};
|
||||
if hit {
|
||||
out.push(json!({"layer": lid.0, "prop": pr.uid}));
|
||||
}
|
||||
}
|
||||
// Modified: a fresh layer of the same kind gives the defaults to compare against.
|
||||
let fresh = (kind == "modified").then(|| build::layer(&mut Project::default(), comp, "", l.source.clone(), (w, h), None));
|
||||
let camera_or_light = matches!(l.source, LayerSource::Camera | LayerSource::Light { .. });
|
||||
for top in l.props.groups() {
|
||||
let hit = |pr: &Property| match kind.as_str() {
|
||||
"keyframes" => pr.is_animated(),
|
||||
"animation" => pr.is_animated() || pr.has_expression(),
|
||||
// A camera's or light's Transform is compared with a fresh one's below.
|
||||
_ if camera_or_light && top.match_id == "transform" => pr.is_animated() || pr.expr.is_some(),
|
||||
_ => modified(pr, &top.match_id, anchor, center),
|
||||
};
|
||||
let mut found = vec![];
|
||||
|
|
@ -335,6 +400,23 @@ fn reveal(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
found.push(g.uid);
|
||||
}
|
||||
}
|
||||
if let Some(fresh) = &fresh {
|
||||
if let Some(default) = default_group(top, l, fresh) {
|
||||
changed_from(top, &default, &mut found);
|
||||
}
|
||||
// Every layer style counts in full, like an effect; so does each overridden
|
||||
// Essential Property.
|
||||
if top.match_id == effectcraft_project::styles::GROUP {
|
||||
found.extend(top.groups().filter(|g| g.match_id != effectcraft_project::styles::BLENDING).map(|g| g.uid));
|
||||
}
|
||||
if let GroupKind::Essential { overridden } = &top.kind {
|
||||
for u in overridden {
|
||||
if !found.contains(u) && (top.find(*u).is_some() || top.find_group(*u).is_some()) {
|
||||
found.push(*u);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.extend(found.into_iter().map(|u| json!({"layer": lid.0, "prop": u})));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@ fn clear_recent_presets(s: &mut Session, _: &Value) -> Result<Value> {
|
|||
|
||||
fn save_copy_xml(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = str_p(p, "path").ok_or_else(|| bad("file.saveCopyAsXml", "missing `path` (.ecprojx)"))?;
|
||||
let xml = crate::xml_project::to_xml(&s.project.to_file_json()?).map_err(EngineError::Other)?;
|
||||
let xml = crate::xml_project::to_xml(&s.project.to_file_json_with_view(&s.saved_view())?).map_err(EngineError::Other)?;
|
||||
s.services.write_file(path, xml.as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
|
||||
s.toast(format!("Saved an XML copy to {path}"));
|
||||
Ok(json!({"path": path, "bytes": xml.len()}))
|
||||
|
|
@ -194,7 +194,7 @@ fn execute_file(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
s.events.push(Event::Frontend { command: "app.confirmExecute".into(), params: json!({"path": path}) });
|
||||
return Ok(json!({"needsConfirmation": path}));
|
||||
}
|
||||
let url = format!("file://{}", path.replace(' ', "%20"));
|
||||
let url = super::layer_menu::file_url(path);
|
||||
s.events.push(Event::OpenUrl(url));
|
||||
Ok(json!({"opened": path}))
|
||||
}
|
||||
|
|
@ -288,7 +288,12 @@ fn enable_logging(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
}
|
||||
|
||||
fn reveal_log(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let file = crate::logging::current_file().unwrap_or_else(|| log_file(s));
|
||||
// Enable Logging's file while it is on, else this run's session log.
|
||||
let file = crate::logging::current_file()
|
||||
.filter(|_| crate::logging::is_enabled())
|
||||
.or_else(crate::logging::session_file)
|
||||
.or_else(crate::logging::current_file)
|
||||
.unwrap_or_else(|| log_file(s));
|
||||
let path = file.to_string_lossy().to_string();
|
||||
let url = super::layer_menu::folder_url(&path);
|
||||
s.events.push(Event::OpenUrl(url.clone()));
|
||||
|
|
@ -316,6 +321,8 @@ pub const IMPORT_FORMATS: &[&str] = &[
|
|||
"Opus",
|
||||
"glTF/GLB",
|
||||
"OBJ",
|
||||
"STL",
|
||||
"FBX",
|
||||
"Lottie JSON",
|
||||
];
|
||||
|
||||
|
|
@ -614,3 +621,25 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
query!("help.systemInfo", "System Information", "{}", |s, _| Ok(system_report(s))),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
/// `File.execute()` opens the file as a well-formed `file://` URL (spaces, `#`, `%`).
|
||||
#[test]
|
||||
fn execute_file_opens_an_encoded_file_url() {
|
||||
let dir = std::env::temp_dir().join(format!("ec-exec-url-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let f = dir.join("run #1 100%.txt");
|
||||
std::fs::write(&f, "x").unwrap();
|
||||
let mut s = crate::Session::default();
|
||||
s.execute("prefs.set", json!({"key": "scripting.allowScriptsWriteFiles", "value": true})).unwrap();
|
||||
s.drain_events();
|
||||
s.execute("file.executeFile", json!({"path": f.to_string_lossy(), "confirmed": true})).unwrap();
|
||||
let expected = super::super::layer_menu::file_url(&f.to_string_lossy());
|
||||
assert!(expected.ends_with("/run%20%231%20100%25.txt"), "{expected}");
|
||||
assert!(s.drain_events().iter().any(|e| matches!(e, crate::Event::OpenUrl(u) if *u == expected)));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -109,6 +109,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
label: "Run Commands (Batch)",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
alt_shortcuts: &[],
|
||||
params: "{steps: [{command, params?}], label? (undo step name, default Batch), atomic?: bool (default true: a failing step rolls the whole batch back)} → {steps, results: [each step's result]}; a string param \"$N\" or \"$N.key.0\" is step N's result (1-based), e.g. {\"layer\": \"$1.layer\"}",
|
||||
enabled: always,
|
||||
run: batch,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
//! Composition menu.
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_project::{Comp, ItemId, ItemKind, Marker};
|
||||
use effectcraft_project::{Comp, ItemId, ItemKind, Marker, limits};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
|
|
@ -15,7 +15,7 @@ pub(crate) fn rate_p(p: &Value) -> Option<FrameRate> {
|
|||
/// A zero, negative or tiny rate would make a zero-length frame (and divide by zero later).
|
||||
fn check_rate(p: &Value, cmd: &str) -> Result<()> {
|
||||
match f_p(p, "frameRate").or(f_p(p, "fps")) {
|
||||
Some(f) if !(0.001..=1000.0).contains(&f) => Err(bad(cmd, "frameRate: fps from 0.001 to 1000")),
|
||||
Some(f) if !(limits::MIN_FPS..=limits::MAX_FPS).contains(&f) => Err(bad(cmd, "frameRate: fps from 0.001 to 1000")),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
|
@ -23,10 +23,10 @@ fn check_rate(p: &Value, cmd: &str) -> Result<()> {
|
|||
fn apply_settings(c: &mut Comp, p: &Value) {
|
||||
let (ow, oh) = (c.width, c.height);
|
||||
if let Some(w) = p.get("width").and_then(Value::as_u64) {
|
||||
c.width = (w as u32).clamp(4, 30000);
|
||||
c.width = limits::size(w).max(4);
|
||||
}
|
||||
if let Some(h) = p.get("height").and_then(Value::as_u64) {
|
||||
c.height = (h as u32).clamp(4, 30000);
|
||||
c.height = limits::size(h).max(4);
|
||||
}
|
||||
// Advanced › Anchor: where the old frame sits in the resized one (0 = top left … 4 = center …
|
||||
// 8 = bottom right). Unparented layers (and all their position keys) move by that offset.
|
||||
|
|
@ -69,7 +69,7 @@ fn apply_settings(c: &mut Comp, p: &Value) {
|
|||
keep_work_area_ordered(c);
|
||||
}
|
||||
if let Some(pa) = f_p(p, "pixelAspect") {
|
||||
c.pixel_aspect = pa.max(0.1);
|
||||
c.pixel_aspect = limits::pixel_aspect(pa.max(limits::MIN_PIXEL_ASPECT));
|
||||
}
|
||||
if let Some(Value::Array(bg)) = p.get("background") {
|
||||
let g = |i: usize| bg.get(i).and_then(Value::as_f64).unwrap_or(0.0) as f32;
|
||||
|
|
@ -84,10 +84,10 @@ fn apply_settings(c: &mut Comp, p: &Value) {
|
|||
c.shutter_phase = a.clamp(-360.0, 360.0);
|
||||
}
|
||||
if let Some(n) = p.get("motionBlurSamples").and_then(Value::as_u64) {
|
||||
c.motion_blur_samples = (n as u32).clamp(2, 64);
|
||||
c.motion_blur_samples = n.clamp(limits::MOTION_BLUR_SAMPLES.0.into(), limits::MOTION_BLUR_SAMPLES.1.into()) as u32;
|
||||
}
|
||||
if let Some(n) = p.get("adaptiveSampleLimit").and_then(Value::as_u64) {
|
||||
c.motion_blur_adaptive_limit = (n as u32).clamp(16, 256);
|
||||
c.motion_blur_adaptive_limit = n.clamp(limits::ADAPTIVE_SAMPLE_LIMIT.0.into(), limits::ADAPTIVE_SAMPLE_LIMIT.1.into()) as u32;
|
||||
}
|
||||
if let Some(b) = p.get("preserveFrameRate").and_then(Value::as_bool) {
|
||||
c.preserve_frame_rate = b;
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
//! Composition menu, part two: crop to region of interest / layer bounds, Save Frame As ▸ File,
|
||||
//! Responsive Design — Time.
|
||||
//! Copy Frame to Clipboard, Responsive Design — Time.
|
||||
|
||||
use effectcraft_project::Marker;
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, bad, comp_id, f_p, has_comp, has_layers, layers_p, str_p};
|
||||
use crate::{EngineError, Result, Session, cmd};
|
||||
use super::{CommandSpec, always, bad, comp_id, f_p, has_comp, has_layers, layers_p, str_p};
|
||||
use crate::prefs::CompPreset;
|
||||
use crate::{EngineError, Result, Session, cmd, query};
|
||||
|
||||
fn has_roi(s: &Session) -> std::result::Result<(), String> {
|
||||
has_comp(s)?;
|
||||
|
|
@ -17,7 +18,8 @@ fn has_roi(s: &Session) -> std::result::Result<(), String> {
|
|||
/// (all position keys too) so nothing moves on screen.
|
||||
fn crop(s: &mut Session, cid: effectcraft_project::ItemId, r: [f64; 4], label: &str) -> Result<Value> {
|
||||
let (x, y) = (r[0].round(), r[1].round());
|
||||
let (w, h) = (r[2].round().max(1.0) as u32, r[3].round().max(1.0) as u32);
|
||||
// At most 30,000 px a side, like any composition (#788).
|
||||
let (w, h) = (effectcraft_project::limits::size_f64(r[2]), effectcraft_project::limits::size_f64(r[3]));
|
||||
s.edit(label, None, |proj, st| {
|
||||
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
c.width = w;
|
||||
|
|
@ -120,6 +122,26 @@ fn save_frame(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(json!({"path": path, "width": img.width, "height": img.height, "time": t.seconds()}))
|
||||
}
|
||||
|
||||
/// Composition ▸ Copy Frame to Clipboard (After Effects 26.3): render the frame as Save Frame As
|
||||
/// ▸ File does and hand it to the frontend, which puts it on the system clipboard.
|
||||
fn copy_frame(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = comp_id(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let t = f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or(s.time());
|
||||
let scale = f_p(p, "scale").unwrap_or(1.0).clamp(0.01, 4.0);
|
||||
let bg = comp.background;
|
||||
let img = s.render(cid, t, effectcraft_render::RenderOpts { scale, backend: effectcraft_render::Backend::Auto, ..Default::default() });
|
||||
let (width, height) = (img.width, img.height);
|
||||
let rgba = img.to_rgba8_over(bg);
|
||||
let expected = (width as usize).checked_mul(height as usize).and_then(|n| n.checked_mul(4));
|
||||
if width == 0 || height == 0 || expected != Some(rgba.len()) {
|
||||
return Err(EngineError::Other(format!("cannot copy an empty or malformed {width}×{height} frame")));
|
||||
}
|
||||
s.events.push(crate::Event::CopyImage { width, height, rgba });
|
||||
s.toast("Copied frame to the clipboard");
|
||||
Ok(json!({"width": width, "height": height, "time": t.seconds()}))
|
||||
}
|
||||
|
||||
/// Responsive Design — Time: protected comp markers for an intro, an outro or the work area.
|
||||
fn responsive_time(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = comp_id(s, p)?;
|
||||
|
|
@ -143,6 +165,65 @@ fn responsive_time(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(json!({"start": start.seconds(), "end": end.seconds()}))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- composition presets
|
||||
|
||||
/// `comp.savePreset`: remember a frame size, pixel aspect and frame rate under a name in the
|
||||
/// settings (shown in Composition Settings ▸ Preset). A preset with the same name is replaced.
|
||||
fn save_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "comp.savePreset";
|
||||
let name = str_p(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(C, "missing `name`"))?;
|
||||
let num = |k: &str| f_p(p, k).ok_or_else(|| bad(C, format!("missing `{k}`")));
|
||||
let dim = |k: &str| {
|
||||
num(k).and_then(|v| {
|
||||
if v.fract() == 0.0 && (0.0..=f64::from(u32::MAX)).contains(&v) { Ok(v as u32) } else { Err(bad(C, format!("`{k}` must be a whole number"))) }
|
||||
})
|
||||
};
|
||||
let preset =
|
||||
CompPreset { name: name.to_string(), width: dim("width")?, height: dim("height")?, pixel_aspect: num("pixelAspect")?, frame_rate: num("frameRate")? };
|
||||
if !preset.is_valid() {
|
||||
return Err(bad(
|
||||
C,
|
||||
format!(
|
||||
"not a valid preset: width and height 4–30000 px, pixel aspect 0.01–100, frame rate 1–999, a name of at most {} characters",
|
||||
CompPreset::MAX_NAME
|
||||
),
|
||||
));
|
||||
}
|
||||
let presets = &mut s.prefs.comp_presets;
|
||||
let replaced = match presets.iter_mut().find(|q| q.name == preset.name) {
|
||||
Some(q) => {
|
||||
*q = preset;
|
||||
true
|
||||
}
|
||||
None => {
|
||||
presets.push(preset);
|
||||
false
|
||||
}
|
||||
};
|
||||
s.save_prefs();
|
||||
s.prefs_revision += 1;
|
||||
Ok(json!({"name": name, "replaced": replaced}))
|
||||
}
|
||||
|
||||
/// `comp.deletePreset`: forget a saved composition preset.
|
||||
fn delete_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "comp.deletePreset";
|
||||
let name = str_p(p, "name").ok_or_else(|| bad(C, "missing `name`"))?;
|
||||
let before = s.prefs.comp_presets.len();
|
||||
s.prefs.comp_presets.retain(|q| q.name != name);
|
||||
if s.prefs.comp_presets.len() == before {
|
||||
return Err(bad(C, format!("no saved preset named `{name}`")));
|
||||
}
|
||||
s.save_prefs();
|
||||
s.prefs_revision += 1;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// `comp.presets`: the saved composition presets.
|
||||
fn list_presets(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
serde_json::to_value(&s.prefs.comp_presets).map_err(|e| bad("comp.presets", e.to_string()))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("comp.cropToRegionOfInterest", "Crop Comp to Region of Interest", ["Composition"], None, "{comp?}", has_roi, crop_to_roi),
|
||||
|
|
@ -156,6 +237,18 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
has_comp,
|
||||
save_frame
|
||||
),
|
||||
cmd!("comp.copyFrame", "Copy Frame to Clipboard", ["Composition"], None, "{comp?, time?, scale?}", has_comp, copy_frame),
|
||||
cmd!(
|
||||
"comp.savePreset",
|
||||
"Save Composition Preset",
|
||||
[],
|
||||
None,
|
||||
"{name, width, height, pixelAspect, frameRate} (replaces a saved preset with the same name)",
|
||||
always,
|
||||
save_preset
|
||||
),
|
||||
cmd!("comp.deletePreset", "Delete Composition Preset", [], None, "{name}", always, delete_preset),
|
||||
query!("comp.presets", "Composition Presets", "{}", list_presets),
|
||||
cmd!("comp.responsiveTime", "Responsive Design — Time", [], None, "{op: intro|outro|workArea}", has_comp, responsive_time),
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -405,6 +405,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
label: "Generate Fill Layer",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
alt_shortcuts: &[],
|
||||
params: P,
|
||||
enabled: has_layers,
|
||||
run: generate,
|
||||
|
|
@ -415,6 +416,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
label: "Content-Aware Fill Layer...",
|
||||
menu: &["Layer", "New"],
|
||||
shortcut: None,
|
||||
alt_shortcuts: &[],
|
||||
params: P,
|
||||
enabled: always,
|
||||
run: open_panel,
|
||||
|
|
|
|||
|
|
@ -531,6 +531,42 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(json!(new.iter().map(|l| l.0).collect::<Vec<_>>()))
|
||||
}
|
||||
|
||||
/// Largest SVG text `edit.pasteSvg` reads (a clipboard can hold anything).
|
||||
const MAX_SVG_PASTE: usize = 64 << 20;
|
||||
|
||||
/// `edit.pasteSvg`: SVG markup (the system clipboard's, copied from a browser, Illustrator or a
|
||||
/// design tool) as a new shape layer above the selected layer, its canvas centred in the comp:
|
||||
/// After Effects 26.3 pastes copied vector artwork as shape layers. Edit ▸ Paste runs it when
|
||||
/// the system clipboard holds SVG copied after anything copied in the app.
|
||||
fn paste_svg(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "edit.pasteSvg";
|
||||
let text = str_p(p, "svg").ok_or_else(|| super::bad(c, "missing `svg` (SVG markup)"))?;
|
||||
if text.len() > MAX_SVG_PASTE {
|
||||
return Err(super::bad(c, format!("the SVG is larger than {} MB", MAX_SVG_PASTE >> 20)));
|
||||
}
|
||||
if !crate::vector::is_svg_text(text) {
|
||||
return Err(super::bad(c, "the text is not SVG markup"));
|
||||
}
|
||||
let doc = effectcraft_svg::parse(text.as_bytes()).map_err(|e| super::bad(c, format!("cannot read the SVG: {e}")))?;
|
||||
if doc.flatten().is_empty() {
|
||||
return Err(super::bad(c, "the SVG has no shapes"));
|
||||
}
|
||||
let cid = super::comp_id(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
|
||||
let name = match str_p(p, "name") {
|
||||
Some(n) => n.to_string(),
|
||||
None => {
|
||||
let count = comp.layers.iter().filter(|l| matches!(l.source, LayerSource::Shape)).count();
|
||||
comp.unique_layer_name(&format!("Shape Layer {}", count.saturating_add(1)))
|
||||
}
|
||||
};
|
||||
let id = s.edit("Paste", None, |proj, st| {
|
||||
let l = crate::vector::shapes_from_svg(proj, &comp, &doc, &name);
|
||||
super::layer::insert_layer(proj, st, cid, l)
|
||||
})?;
|
||||
Ok(json!({"layer": id.0, "skipped": doc.skipped}))
|
||||
}
|
||||
|
||||
fn split(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
let t = s.time();
|
||||
|
|
@ -773,7 +809,7 @@ fn original_path(s: &Session) -> Option<String> {
|
|||
|
||||
fn edit_original(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let path = original_path(s).ok_or_else(|| super::bad("edit.editOriginal", "select a footage item or layer"))?;
|
||||
let url = format!("file://{path}");
|
||||
let url = super::layer_menu::file_url(&path);
|
||||
s.events.push(crate::Event::OpenUrl(url.clone()));
|
||||
Ok(json!({"url": url}))
|
||||
}
|
||||
|
|
@ -818,6 +854,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
has_clip,
|
||||
paste
|
||||
),
|
||||
cmd!(
|
||||
"edit.pasteSvg",
|
||||
"Paste SVG as Shapes",
|
||||
[],
|
||||
None,
|
||||
"{svg: SVG markup, name?, comp?} → {layer, skipped} (Edit ▸ Paste runs it for SVG on the system clipboard)",
|
||||
has_comp,
|
||||
paste_svg
|
||||
),
|
||||
cmd!("edit.pasteReversedKeyframes", "Paste Reversed Keyframes", ["Edit"], None, "{layers?, prop?|path?, time?}", has_key_clip, paste_reversed),
|
||||
cmd!("edit.clear", "Clear", ["Edit"], Some("Delete"), "{layers?}", layers_or_keys, delete),
|
||||
cmd!("edit.duplicate", "Duplicate", ["Edit"], Some("Cmd+D"), "{layers?} (effects or shape items when they are selected)", has_layers, duplicate),
|
||||
|
|
@ -893,3 +938,25 @@ fn layers_or_keys(s: &Session) -> std::result::Result<(), String> {
|
|||
fn always_ok(_: &Session) -> std::result::Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
/// Edit Original opens the footage as a well-formed `file://` URL, whatever its name contains.
|
||||
#[test]
|
||||
fn edit_original_opens_an_encoded_file_url() {
|
||||
let mut s = crate::Session::default();
|
||||
let id = s
|
||||
.edit("add", None, |p, _| {
|
||||
let f = effectcraft_project::Footage { path: "/clips/take #1.mov".into(), ..Default::default() };
|
||||
Ok(p.add_item("take", effectcraft_color::Label::Lavender, None, effectcraft_project::ItemKind::Footage(f)))
|
||||
})
|
||||
.unwrap();
|
||||
s.state.project_selection = vec![id];
|
||||
s.drain_events();
|
||||
let r = s.execute("edit.editOriginal", json!({})).unwrap();
|
||||
assert_eq!(r["url"], "file:///clips/take%20%231.mov");
|
||||
assert!(s.drain_events().iter().any(|e| matches!(e, crate::Event::OpenUrl(u) if u == "file:///clips/take%20%231.mov")));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,8 +43,18 @@ impl ExprError {
|
|||
pub fn errors(s: &Session, cid: ItemId, t: Tick) -> Vec<ExprError> {
|
||||
let Some(comp) = s.project.comp(cid) else { return vec![] };
|
||||
let ctx = EvalCtx { footage: Some(s.footage.as_ref()), expr: s.expr.as_deref(), ..EvalCtx::new(&s.project, cid, comp, t) };
|
||||
errors_in(&ctx, s.expr_check, || true).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Evaluate diagnostics against an immutable snapshot. Background callers can cancel between
|
||||
/// properties; cancelled scans return no partial result. Includes hidden/non-solo layers and
|
||||
/// syntax errors on disabled expressions, just like the explicit `expr.errors` query.
|
||||
pub fn errors_in(ctx: &EvalCtx, check: Option<fn(&str) -> std::result::Result<(), String>>, mut keep_going: impl FnMut() -> bool) -> Option<Vec<ExprError>> {
|
||||
let mut out = vec![];
|
||||
for (i, l) in comp.layers.iter().enumerate() {
|
||||
for (i, l) in ctx.comp.layers.iter().enumerate() {
|
||||
if !keep_going() {
|
||||
return None;
|
||||
}
|
||||
let mut props: Vec<(&Property, &Expression)> = vec![];
|
||||
l.props.walk("", &mut |_, p| {
|
||||
if let Some(e) = p.expr.as_ref().filter(|e| !e.text.trim().is_empty()) {
|
||||
|
|
@ -52,20 +62,23 @@ pub fn errors(s: &Session, cid: ItemId, t: Tick) -> Vec<ExprError> {
|
|||
}
|
||||
});
|
||||
for (p, e) in props {
|
||||
if !keep_going() {
|
||||
return None;
|
||||
}
|
||||
let msg = if e.enabled {
|
||||
match &ctx.expr {
|
||||
Some(h) => h.eval(&ctx, l, p, &p.value_at(l.layer_time(t))).err(),
|
||||
Some(h) => h.eval(ctx, l, p, &p.value_at(l.layer_time(ctx.time))).err(),
|
||||
None => None,
|
||||
}
|
||||
} else {
|
||||
s.expr_check.and_then(|check| check(&e.text).err())
|
||||
check.and_then(|check| check(&e.text).err())
|
||||
};
|
||||
if let Some(message) = msg {
|
||||
out.push(error_of(cid, i, l, p, message, !e.enabled));
|
||||
out.push(error_of(ctx.comp_id, i, l, p, message, !e.enabled));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
keep_going().then_some(out)
|
||||
}
|
||||
|
||||
fn error_of(cid: ItemId, i: usize, l: &Layer, p: &Property, message: String, disabled: bool) -> ExprError {
|
||||
|
|
|
|||
|
|
@ -43,10 +43,19 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let text = String::from_utf8(bytes).map_err(|_| EngineError::Other("not a text project file".into()))?;
|
||||
// XML copies (File ▸ Save a Copy As XML…) open like the JSON project.
|
||||
let text = if crate::xml_project::is_xml(&text) { crate::xml_project::from_xml(&text).map_err(EngineError::Other)? } else { text };
|
||||
let mut proj = Project::from_json(&text).map_err(|e| EngineError::Other(format!("{path} is not a project EffectCraft can open: {e}")))?;
|
||||
let (mut proj, repaired) =
|
||||
Project::from_json_repaired(&text).map_err(|e| EngineError::Other(format!("{path} is not a project EffectCraft can open: {e}")))?;
|
||||
resolve_relative_footage(&mut proj, path, s.services.as_ref());
|
||||
s.replace_project(proj, Some(path.to_string()));
|
||||
s.restore_view(&effectcraft_project::saved_view(&text));
|
||||
s.note_project_path(path);
|
||||
// Values past the limits (a corrupt or hostile file) were brought within them (#788).
|
||||
if !repaired.is_empty() {
|
||||
let more = repaired.len().saturating_sub(3);
|
||||
let shown = repaired.iter().take(3).cloned().collect::<Vec<_>>().join("; ");
|
||||
let more = if more > 0 { format!(" and {more} more") } else { String::new() };
|
||||
s.toast(format!("This project had values outside EffectCraft's limits, corrected when it opened: {shown}{more}."));
|
||||
}
|
||||
// Written by a newer EffectCraft: what this version doesn't know would be lost on saving.
|
||||
let saved_by = effectcraft_project::saved_by(&text);
|
||||
if let Some(v) = saved_by.as_deref().filter(|v| effectcraft_project::is_newer_version(v, effectcraft_project::APP_VERSION)) {
|
||||
|
|
@ -60,7 +69,7 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
if s.check_footage_on_open {
|
||||
crate::footage_check::start(s, None, false)?;
|
||||
}
|
||||
Ok(json!({"path": path, "savedBy": saved_by}))
|
||||
Ok(json!({"path": path, "savedBy": saved_by, "repaired": repaired}))
|
||||
}
|
||||
|
||||
/// Footage saved with a relative path (imported by the CLI or an agent as `clip.mp4`, or written
|
||||
|
|
@ -89,7 +98,7 @@ fn resolve_relative_footage(p: &mut Project, project_path: &str, services: &dyn
|
|||
/// project opened from an XML copy saves as XML again). Fails, writing nothing, when the project
|
||||
/// wouldn't read back.
|
||||
pub(crate) fn file_text(s: &Session, path: &str) -> Result<String> {
|
||||
let json = s.project.to_file_json()?;
|
||||
let json = s.project.to_file_json_with_view(&s.saved_view())?;
|
||||
if crate::xml_project::is_xml_path(path) { crate::xml_project::to_xml(&json).map_err(EngineError::Other) } else { Ok(json) }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_project::{AlphaMode, Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid};
|
||||
use effectcraft_project::{AlphaMode, Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid, limits};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
|
|
@ -84,12 +84,12 @@ fn save_copy(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
}
|
||||
|
||||
fn rate_of(p: &Value) -> FrameRate {
|
||||
f_p(p, "frameRate").map(FrameRate::from_f64).unwrap_or(FrameRate::FPS_29_97)
|
||||
f_p(p, "frameRate").map(|f| limits::frame_rate(FrameRate::from_f64(f), FrameRate::FPS_29_97)).unwrap_or(FrameRate::FPS_29_97)
|
||||
}
|
||||
|
||||
fn placeholder_footage(p: &Value) -> Footage {
|
||||
let w = p.get("width").and_then(Value::as_u64).unwrap_or(1920) as u32;
|
||||
let h = p.get("height").and_then(Value::as_u64).unwrap_or(1080) as u32;
|
||||
let w = p.get("width").and_then(Value::as_u64).map_or(1920, limits::size);
|
||||
let h = p.get("height").and_then(Value::as_u64).map_or(1080, limits::size);
|
||||
Footage {
|
||||
path: String::new(),
|
||||
kind: FootageKind::Video,
|
||||
|
|
@ -133,8 +133,8 @@ fn solid_from(p: &Value, s: &Session) -> Solid {
|
|||
.unwrap_or([0.5, 0.5, 0.5]);
|
||||
Solid {
|
||||
color,
|
||||
width: p.get("width").and_then(Value::as_u64).map(|v| v as u32).unwrap_or(cw).max(1),
|
||||
height: p.get("height").and_then(Value::as_u64).map(|v| v as u32).unwrap_or(ch).max(1),
|
||||
width: p.get("width").and_then(Value::as_u64).map(limits::size).unwrap_or(cw).max(1),
|
||||
height: p.get("height").and_then(Value::as_u64).map(limits::size).unwrap_or(ch).max(1),
|
||||
pixel_aspect: 1.0,
|
||||
}
|
||||
}
|
||||
|
|
@ -620,7 +620,8 @@ impl Interpretation {
|
|||
),
|
||||
};
|
||||
it.invert_alpha = super::b_p(p, "invertAlpha");
|
||||
it.loops = p.get("loop").and_then(Value::as_u64).map(|v| v.clamp(1, 9999) as u32);
|
||||
// The Interpret Footage form sends numbers as floats (3.0), which `as_u64` rejects.
|
||||
it.loops = f_p(p, "loop").filter(|x| x.is_finite()).map(|v| v.round().clamp(1.0, 9999.0) as u32);
|
||||
it.par = f_p(p, "pixelAspect").filter(|x| *x > 0.0);
|
||||
it.fields = match str_p(p, "fields") {
|
||||
Some(f) => Some(effectcraft_project::FieldOrder::parse(f).ok_or_else(|| bad(cmd, "fields: off|upper|lower"))?),
|
||||
|
|
|
|||
|
|
@ -179,7 +179,8 @@ fn edit_into_comp(s: &mut Session, p: &Value, ripple: bool) -> Result<Value> {
|
|||
let mut l = build::layer(proj, &comp, &it.name, src, size, Some(len));
|
||||
l.start_time = at - src_in;
|
||||
l.in_point = at;
|
||||
l.out_point = (at + len).min(comp.duration);
|
||||
// Keep the full marked length past the comp's end (After Effects); the comp just doesn't show it.
|
||||
l.out_point = at + len;
|
||||
let lid = l.id;
|
||||
let cm = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
cm.layers.insert(0, l);
|
||||
|
|
@ -206,8 +207,12 @@ fn info(s: &mut Session, _: &Value) -> Result<Value> {
|
|||
let item = s.project.item(v.item);
|
||||
let name = item.map(|i| i.name.clone()).unwrap_or_default();
|
||||
let frame = fr.frame_at(v.time);
|
||||
let problem = match item.map(|i| &i.kind) {
|
||||
Some(ItemKind::Footage(f)) => s.footage.problem(f),
|
||||
_ => None,
|
||||
};
|
||||
Ok(
|
||||
json!({"item": v.item.0, "name": name, "time": v.time.seconds(), "frame": frame, "timecode": timecode(item, fr, frame), "in": v.in_point.map(|t| t.seconds()), "out": v.out_point.map(|t| t.seconds()), "duration": dur.map(|d| d.seconds()), "frameRate": fr.as_f64()}),
|
||||
json!({"item": v.item.0, "name": name, "time": v.time.seconds(), "frame": frame, "timecode": timecode(item, fr, frame), "in": v.in_point.map(|t| t.seconds()), "out": v.out_point.map(|t| t.seconds()), "duration": dur.map(|d| d.seconds()), "frameRate": fr.as_f64(), "problem": problem}),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
//! Frontend-only commands: viewer zoom/resolution/overlays, panels, workspaces, dialogs and app
|
||||
//! Frontend-only commands: viewer zoom/overlays, panels, workspaces, dialogs and app
|
||||
//! chrome. They live in the registry so every menu entry is a command (menus, shortcuts, the
|
||||
//! control channel and MCP all see them); running one emits [`crate::Event::Frontend`] and the UI
|
||||
//! performs it. Headless sessions accept and ignore them.
|
||||
|
||||
use super::{CommandSpec, always, frontend, has_comp, has_layers};
|
||||
use crate::cmd;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, frontend, has_comp, has_layers};
|
||||
use crate::{Result, Session, cmd};
|
||||
|
||||
macro_rules! fe {
|
||||
($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr) => {
|
||||
|
|
@ -57,11 +59,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
// Viewer.
|
||||
fe!("view.zoomIn", "Zoom In", ["View"], Some("."), "{}", has_comp),
|
||||
fe!("view.zoomOut", "Zoom Out", ["View"], Some(","), "{}", has_comp),
|
||||
fe!("view.res.full", "Full", ["View", "Resolution"], Some("Cmd+J"), "{}", has_comp),
|
||||
fe!("view.res.half", "Half", ["View", "Resolution"], Some("Cmd+Shift+J"), "{}", has_comp),
|
||||
fe!("view.res.third", "Third", ["View", "Resolution"], None, "{}", has_comp),
|
||||
fe!("view.res.quarter", "Quarter", ["View", "Resolution"], Some("Cmd+Alt+Shift+J"), "{}", has_comp),
|
||||
fe!("view.res.custom", "Custom...", ["View", "Resolution"], None, "{factor?: 1..40 (render every n-th pixel)}", has_comp),
|
||||
fe!("view.rulers", "Show Rulers", ["View"], Some("Cmd+R"), "{value?}", always),
|
||||
fe!("view.panelBackground", "Panel Background Color", [], None, "{color?: black|darkGray|mediumGray|lightGray|white|custom|#hex, pick?: true}", always),
|
||||
fe!("view.guides", "Show Guides", ["View"], Some("Cmd+;"), "{value?}", always),
|
||||
|
|
@ -71,7 +68,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
fe!("view.snapToGrid", "Snap to Grid", ["View"], Some("Cmd+Shift+'"), "{value?}", always),
|
||||
fe!("view.options", "View Options...", ["View"], Some("Cmd+Alt+U"), "{}", has_comp),
|
||||
fe!("view.layerControls", "Show Layer Controls", ["View"], Some("Cmd+Shift+H"), "{value?}", always),
|
||||
fe!("view.fullScreen", "Enter Full Screen", ["View"], Some("Ctrl+Cmd+F"), "{}", always),
|
||||
// macOS uses Control+Command+F for native full screen. On Windows and Linux,
|
||||
// Cmd maps to Ctrl; the After Effects-style Ctrl+\ is window.maximizeApp.
|
||||
fe!("view.fullScreen", "Enter Full Screen", ["View"], cfg!(target_os = "macos").then_some("Ctrl+Cmd+F"), "{}", always),
|
||||
fe!(
|
||||
"view.newViewer",
|
||||
"New Viewer",
|
||||
|
|
@ -116,5 +115,28 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"{} — the Project panel's selection, with the layers that use it; one undo step",
|
||||
always
|
||||
),
|
||||
// A gradient property's "Edit Gradient…" (the Timeline's Colors value): opens the
|
||||
// Gradient Editor on it at the current time. OK sets it with prop.set (one undo step).
|
||||
cmd!(
|
||||
"prop.editGradient",
|
||||
"Edit Gradient...",
|
||||
[],
|
||||
None,
|
||||
"{layer?, path|prop, comp?} — a gradient property; opens the Gradient Editor at the current time",
|
||||
has_layers,
|
||||
edit_gradient
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// `prop.editGradient`: check the target is a gradient property before the UI opens the editor,
|
||||
/// so agents get an error (not success) for a wrong one, and hand the UI the resolved ids.
|
||||
fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "prop.editGradient";
|
||||
let (cid, lid, uid) = super::prop::prop_ref(s, p, c)?;
|
||||
let pr = s.project.comp(cid).and_then(|comp| comp.layer(lid)).and_then(|l| l.props.find(uid)).ok_or_else(|| bad(c, format!("no property @{uid}")))?;
|
||||
if !matches!(pr.value, effectcraft_keyframe::Value::Gradient(_)) {
|
||||
return Err(bad(c, format!("{} is not a gradient property", pr.name)));
|
||||
}
|
||||
frontend(s, c, &json!({"comp": cid.0, "layer": lid.0, "prop": uid}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ use effectcraft_color::{BlendMode, Label};
|
|||
use effectcraft_keyframe::{Justify, TextDoc, Value as KV};
|
||||
use effectcraft_project::build::{self, Ids};
|
||||
use effectcraft_project::{
|
||||
Comp, FrameBlend, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, MaskMode, MatteKind, Project, PropGroup, Quality, Solid, TrackMatte,
|
||||
Comp, FrameBlend, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, MaskMode, MatteKind, Node, Project, PropGroup, Quality, Solid, TrackMatte,
|
||||
limits,
|
||||
};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
|
@ -92,8 +93,9 @@ fn new_solid_like(s: &mut Session, p: &Value, adjustment: bool) -> Result<Value>
|
|||
let cid = comp_id(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
|
||||
let color = color_p(p, "color").unwrap_or(if adjustment { [1.0, 1.0, 1.0] } else { [0.85, 0.2, 0.2] });
|
||||
let w = p.get("width").and_then(Value::as_u64).map(|v| v as u32).unwrap_or(comp.width);
|
||||
let h = p.get("height").and_then(Value::as_u64).map(|v| v as u32).unwrap_or(comp.height);
|
||||
// 1 to 30,000 px a side (#788): the layer is sized from the same numbers as its solid.
|
||||
let w = p.get("width").and_then(Value::as_u64).map(limits::size).unwrap_or(comp.width);
|
||||
let h = p.get("height").and_then(Value::as_u64).map(limits::size).unwrap_or(comp.height);
|
||||
let pixel_aspect = pixel_aspect_p(p)?.unwrap_or(comp.pixel_aspect);
|
||||
let default_name = if adjustment { "Adjustment Layer 1".to_string() } else { "Solid 1".to_string() };
|
||||
let name = str_p(p, "name").map(str::to_string).unwrap_or(default_name);
|
||||
|
|
@ -114,7 +116,7 @@ fn new_solid_like(s: &mut Session, p: &Value, adjustment: bool) -> Result<Value>
|
|||
fn pixel_aspect_p(p: &Value) -> Result<Option<f64>> {
|
||||
match p.get("pixelAspect").and_then(Value::as_f64) {
|
||||
Some(v) if !(v.is_finite() && v > 0.0) => Err(bad("layer.settings", "`pixelAspect` must be a positive number")),
|
||||
v => Ok(v),
|
||||
v => Ok(v.map(limits::pixel_aspect)),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -295,7 +297,10 @@ fn add_item(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let mut l = build::layer(proj, &comp, &it.name, src, size, dur);
|
||||
l.start_time = start;
|
||||
l.in_point = start;
|
||||
l.out_point = fr.snap_nearest((start + dur.unwrap_or(comp.duration)).min(comp.duration)).max(start + fr.frame_duration());
|
||||
// Footage and precomps keep their full length past the comp's end (After Effects); items
|
||||
// without a duration (solids, comp-length stills) end with the comp.
|
||||
let end = dur.map_or((start + comp.duration).min(comp.duration), |d| start + d);
|
||||
l.out_point = fr.snap_nearest(end).max(start + fr.frame_duration());
|
||||
place(&mut l, position);
|
||||
insert_layer_at(proj, st, cid, l, index)
|
||||
})?;
|
||||
|
|
@ -377,6 +382,9 @@ fn set_switch(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let name = str_p(p, "switch").ok_or_else(|| bad("layer.setSwitch", "missing `switch`"))?.to_string();
|
||||
let v = b_p(p, "value");
|
||||
let label = format!("Layer Switch ({name})");
|
||||
if b_p(p, "others") == Some(true) {
|
||||
return set_switch_others(s, cid, &ids, &name, v, &label);
|
||||
}
|
||||
// `merge`: a switch dragged over several layers in the Timeline is one undo step.
|
||||
let r = s.edit(&label, merge_p(p), |proj, _| {
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
|
|
@ -394,6 +402,38 @@ fn set_switch(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(json!(r))
|
||||
}
|
||||
|
||||
/// `layer.setSwitch {others: true}` (Alt-click on an A/V feature in the Timeline): the named
|
||||
/// layers stand alone, shown / heard / soloed / unlocked while every other layer the switch
|
||||
/// applies to is the opposite; when they already do, the other layers come back. `value` sets
|
||||
/// the other layers directly.
|
||||
fn set_switch_others(s: &mut Session, cid: ItemId, ids: &[LayerId], name: &str, value: Option<bool>, label: &str) -> Result<Value> {
|
||||
let r = s.edit(label, None, |proj, _| {
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
// The state that sets a layer apart: unlocked for Lock, on for the rest.
|
||||
let active = !matches!(name, "lock" | "locked");
|
||||
// Track mattes keep their video off by design.
|
||||
let mattes: Vec<LayerId> =
|
||||
if matches!(name, "video" | "eye") { comp.layers.iter().filter_map(|l| l.track_matte.map(|m| m.layer)).collect() } else { Vec::new() };
|
||||
let applies = |l: &Layer| match name {
|
||||
"audio" => l.props.sub("audio").is_some(),
|
||||
"lock" | "locked" => true,
|
||||
_ => l.source.is_av() && !mattes.contains(&l.id),
|
||||
};
|
||||
let mut alone = true;
|
||||
for l in comp.layers.iter().filter(|l| applies(l)) {
|
||||
let Some(on) = switch_value(l, name) else { return Err(bad("layer.setSwitch", format!("unknown switch `{name}`"))) };
|
||||
alone &= on == (ids.contains(&l.id) == active);
|
||||
}
|
||||
let others = value.unwrap_or(alone == active);
|
||||
for l in comp.layers.iter_mut().filter(|l| applies(l)) {
|
||||
let v = if ids.contains(&l.id) { active } else { others };
|
||||
set_switch_value(l, name, v);
|
||||
}
|
||||
Ok(others)
|
||||
})?;
|
||||
Ok(json!(r))
|
||||
}
|
||||
|
||||
fn switch_value(l: &Layer, name: &str) -> Option<bool> {
|
||||
let sw = &l.switches;
|
||||
Some(match name {
|
||||
|
|
@ -857,9 +897,13 @@ fn timing(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let fr = s.project.comp(cid).ok_or(EngineError::NoComp)?.frame_rate;
|
||||
let snap = |k: &str| f_p(p, k).map(|v| fr.snap_nearest(Tick::from_seconds_f64(v)));
|
||||
let (delta, start, inp, outp) = (snap("delta"), snap("start"), snap("in"), snap("out"));
|
||||
// Relative trims: each layer moves its own in/out point by the same amount, so dragging an
|
||||
// edge of several selected layers trims them all together (as in AE).
|
||||
let (in_delta, out_delta) = (snap("inDelta"), snap("outDelta"));
|
||||
s.edit("Layer Timing", merge_p(p), |proj, _| {
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let fd = comp.frame_duration();
|
||||
let comp_dur = comp.duration;
|
||||
for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
|
||||
let shift = |l: &mut Layer, d: Tick| {
|
||||
l.start_time += d;
|
||||
|
|
@ -891,6 +935,14 @@ fn timing(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
if let Some(o) = outp {
|
||||
l.out_point = o.max(l.in_point + fd);
|
||||
}
|
||||
// Stops at the comp start/end (a point already past it doesn't jump onto
|
||||
// it), keeping at least one frame of layer.
|
||||
if let Some(d) = in_delta {
|
||||
l.in_point = (l.in_point + d).max(Tick::ZERO.min(l.in_point)).min(l.out_point - fd);
|
||||
}
|
||||
if let Some(d) = out_delta {
|
||||
l.out_point = (l.out_point + d).min(comp_dur.max(l.out_point)).max(l.in_point + fd);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1186,6 +1238,26 @@ fn add_shape_item(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let uid = s.edit("Add Shape Item", None, |proj, _| {
|
||||
let mut next = proj.next_id;
|
||||
let mut ids = Ids(&mut next);
|
||||
// Front / Bevel / Side / Back ▸ property: into the group's surface item (one per group).
|
||||
if let Some((surface, prop)) = build::shape_surface_kind(&kind) {
|
||||
let target = shape_item_target(proj, cid, lid, group_uid)?;
|
||||
let item = match target.children.iter().position(|c| matches!(c, Node::Group(g) if g.match_id == surface)) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
let g = build::shape_surface(&mut ids, surface).ok_or_else(|| bad("layer.addShapeItem", format!("unknown kind `{kind}`")))?;
|
||||
target.children.push(g.into());
|
||||
target.children.len() - 1
|
||||
}
|
||||
};
|
||||
let Some(Node::Group(g)) = target.children.get_mut(item) else { return Err(bad("layer.addShapeItem", "no such group")) };
|
||||
if g.get(prop).is_none() {
|
||||
let p = build::shape_surface_prop(&mut ids, prop).ok_or_else(|| bad("layer.addShapeItem", format!("unknown kind `{kind}`")))?;
|
||||
g.children.push(p.into());
|
||||
}
|
||||
let uid = g.uid;
|
||||
proj.next_id = next;
|
||||
return Ok(uid);
|
||||
}
|
||||
let g = match kind.as_str() {
|
||||
"group" => build::shape_group(&mut ids, "Group 1", vec![]),
|
||||
"rect" | "rectangle" => build::shape_rect(&mut ids, [200.0, 200.0], [0.0, 0.0], 0.0),
|
||||
|
|
@ -1203,16 +1275,7 @@ fn add_shape_item(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
};
|
||||
let uid = g.uid;
|
||||
proj.next_id = next;
|
||||
let l = layer_mut(proj, cid, lid)?;
|
||||
let contents = l.props.sub_mut("contents").ok_or_else(|| bad("layer.addShapeItem", "not a shape layer"))?;
|
||||
let target = match group_uid {
|
||||
Some(u) => contents
|
||||
.find_group_mut(u)
|
||||
.and_then(|g| if g.match_id == "contents" { Some(g) } else { g.sub_mut("contents") })
|
||||
.ok_or_else(|| bad("layer.addShapeItem", "no such group"))?,
|
||||
None => contents,
|
||||
};
|
||||
target.children.push(g.into());
|
||||
shape_item_target(proj, cid, lid, group_uid)?.children.push(g.into());
|
||||
Ok(uid)
|
||||
})?;
|
||||
// The new item's property path, ready for prop.set / add_keyframe (`contents/trim#2/end`).
|
||||
|
|
@ -1220,6 +1283,20 @@ fn add_shape_item(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(json!({"uid": uid, "path": path}))
|
||||
}
|
||||
|
||||
/// The contents a new shape item goes into: the shape group `group_uid` (its Contents), else the
|
||||
/// layer's Contents.
|
||||
fn shape_item_target(proj: &mut Project, cid: ItemId, lid: LayerId, group_uid: Option<u64>) -> Result<&mut PropGroup> {
|
||||
let l = layer_mut(proj, cid, lid)?;
|
||||
let contents = l.props.sub_mut("contents").ok_or_else(|| bad("layer.addShapeItem", "not a shape layer"))?;
|
||||
Ok(match group_uid {
|
||||
Some(u) => contents
|
||||
.find_group_mut(u)
|
||||
.and_then(|g| if g.match_id == "contents" { Some(g) } else { g.sub_mut("contents") })
|
||||
.ok_or_else(|| bad("layer.addShapeItem", "no such group"))?,
|
||||
None => contents,
|
||||
})
|
||||
}
|
||||
|
||||
fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, lid) = layer_p(s, p, "layer.setText")?;
|
||||
// The Character panel's recent fonts (Settings ▸ Type ▸ Number of Recent Fonts to Display).
|
||||
|
|
@ -1404,8 +1481,8 @@ fn layer_settings(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
_ => {}
|
||||
}
|
||||
let color = color_p(p, "color");
|
||||
let w = p.get("width").and_then(Value::as_u64).map(|v| v.max(1) as u32);
|
||||
let h = p.get("height").and_then(Value::as_u64).map(|v| v.max(1) as u32);
|
||||
let w = p.get("width").and_then(Value::as_u64).map(limits::size);
|
||||
let h = p.get("height").and_then(Value::as_u64).map(limits::size);
|
||||
let pixel_aspect = pixel_aspect_p(p)?;
|
||||
let name = str_p(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
|
||||
let affect_all = p.get("affectAll").and_then(Value::as_bool).unwrap_or(true);
|
||||
|
|
@ -1508,7 +1585,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Layer Switch",
|
||||
[],
|
||||
None,
|
||||
"{layers?, switch: video|audio|solo|lock|shy|collapse|quality|fx|frameBlend|motionBlur|adjustment|threeD|guide|preserveTransparency, value?, merge?}",
|
||||
"{layers?, switch: video|audio|solo|lock|shy|collapse|quality|fx|frameBlend|motionBlur|adjustment|threeD|guide|preserveTransparency, value?, merge?, others? (the named layers alone: the other layers get the opposite state, or come back)}",
|
||||
has_layers,
|
||||
set_switch
|
||||
),
|
||||
|
|
@ -1530,7 +1607,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Layer Timing",
|
||||
[],
|
||||
None,
|
||||
"{layers?, op?: moveInToTime|moveOutToTime|trimInToTime|trimOutToTime, delta?, start?, in?, out?, merge?}",
|
||||
"{layers?, op?: moveInToTime|moveOutToTime|trimInToTime|trimOutToTime, delta?, start?, in?, out?, inDelta?, outDelta? (seconds, per layer), merge?}",
|
||||
has_layers,
|
||||
timing
|
||||
),
|
||||
|
|
@ -1570,7 +1647,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Add (Shape)",
|
||||
[],
|
||||
None,
|
||||
"{layer?, kind: group|rect|ellipse|star|polygon|path|fill|stroke|gfill|gstroke|trim|repeater|round|offset|pucker|twist|zigzag|wiggle|merge, group?: uid|path} → {uid, path}",
|
||||
"{layer?, kind: group|rect|ellipse|star|polygon|path|fill|stroke|gfill|gstroke|trim|repeater|round|offset|pucker|twist|zigzag|wiggle|merge|frontColor|bevelColor|sideColor|backColor, group?: uid|path} → {uid, path} (frontColor…backColor: the group's Front / Bevel / Side / Back item, which colours that surface of every extruded paint in the group in Advanced 3D)",
|
||||
has_layers,
|
||||
add_shape_item
|
||||
),
|
||||
|
|
|
|||
|
|
@ -661,7 +661,24 @@ fn open_source(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
/// Folder containing a file as a `file://` URL.
|
||||
pub(crate) fn folder_url(path: &str) -> String {
|
||||
let dir = std::path::Path::new(path).parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_default();
|
||||
format!("file://{dir}")
|
||||
file_url(&dir)
|
||||
}
|
||||
|
||||
/// `path` as a `file://` URL: backslashes become `/`, a Windows drive path gets the third slash
|
||||
/// (`file:///C:/…`), and every byte that isn't a plain path character is percent-encoded (spaces,
|
||||
/// `#`, `%`, `?`, non-ASCII as UTF-8), so the URL names exactly this file.
|
||||
pub(crate) fn file_url(path: &str) -> String {
|
||||
use std::fmt::Write as _;
|
||||
let path = path.replace('\\', "/");
|
||||
let mut url = String::from(if path.starts_with('/') { "file://" } else { "file:///" });
|
||||
for b in path.bytes() {
|
||||
if b.is_ascii_alphanumeric() || b"/-._~:@!$&'()*+,;=".contains(&b) {
|
||||
url.push(char::from(b));
|
||||
} else {
|
||||
let _ = write!(url, "%{b:02X}");
|
||||
}
|
||||
}
|
||||
url
|
||||
}
|
||||
|
||||
fn reveal_in_finder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
|
@ -845,3 +862,21 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{file_url, folder_url};
|
||||
|
||||
#[test]
|
||||
fn file_urls_are_well_formed() {
|
||||
assert_eq!(file_url("C:\\Users\\Ann\\Footage\\clip 1.mov"), "file:///C:/Users/Ann/Footage/clip%201.mov");
|
||||
assert_eq!(file_url("/home/ann/a#b%c?d.mov"), "file:///home/ann/a%23b%25c%3Fd.mov");
|
||||
assert_eq!(file_url("/Users/ann/Café/x.mov"), "file:///Users/ann/Caf%C3%A9/x.mov");
|
||||
assert_eq!(file_url("/plain/path-1_2.~(x).mov"), "file:///plain/path-1_2.~(x).mov", "plain characters stay");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folder_url_names_the_containing_folder() {
|
||||
assert_eq!(folder_url("/media/My Clips/a.mov"), "file:///media/My%20Clips");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
mod align;
|
||||
mod align_data;
|
||||
mod anim;
|
||||
mod anim_presets;
|
||||
pub mod anim_tools;
|
||||
mod animation;
|
||||
pub(crate) mod app_more;
|
||||
|
|
@ -43,7 +44,7 @@ pub mod panels_cmds;
|
|||
pub(crate) mod path_nulls;
|
||||
mod paths;
|
||||
mod project_items;
|
||||
mod prop;
|
||||
pub(crate) mod prop;
|
||||
mod prop_groups;
|
||||
mod proxy;
|
||||
pub mod puppet;
|
||||
|
|
@ -75,6 +76,8 @@ pub(crate) mod watch_folder;
|
|||
pub(crate) use app_more::report_text as report_text_for_tests;
|
||||
pub(crate) use app_more::view_command;
|
||||
#[cfg(test)]
|
||||
pub(crate) use file::open as open_for_tests;
|
||||
#[cfg(test)]
|
||||
pub(crate) use keys_more::amplitudes as amplitudes_for_tests;
|
||||
#[cfg(test)]
|
||||
pub(crate) use mask::split_segment as split_segment_for_tests;
|
||||
|
|
@ -98,6 +101,8 @@ pub struct CommandSpec {
|
|||
pub menu: &'static [&'static str],
|
||||
/// Default shortcut (egui-style names, `Cmd` = ⌘ on macOS / Ctrl elsewhere).
|
||||
pub shortcut: Option<&'static str>,
|
||||
/// Further default shortcuts (the menus and tooltips show `shortcut`).
|
||||
pub alt_shortcuts: &'static [&'static str],
|
||||
/// Parameter description for agents and the MCP schema.
|
||||
pub params: &'static str,
|
||||
pub enabled: Enabled,
|
||||
|
|
@ -108,8 +113,22 @@ pub struct CommandSpec {
|
|||
|
||||
#[macro_export]
|
||||
macro_rules! cmd {
|
||||
// Several default shortcuts: `["PageDown", "Cmd+ArrowRight"]`.
|
||||
($id:literal, $label:literal, [$($m:literal),*], [$sc:literal, $($alt:literal),+], $params:literal, $en:expr, $run:expr) => {
|
||||
$crate::commands::CommandSpec { alt_shortcuts: &[$($alt),+], ..$crate::cmd!($id, $label, [$($m),*], Some($sc), $params, $en, $run) }
|
||||
};
|
||||
($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
|
||||
$crate::commands::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true }
|
||||
$crate::commands::CommandSpec {
|
||||
id: $id,
|
||||
label: $label,
|
||||
menu: &[$($m),*],
|
||||
shortcut: $sc,
|
||||
alt_shortcuts: &[],
|
||||
params: $params,
|
||||
enabled: $en,
|
||||
run: $run,
|
||||
journal: true,
|
||||
}
|
||||
};
|
||||
}
|
||||
#[macro_export]
|
||||
|
|
@ -120,6 +139,7 @@ macro_rules! query {
|
|||
label: $label,
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
alt_shortcuts: &[],
|
||||
params: $params,
|
||||
enabled: $crate::commands::always,
|
||||
run: $run,
|
||||
|
|
@ -162,6 +182,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
|||
v.extend(layer_menu::specs());
|
||||
v.extend(create::specs());
|
||||
v.extend(animation::specs());
|
||||
v.extend(anim_presets::specs());
|
||||
v.extend(view::specs());
|
||||
v.extend(viewer_cmds::specs());
|
||||
v.extend(shape_stroke::specs());
|
||||
|
|
@ -221,6 +242,11 @@ pub fn text_preset_list() -> Vec<(String, String)> {
|
|||
text_anim::preset_names()
|
||||
}
|
||||
|
||||
/// (id, name, category) of the built-in animation presets (Transitions…).
|
||||
pub fn builtin_preset_list() -> Vec<(String, String, String)> {
|
||||
anim_presets::preset_names()
|
||||
}
|
||||
|
||||
pub fn find(id: &str) -> Option<&'static CommandSpec> {
|
||||
// Every command execution looks its spec up: index the ~2,000 ids once.
|
||||
static INDEX: OnceLock<std::collections::HashMap<&'static str, usize>> = OnceLock::new();
|
||||
|
|
@ -442,17 +468,34 @@ pub(crate) fn merge_p(p: &Value) -> Option<&str> {
|
|||
str_p(p, "merge")
|
||||
}
|
||||
|
||||
/// `comp`: item id or name; default the active comp.
|
||||
/// `comp`: item id or name (an all-digit string that names no comp is taken as an id, as MCP
|
||||
/// clients often send numbers as strings); default the active comp.
|
||||
pub(crate) fn comp_id(s: &Session, p: &Value) -> Result<ItemId> {
|
||||
let missing = |v: &Value| EngineError::NoSuchComp(v.to_string());
|
||||
match p.get("comp") {
|
||||
Some(v @ Value::Number(n)) => n.as_u64().map(ItemId).filter(|id| s.project.comp(*id).is_some()).ok_or_else(|| missing(v)),
|
||||
Some(v @ Value::String(name)) => s.project.items.values().find(|i| &i.name == name && i.as_comp().is_some()).map(|i| i.id).ok_or_else(|| missing(v)),
|
||||
Some(v @ Value::String(name)) => s
|
||||
.project
|
||||
.items
|
||||
.values()
|
||||
.find(|i| &i.name == name && i.as_comp().is_some())
|
||||
.map(|i| i.id)
|
||||
.or_else(|| digits_id(name).map(ItemId).filter(|id| s.project.comp(*id).is_some()))
|
||||
.ok_or_else(|| missing(v)),
|
||||
_ => s.active_comp_id().ok_or(EngineError::NoComp),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a layer reference: id (number), `#n` / index (1-based), or name.
|
||||
/// An all-ASCII-digit string as a number (`"98"` -> 98); anything else (names, `#n`, signs) is `None`.
|
||||
fn digits_id(s: &str) -> Option<u64> {
|
||||
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
s.parse().ok()
|
||||
}
|
||||
|
||||
/// Resolve a layer reference: id (number), `#n` / index (1-based), or name. An all-digit string
|
||||
/// that names no layer is resolved as the number it spells.
|
||||
pub(crate) fn resolve_layer(comp: &Comp, v: &Value) -> Option<LayerId> {
|
||||
match v {
|
||||
Value::Number(n) => {
|
||||
|
|
@ -463,7 +506,7 @@ pub(crate) fn resolve_layer(comp: &Comp, v: &Value) -> Option<LayerId> {
|
|||
if let Some(i) = s.strip_prefix('#').and_then(|i| i.parse::<usize>().ok()) {
|
||||
return comp.layers.get(i.checked_sub(1)?).map(|l| l.id);
|
||||
}
|
||||
comp.layers.iter().find(|l| &l.name == s).map(|l| l.id)
|
||||
comp.layers.iter().find(|l| &l.name == s).map(|l| l.id).or_else(|| digits_id(s).and_then(|id| resolve_layer(comp, &Value::from(id))))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
//! Advanced 3D: model layers (File ▸ Import of glTF/OBJ, Layer ▸ New ▸ 3D primitives), the
|
||||
//! Advanced 3D: model layers (File ▸ Import of glTF/OBJ/STL/FBX, Layer ▸ New ▸ 3D primitives), the
|
||||
//! comp renderer switch, Layer ▸ Environment Layer, Geometry Options for extruded text and
|
||||
//! shapes, and Layer ▸ Material commands.
|
||||
|
||||
|
|
@ -376,7 +376,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"3D Model Layer",
|
||||
[],
|
||||
None,
|
||||
"{item?: id or name of a 3D model footage item, path?: a .gltf, .glb or .obj file to import, name?, time? (s), index? (1-based stack position), position? ([x, y] comp px)}; switches a Classic 3D comp to Advanced 3D",
|
||||
"{item?: id or name of a 3D model footage item, path?: a .gltf, .glb, .obj, .stl or .fbx file to import, name?, time? (s), index? (1-based stack position), position? ([x, y] comp px)}; switches a Classic 3D comp to Advanced 3D",
|
||||
has_comp,
|
||||
new_model
|
||||
),
|
||||
|
|
|
|||
|
|
@ -284,7 +284,7 @@ fn free_transform(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
|
||||
const SHAPE_ITEMS: &[&str] = &[
|
||||
"group", "rect", "ellipse", "star", "path", "fill", "stroke", "gfill", "gstroke", "trim", "repeater", "round", "offset", "pucker", "twist", "zigzag",
|
||||
"wiggle", "merge",
|
||||
"wiggle", "merge", "front", "bevel", "side", "back",
|
||||
];
|
||||
|
||||
/// Selected shape items (contents entries) of shape layers: (layer, uid).
|
||||
|
|
|
|||
|
|
@ -230,8 +230,20 @@ fn json_numbers(v: &Value) -> Option<Vec<f64>> {
|
|||
}
|
||||
}
|
||||
|
||||
/// The share of a Proportional Scrubbing change (After Effects 26.2, Ctrl+Alt / Cmd+Opt scrub)
|
||||
/// that the layer at `index` of `count` selected layers (in selection order) takes: the first
|
||||
/// none, the last all of it, the ones between even steps.
|
||||
pub(crate) fn proportional_share(index: usize, count: usize) -> f64 {
|
||||
match count.checked_sub(1) {
|
||||
Some(last) if last > 0 => index.min(last) as f64 / last as f64,
|
||||
_ => 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// `prop.set`; with `selected`, also on the other selected layers: the same value, or with
|
||||
/// `offset` (a scrub) the same change from each layer's own value. One undo step (with `merge`,
|
||||
/// `offset` (a scrub) the same change from each layer's own value. With `offset` and
|
||||
/// `proportional` (Proportional Scrubbing) each layer takes [`proportional_share`] of the change
|
||||
/// by its place in the selection order, the scrubbed layer too. One undo step (with `merge`,
|
||||
/// one that a continuing scrub keeps folding into).
|
||||
fn set_selected(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let twins = selected_twins(s, p, "prop.set")?;
|
||||
|
|
@ -244,16 +256,41 @@ fn set_selected(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
(Some(true), Some(n), Some(c)) => Some(n.iter().zip(c.iter().chain(std::iter::repeat(&0.0))).map(|(a, b)| a - b).collect::<Vec<f64>>()),
|
||||
_ => None,
|
||||
};
|
||||
// Proportional Scrubbing: the edited layer and its twins in selection order.
|
||||
let order: Option<Vec<LayerId>> = (delta.is_some() && b_p(p, "proportional") == Some(true))
|
||||
.then(|| s.state.selected_layers.iter().copied().filter(|o| *o == lid || twins.iter().any(|(t, _)| t == o)).collect());
|
||||
let share = |l: LayerId| order.as_ref().map_or(1.0, |o| o.iter().position(|x| *x == l).map_or(1.0, |i| proportional_share(i, o.len())));
|
||||
let scalar = p.get("value").is_some_and(Value::is_number);
|
||||
// `p` aimed at (layer, prop) with its value moved by `k` × the change.
|
||||
let shifted = |s: &Session, q: &mut Value, olid: LayerId, ouid: Uid, k: f64| {
|
||||
if let Some(d) = &delta
|
||||
&& let Some(c) = current_components(s, cid, olid, ouid)
|
||||
{
|
||||
let v: Vec<f64> = c.iter().enumerate().map(|(i, x)| x + k * d.get(i).copied().unwrap_or(0.0)).collect();
|
||||
q["value"] = if scalar { json!(v.first().copied().unwrap_or(0.0)) } else { json!(v) };
|
||||
}
|
||||
};
|
||||
let run = |s: &mut Session| -> Result<Value> {
|
||||
let out = set(s, p)?;
|
||||
for (olid, ouid) in twins {
|
||||
let mut q = twin_params(p, olid, ouid);
|
||||
if let Some(d) = &delta
|
||||
&& let Some(c) = current_components(s, cid, olid, ouid)
|
||||
{
|
||||
let v: Vec<f64> = c.iter().enumerate().map(|(i, x)| x + d.get(i).copied().unwrap_or(0.0)).collect();
|
||||
q["value"] = if p.get("value").is_some_and(Value::is_number) { json!(v.first().copied().unwrap_or(0.0)) } else { json!(v) };
|
||||
let out = if order.is_some() {
|
||||
let k = share(lid);
|
||||
if k <= 0.0 {
|
||||
// (the first selected layer keeps its value: no key is added to it)
|
||||
current_components(s, cid, lid, uid).map_or(Value::Null, |c| if scalar { json!(c.first().copied().unwrap_or(0.0)) } else { json!(c) })
|
||||
} else {
|
||||
let mut q = p.clone();
|
||||
shifted(s, &mut q, lid, uid, k);
|
||||
set(s, &q)?
|
||||
}
|
||||
} else {
|
||||
set(s, p)?
|
||||
};
|
||||
for (olid, ouid) in twins {
|
||||
let k = share(olid);
|
||||
if k <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let mut q = twin_params(p, olid, ouid);
|
||||
shifted(s, &mut q, olid, ouid, k);
|
||||
// (a layer that can't take it, e.g. a clamped or static property, keeps its value)
|
||||
let _ = set(s, &q);
|
||||
}
|
||||
|
|
@ -1140,7 +1177,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Set Property Value",
|
||||
[],
|
||||
None,
|
||||
"{layer?, path|prop, value, time? (layer s by default), timeBase?: layer|comp, merge?, selected? (also the other selected layers), offset? (them by the same change)}",
|
||||
"{layer?, path|prop, value, time? (layer s by default), timeBase?: layer|comp, merge?, selected? (also the other selected layers), offset? (them by the same change), proportional? (with offset: Proportional Scrubbing, each layer by its share in selection order, the first none, the last all)}",
|
||||
has_layers,
|
||||
set_selected
|
||||
),
|
||||
|
|
|
|||
|
|
@ -46,6 +46,13 @@ fn summary(s: &mut Session, _: &Value) -> Result<Value> {
|
|||
o["layers"] = json!(c.layers.len());
|
||||
o["frameRate"] = json!(c.frame_rate.as_f64());
|
||||
}
|
||||
// Footage that shows no picture although its file is there (a codec or profile the
|
||||
// decoders lack): why, so an agent can tell the user.
|
||||
if let ItemKind::Footage(f) = &i.kind
|
||||
&& let Some(why) = s.footage.problem(f)
|
||||
{
|
||||
o["problem"] = json!(why);
|
||||
}
|
||||
o
|
||||
})
|
||||
.collect();
|
||||
|
|
@ -80,6 +87,7 @@ fn comp_info(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
"id": cid.0, "name": name, "width": c.width, "height": c.height, "frameRate": c.frame_rate.as_f64(), "duration": c.duration.seconds(),
|
||||
"workArea": [c.work_area.0.seconds(), c.work_area.1.seconds()], "background": c.background,
|
||||
"preserveFrameRate": c.preserve_frame_rate, "preserveResolution": c.preserve_resolution,
|
||||
"resolution": c.resolution.label(), "resolutionFactor": c.resolution.factor(),
|
||||
"layers": c.layers.iter().enumerate().map(|(i, l)| layer_json(l, i + 1)).collect::<Vec<_>>(),
|
||||
}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -203,7 +203,7 @@ fn apply_om_template(om: &mut OutputModule, t: &OutputModule) {
|
|||
}
|
||||
|
||||
/// The `format` parameter's values.
|
||||
const FORMATS: &str = "format: h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff";
|
||||
const FORMATS: &str = "format: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|gif|wav|aiff";
|
||||
|
||||
/// Apply Output Module parameters (`template` first). `roi`: the viewer's region of interest
|
||||
/// (captured by `crop.useRoi`).
|
||||
|
|
@ -999,7 +999,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Add to Render Queue",
|
||||
["Composition"],
|
||||
Some("Cmd+M"),
|
||||
"{comp?: id|name, template?: Render Settings template, format?: h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff, output?: path|template, log?: errorsOnly|plusSettings|plusPerFrameInfo, quality?: best|draft|1-100 (jpeg, webm), resolution?: full|half|third|quarter|scale, proxyUse?, effects?, solo?, guideLayers?, colorDepth?, frameBlending?, fieldRender?, pulldown?, motionBlur?, timeSpan?: workArea|comp|custom, start?: s, end?: s, duration?: s, frameRate?: fps|null, skipExisting?: bool, storageOverflow?: bool, channels?: rgb|rgba|alpha, color?, bitrate?: kbps, webmBitrate?, keyframeInterval?, proresProfile?, profile?, level?, rateControl?, webmCodec?, audioBitrate?, opusApplication?, crop?, resize?, includeProjectLink?, audio?: auto|on|off, sampleRate?, audioChannels?, audioFormat?, loop?: bool (values as in setRenderSettings / setOutputModule)}",
|
||||
"{comp?: id|name, template?: Render Settings template, format?: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|gif|wav|aiff, output?: path|template, log?: errorsOnly|plusSettings|plusPerFrameInfo, quality?: best|draft|1-100 (jpeg, webm), resolution?: full|half|third|quarter|scale, proxyUse?, effects?, solo?, guideLayers?, colorDepth?, frameBlending?, fieldRender?, pulldown?, motionBlur?, timeSpan?: workArea|comp|custom, start?: s, end?: s, duration?: s, frameRate?: fps|null, skipExisting?: bool, storageOverflow?: bool, channels?: rgb|rgba|alpha, color?, bitrate?: kbps, webmBitrate?, keyframeInterval?, proresProfile?, profile?, level?, rateControl?, webmCodec?, audioBitrate?, opusApplication?, crop?, resize?, includeProjectLink?, audio?: auto|on|off, sampleRate?, audioChannels?, audioFormat?, loop?: bool (values as in setRenderSettings / setOutputModule)}",
|
||||
can_add,
|
||||
add
|
||||
),
|
||||
|
|
@ -1019,7 +1019,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Output Module Settings...",
|
||||
[],
|
||||
None,
|
||||
"{item?|index?, module?: n (1 = first), template?: name, format?: h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff, channels?: rgb|rgba|alpha, color?: straight|premultiplied, quality?: 1-100, bitrate?: kbps, webmBitrate?: bool, keyframeInterval?: frames (0 = auto), proresProfile?: proxy|lt|standard|hq|4444|4444xq, profile?: main|main10 (HEVC/AV1), level?: auto|4.1, rateControl?: bitrate|quality, webmCodec?: vp9|av1, audioBitrate?: Opus kbps, opusApplication?: audio|voice, crop?: bool|{useRoi?, roi?, top?, left?, bottom?, right?}, cropTop?, cropLeft?, cropBottom?, cropRight?, resize?: bool|{preset?, width?, height?, lockAspect?, quality?: low|high}, resizeWidth?, resizeHeight?, postRenderAction?: none|import|importAndReplace|setProxy, includeProjectLink?: bool, audio?: auto|on|off, sampleRate?, audioChannels?: mono|stereo, audioFormat?: 16|24|32, loop?: bool, output?}",
|
||||
"{item?|index?, module?: n (1 = first), template?: name, format?: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|gif|wav|aiff, channels?: rgb|rgba|alpha, color?: straight|premultiplied, quality?: 1-100, bitrate?: kbps, webmBitrate?: bool, keyframeInterval?: frames (0 = auto), proresProfile?: proxy|lt|standard|hq|4444|4444xq, profile?: main|main10 (HEVC/AV1), level?: auto|4.1, rateControl?: bitrate|quality, webmCodec?: vp9|av1, audioBitrate?: Opus kbps, opusApplication?: audio|voice, crop?: bool|{useRoi?, roi?, top?, left?, bottom?, right?}, cropTop?, cropLeft?, cropBottom?, cropRight?, resize?: bool|{preset?, width?, height?, lockAspect?, quality?: low|high}, resizeWidth?, resizeHeight?, postRenderAction?: none|import|importAndReplace|setProxy, includeProjectLink?: bool, audio?: auto|on|off, sampleRate?, audioChannels?: mono|stereo, audioFormat?: 16|24|32, loop?: bool, output?}",
|
||||
has_items,
|
||||
set_output_module
|
||||
),
|
||||
|
|
|
|||
|
|
@ -384,6 +384,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
always,
|
||||
settings_save
|
||||
),
|
||||
cmd!(
|
||||
"script.runTasks",
|
||||
"Run Scheduled Script Tasks",
|
||||
[],
|
||||
None,
|
||||
"{} → runs the app.scheduleTask tasks that are due (frontends do it every frame) → {ran, output, errors, next (ms until the next task, or null)}",
|
||||
always,
|
||||
scriptui::run_tasks
|
||||
),
|
||||
crate::query!("scriptui.list", "List Script Windows", "{} → [{window, title, kind: dialog|palette|window|panel, script, modal, size}]", scriptui::list),
|
||||
crate::query!(
|
||||
"scriptui.get",
|
||||
|
|
@ -409,6 +418,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
always,
|
||||
scriptui::set
|
||||
),
|
||||
cmd!(
|
||||
"scriptui.mouse",
|
||||
"Mouse Event on Script Window Control",
|
||||
[],
|
||||
None,
|
||||
"{window?, widget, type: mousedown | mouseup | mousemove | mouseover | mouseout | click (default), x?, y? (relative to the control; default its centre), button? (0 left, 1 middle, 2 right), detail? (click count), screenX?, screenY?, altKey?, ctrlKey?, metaKey?, shiftKey?} → the script's addEventListener listeners on the control and its containers receive a MouseEvent",
|
||||
always,
|
||||
scriptui::mouse
|
||||
),
|
||||
cmd!(
|
||||
"scriptui.close",
|
||||
"Close Script Window",
|
||||
|
|
|
|||
|
|
@ -3,13 +3,14 @@
|
|||
//! commands here set several of them in one undo step and add the groups to strokes saved before
|
||||
//! they existed. Also the toolbar's Fill / Stroke options on selected shape layers.
|
||||
|
||||
use effectcraft_keyframe::Value as KV;
|
||||
use effectcraft_keyframe::{Gradient, Value as KV};
|
||||
use effectcraft_project::build::{self, EXTRA_DASHES, Ids};
|
||||
use effectcraft_project::{ItemId, Layer, LayerId, LayerSource, Node, PropGroup, Uid};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::layer::color_p;
|
||||
use super::shape_tool::{self, PaintKind};
|
||||
use super::{CommandSpec, bad, f_p, has_layers, layer_mut, layer_p, layers_p, merge_p};
|
||||
use crate::{EngineError, Result, Session, cmd};
|
||||
|
||||
|
|
@ -224,15 +225,157 @@ pub fn first_paint(layer: &Layer, lt: Tick) -> (Option<[f64; 4]>, Option<[f64; 4
|
|||
(fill, stroke, width)
|
||||
}
|
||||
|
||||
/// Paint one contents group and its sub-groups: Fills take `fill`, Strokes `stroke`, Strokes and
|
||||
/// Gradient Strokes `width`. With `add_stroke`, a group with a path and no stroke gets one (before
|
||||
/// its fills, as new shapes have it).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn paint(c: &mut PropGroup, ids: &mut Ids, lt: Tick, fill: Option<[f64; 4]>, stroke: Option<[f64; 4]>, width: Option<f64>, add_stroke: bool, depth: usize) {
|
||||
/// What the toolbar's Fill and Stroke set on the selected shape layers.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
struct PaintEdit {
|
||||
fill: Option<[f64; 4]>,
|
||||
stroke: Option<[f64; 4]>,
|
||||
width: Option<f64>,
|
||||
/// A group with a path and no stroke gets one.
|
||||
add_stroke: bool,
|
||||
fill_kind: Option<PaintKind>,
|
||||
stroke_kind: Option<PaintKind>,
|
||||
}
|
||||
|
||||
/// The kind of the first Fill and the first Stroke in `layer`'s contents at layer time `lt`: a
|
||||
/// switched-off or missing one is None (what the toolbar shows for a selected shape layer).
|
||||
pub fn first_paint_kinds(layer: &Layer, lt: Tick) -> (PaintKind, PaintKind) {
|
||||
let (mut fill, mut stroke) = (None, None);
|
||||
let kind = |g: &PropGroup| match g.match_id.as_str() {
|
||||
_ if !g.enabled => PaintKind::None,
|
||||
"fill" | "stroke" => PaintKind::Solid,
|
||||
_ if g.get("type").is_some_and(|p| p.value_at(lt).as_enum() == 1) => PaintKind::Radial,
|
||||
_ => PaintKind::Linear,
|
||||
};
|
||||
if let Some(c) = layer.props.sub("contents") {
|
||||
walk_groups(c, &mut |g| match g.match_id.as_str() {
|
||||
"fill" | "gfill" if fill.is_none() => fill = Some(kind(g)),
|
||||
"stroke" | "gstroke" if stroke.is_none() => stroke = Some(kind(g)),
|
||||
_ => {}
|
||||
});
|
||||
}
|
||||
(fill.unwrap_or(PaintKind::None), stroke.unwrap_or(PaintKind::None))
|
||||
}
|
||||
|
||||
/// The bounds (`[x, y, w, h]`) of the path items of one contents level at layer time `lt`, where
|
||||
/// a new gradient's Start and End Points go; a 100 px square at the origin without paths.
|
||||
fn path_bounds(c: &PropGroup, lt: Tick) -> [f64; 4] {
|
||||
let mut acc: Option<[f64; 4]> = None;
|
||||
for g in c.groups() {
|
||||
let at = |id: &str| g.get(id).map(|p| p.value_at(lt));
|
||||
let b = match g.match_id.as_str() {
|
||||
"rect" | "ellipse" => {
|
||||
let [w, h] = at("size").map(|v| v.as_vec2()).unwrap_or([100.0; 2]);
|
||||
let [x, y] = at("position").map(|v| v.as_vec2()).unwrap_or([0.0; 2]);
|
||||
Some([x - w.abs() / 2.0, y - h.abs() / 2.0, x + w.abs() / 2.0, y + h.abs() / 2.0])
|
||||
}
|
||||
"star" => {
|
||||
let r = at("outerRadius").map(|v| v.as_f64().abs()).unwrap_or(100.0);
|
||||
let [x, y] = at("position").map(|v| v.as_vec2()).unwrap_or([0.0; 2]);
|
||||
Some([x - r, y - r, x + r, y + r])
|
||||
}
|
||||
"path" => {
|
||||
let v = at("path");
|
||||
let bez = v.as_ref().and_then(|v| v.as_path()).map(effectcraft_path::to_kurbo);
|
||||
bez.and_then(|b| effectcraft_path::bounds(&[b])).map(|r| [r.x0, r.y0, r.x1, r.y1])
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let Some(b) = b.filter(|b| b.iter().all(|v| v.is_finite())) else { continue };
|
||||
acc = Some(match acc {
|
||||
None => b,
|
||||
Some(a) => [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])],
|
||||
});
|
||||
}
|
||||
acc.map(|[x0, y0, x1, y1]| [x0, y0, x1 - x0, y1 - y0]).unwrap_or([-50.0, -50.0, 100.0, 100.0])
|
||||
}
|
||||
|
||||
/// Fill Options / Stroke Options on one contents level (as in After Effects, with shape layers
|
||||
/// selected they change the layers' paint): None switches its Fills and Gradient Fills (or
|
||||
/// Strokes and Gradient Strokes) off; another kind switches them on, turns them into that kind
|
||||
/// (keeping the properties both kinds have: blend mode, opacity, width, dashes…) and gives a
|
||||
/// level with paths and no such item one.
|
||||
fn set_kind(c: &mut PropGroup, ids: &mut Ids, lt: Tick, stroke: bool, kind: PaintKind, e: &PaintEdit) {
|
||||
let (solid, grad) = if stroke { ("stroke", "gstroke") } else { ("fill", "gfill") };
|
||||
let is_item = |g: &PropGroup| g.match_id == solid || g.match_id == grad;
|
||||
if kind == PaintKind::None {
|
||||
for n in &mut c.children {
|
||||
if let Node::Group(g) = n
|
||||
&& is_item(g)
|
||||
{
|
||||
g.enabled = false;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
let radial = kind == PaintKind::Radial;
|
||||
let color = (if stroke { e.stroke } else { e.fill }).unwrap_or([1.0; 4]);
|
||||
let width = e.width.filter(|w| *w > 0.0).unwrap_or(2.0);
|
||||
let (start, end) = shape_tool::gradient_ends(radial, path_bounds(c, lt));
|
||||
let fresh = |ids: &mut Ids| match (kind, stroke) {
|
||||
(PaintKind::Solid, false) => build::shape_fill(ids, color),
|
||||
(PaintKind::Solid, true) => build::shape_stroke(ids, color, width),
|
||||
(_, false) => build::shape_gradient_fill(ids, radial, start, end, Gradient::default()),
|
||||
(_, true) => build::shape_gradient_stroke(ids, radial, start, end, Gradient::default(), width),
|
||||
};
|
||||
let want = if kind == PaintKind::Solid { solid } else { grad };
|
||||
let mut found = false;
|
||||
for i in 0..c.children.len() {
|
||||
let Some(Node::Group(g)) = c.children.get(i) else { continue };
|
||||
if !is_item(g) {
|
||||
continue;
|
||||
}
|
||||
found = true;
|
||||
let replacement = (g.match_id != want).then(|| {
|
||||
let mut new = fresh(ids);
|
||||
new.uid = g.uid;
|
||||
new.name = super::effect::unique_name(c, &new.name);
|
||||
for n in &mut new.children {
|
||||
if let Some(old) = g.children.iter().find(|o| o.match_id() == n.match_id()) {
|
||||
*n = old.clone();
|
||||
}
|
||||
}
|
||||
new
|
||||
});
|
||||
let Some(Node::Group(g)) = c.children.get_mut(i) else { continue };
|
||||
match replacement {
|
||||
Some(new) => *g = new,
|
||||
None => {
|
||||
g.enabled = true;
|
||||
if let Some(t) = g.get_mut("type").filter(|_| want == grad) {
|
||||
t.set_value_at(lt, KV::Enum(radial as u32));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found && c.groups().any(|g| PATHS.contains(&g.match_id.as_str())) {
|
||||
let mut new = fresh(ids);
|
||||
new.name = super::effect::unique_name(c, &new.name);
|
||||
// Paths, strokes, then fills, as new shapes have them.
|
||||
let at = if stroke {
|
||||
c.children.iter().position(|n| matches!(n.match_id(), "fill" | "gfill")).unwrap_or(c.children.len())
|
||||
} else {
|
||||
c.children.iter().rposition(|n| PATHS.contains(&n.match_id()) || matches!(n.match_id(), "stroke" | "gstroke")).map_or(c.children.len(), |i| i + 1)
|
||||
};
|
||||
c.children.insert(at, new.into());
|
||||
}
|
||||
}
|
||||
|
||||
/// Paint one contents group and its sub-groups: the kinds first, then Fills take `fill`, Strokes
|
||||
/// `stroke`, Strokes and Gradient Strokes `width`. With `add_stroke`, a group with a path and no
|
||||
/// stroke gets one (before its fills, as new shapes have it).
|
||||
fn paint(c: &mut PropGroup, ids: &mut Ids, lt: Tick, e: &PaintEdit, depth: usize) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
if add_stroke && c.groups().any(|g| PATHS.contains(&g.match_id.as_str())) && !c.groups().any(is_stroke) {
|
||||
if let Some(k) = e.stroke_kind {
|
||||
set_kind(c, ids, lt, true, k, e);
|
||||
}
|
||||
if let Some(k) = e.fill_kind {
|
||||
set_kind(c, ids, lt, false, k, e);
|
||||
}
|
||||
let (fill, stroke, width) = (e.fill, e.stroke, e.width);
|
||||
if e.add_stroke && c.groups().any(|g| PATHS.contains(&g.match_id.as_str())) && !c.groups().any(is_stroke) {
|
||||
let at = c.children.iter().position(|n| matches!(n, Node::Group(g) if matches!(g.match_id.as_str(), "fill" | "gfill"))).unwrap_or(c.children.len());
|
||||
c.children.insert(at, build::shape_stroke(ids, stroke.unwrap_or([1.0; 4]), width.unwrap_or(2.0)).into());
|
||||
}
|
||||
|
|
@ -252,7 +395,7 @@ fn paint(c: &mut PropGroup, ids: &mut Ids, lt: Tick, fill: Option<[f64; 4]>, str
|
|||
"gstroke" => set(g, "width", width.map(KV::Scalar)),
|
||||
"group" => {
|
||||
if let Some(sub) = g.sub_mut("contents") {
|
||||
paint(sub, ids, lt, fill, stroke, width, add_stroke, depth + 1);
|
||||
paint(sub, ids, lt, e, depth + 1);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
|
|
@ -260,10 +403,20 @@ fn paint(c: &mut PropGroup, ids: &mut Ids, lt: Tick, fill: Option<[f64; 4]>, str
|
|||
}
|
||||
}
|
||||
|
||||
/// `p`'s `fillType` / `strokeType` (none|solid|linear|radial).
|
||||
fn kind_p(p: &Value, key: &str, c: &str) -> Result<Option<PaintKind>> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => v.as_str().and_then(PaintKind::from_name).map(Some).ok_or_else(|| bad(c, format!("`{key}`: none|solid|linear|radial"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The toolbar's Fill and Stroke options with shape layers selected (as in After Effects, they
|
||||
/// change the layers' fills and strokes): `fill` sets every Fill's colour, `stroke` every Stroke's,
|
||||
/// `strokeWidth` every stroke's width. A stroke colour or a width above 0 gives the paths that
|
||||
/// have no stroke one. Without values, reports the first layer's paint.
|
||||
/// change the layers' fills and strokes): `fillType` / `strokeType` switch every Fill / Stroke
|
||||
/// off (none) or turn them into Solid Color, Linear or Radial Gradient ones (adding one where a
|
||||
/// path has none), `fill` sets every Fill's colour, `stroke` every Stroke's, `strokeWidth` every
|
||||
/// stroke's width. A stroke colour or a width above 0 gives the paths that have no stroke one.
|
||||
/// Without values, reports the first layer's paint.
|
||||
fn fill_stroke(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "shape.fillStroke";
|
||||
let (cid, lids) = layers_p(s, p)?;
|
||||
|
|
@ -275,8 +428,10 @@ fn fill_stroke(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let rgba = |k: &str| color_p(p, k).map(|c| [c[0] as f64, c[1] as f64, c[2] as f64, 1.0]);
|
||||
let (fill, stroke) = (rgba("fill"), rgba("stroke"));
|
||||
let width = f_p(p, "strokeWidth").filter(|w| w.is_finite()).map(|w| w.max(0.0));
|
||||
if fill.is_some() || stroke.is_some() || width.is_some() {
|
||||
let add_stroke = width.map_or(stroke.is_some(), |w| w > 0.0);
|
||||
let (fill_kind, stroke_kind) = (kind_p(p, "fillType", c)?, kind_p(p, "strokeType", c)?);
|
||||
let add_stroke = stroke_kind != Some(PaintKind::None) && width.map_or(stroke.is_some(), |w| w > 0.0);
|
||||
let e = PaintEdit { fill, stroke, width, add_stroke, fill_kind, stroke_kind };
|
||||
if fill.is_some() || stroke.is_some() || width.is_some() || fill_kind.is_some() || stroke_kind.is_some() {
|
||||
let t = s.time();
|
||||
s.edit("Fill and Stroke", merge_p(p), |proj, _| {
|
||||
let mut next = proj.next_id;
|
||||
|
|
@ -284,7 +439,7 @@ fn fill_stroke(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let l = layer_mut(proj, cid, *lid)?;
|
||||
let lt = l.layer_time(t);
|
||||
if let Some(contents) = l.props.sub_mut("contents") {
|
||||
paint(contents, &mut Ids(&mut next), lt, fill, stroke, width, add_stroke, 0);
|
||||
paint(contents, &mut Ids(&mut next), lt, &e, 0);
|
||||
}
|
||||
}
|
||||
proj.next_id = next;
|
||||
|
|
@ -292,8 +447,10 @@ fn fill_stroke(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
})?;
|
||||
}
|
||||
let l = shapes.first().and_then(|id| s.project.comp(cid)?.layer(*id)).ok_or(EngineError::NoComp)?;
|
||||
let (fill, stroke, width) = first_paint(l, l.layer_time(s.time()));
|
||||
Ok(json!({"fill": fill, "stroke": stroke, "strokeWidth": width}))
|
||||
let lt = l.layer_time(s.time());
|
||||
let (fill, stroke, width) = first_paint(l, lt);
|
||||
let (fill_kind, stroke_kind) = first_paint_kinds(l, lt);
|
||||
Ok(json!({"fill": fill, "stroke": stroke, "strokeWidth": width, "fillType": fill_kind.id(), "strokeType": stroke_kind.id()}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
|
@ -303,7 +460,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Fill and Stroke",
|
||||
[],
|
||||
None,
|
||||
"{layers?, fill?: color, stroke?: color, strokeWidth?, merge?} → {fill, stroke, strokeWidth} (first layer)",
|
||||
"{layers?, fillType?: none|solid|linear|radial, strokeType?, fill?: color, stroke?: color, strokeWidth?, merge?} → {fill, stroke, strokeWidth, fillType, strokeType} (first layer)",
|
||||
has_layers,
|
||||
fill_stroke
|
||||
),
|
||||
|
|
|
|||
|
|
@ -139,12 +139,18 @@ fn blended(mut g: PropGroup, paint: &ToolPaint) -> PropGroup {
|
|||
g
|
||||
}
|
||||
|
||||
/// A gradient's Start and End Points across `bounds` (`[x, y, w, h]`): left to right, or out
|
||||
/// from the centre.
|
||||
pub(crate) fn gradient_ends(radial: bool, bounds: [f64; 4]) -> ([f64; 2], [f64; 2]) {
|
||||
let [x, y, w, h] = bounds;
|
||||
let mid = [x + w / 2.0, y + h / 2.0];
|
||||
if radial { (mid, [mid[0] + w.max(h).max(2.0) / 2.0, mid[1]]) } else { ([x, mid[1]], [x + w.max(1.0), mid[1]]) }
|
||||
}
|
||||
|
||||
/// The Stroke and Fill a new shape gets from `paint`; gradients run across `bounds` (`[x, y, w,
|
||||
/// h]` in the group's space): left to right, or out from the centre.
|
||||
pub(crate) fn paint_items(ids: &mut Ids, paint: &ShapeTool, bounds: [f64; 4]) -> Vec<PropGroup> {
|
||||
let [x, y, w, h] = bounds;
|
||||
let mid = [x + w / 2.0, y + h / 2.0];
|
||||
let ends = |radial: bool| if radial { (mid, [mid[0] + w.max(h).max(2.0) / 2.0, mid[1]]) } else { ([x, mid[1]], [x + w.max(1.0), mid[1]]) };
|
||||
let ends = |radial: bool| gradient_ends(radial, bounds);
|
||||
let mut items = vec![];
|
||||
let (s, width) = (&paint.stroke, paint.stroke_width);
|
||||
let stroke = match s.kind {
|
||||
|
|
|
|||
|
|
@ -10,10 +10,10 @@ use super::{CommandSpec, not_yet, not_yet_run};
|
|||
#[allow(unused_macros)]
|
||||
macro_rules! stub {
|
||||
($id:literal, $label:literal, [$($m:literal),*], $params:literal) => {
|
||||
CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: None, params: $params, enabled: not_yet, run: not_yet_run, journal: true }
|
||||
CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: None, alt_shortcuts: &[], params: $params, enabled: not_yet, run: not_yet_run, journal: true }
|
||||
};
|
||||
($id:literal, $label:literal, [$($m:literal),*], $sc:literal, $params:literal) => {
|
||||
CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: Some($sc), params: $params, enabled: not_yet, run: not_yet_run, journal: true }
|
||||
CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: Some($sc), alt_shortcuts: &[], params: $params, enabled: not_yet, run: not_yet_run, journal: true }
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -115,6 +115,13 @@ fn add_animator(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
if added == 0 && !kinds.is_empty() {
|
||||
return Err(bad(C, "unknown animator property"));
|
||||
}
|
||||
// Like After Effects, a scripted animator has no selectors until the script adds one
|
||||
// (addProperty("ADBE Text Selector")); a default 0-100% one would select every character.
|
||||
if kinds.is_empty()
|
||||
&& let Some(sels) = g.sub_mut("selectors")
|
||||
{
|
||||
sels.children.clear();
|
||||
}
|
||||
let uid = g.uid;
|
||||
anims.children.push(g.into());
|
||||
proj.next_id = next;
|
||||
|
|
@ -246,7 +253,7 @@ fn enable_per_char(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
}
|
||||
|
||||
/// JSON → property value shaped like `like`.
|
||||
fn json_value(v: &Value, like: &KV) -> Option<KV> {
|
||||
pub(super) fn json_value(v: &Value, like: &KV) -> Option<KV> {
|
||||
Some(match v {
|
||||
Value::Bool(b) => match like {
|
||||
KV::Bool(_) => KV::Bool(*b),
|
||||
|
|
@ -431,7 +438,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Animate Text",
|
||||
[],
|
||||
None,
|
||||
"{layer?, properties: [anchor|position|scale|skew|rotation|opacity|transformAll|lineAnchor|lineSpacing|characterOffset|characterValue|blur|fillColor|fillHue|fillSaturation|fillBrightness|fillOpacity|strokeColor|strokeHue|strokeSaturation|strokeBrightness|strokeOpacity|strokeWidth|tracking|perChar3d], name?}",
|
||||
"{layer?, properties: [anchor|position|scale|skew|rotation|opacity|transformAll|lineAnchor|lineSpacing|characterOffset|characterValue|blur|fillColor|fillHue|fillSaturation|fillBrightness|fillOpacity|strokeColor|strokeHue|strokeSaturation|strokeBrightness|strokeOpacity|strokeWidth|tracking|frontColor|bevelColor|sideColor|backColor|perChar3d], name?}",
|
||||
has_text_layer,
|
||||
add_animator
|
||||
),
|
||||
|
|
|
|||
|
|
@ -506,7 +506,9 @@ pub fn fonts(_s: &mut Session, p: &Value) -> Result<Value> {
|
|||
use effectcraft_text::fonts;
|
||||
let added = if p.get("rescan").and_then(Value::as_bool).unwrap_or(false) { fonts::rescan_system() } else { 0 };
|
||||
let query = p.get("query").and_then(Value::as_str).map(str::to_lowercase).unwrap_or_default();
|
||||
let only_variable = p.get("variable").and_then(Value::as_bool).unwrap_or(false);
|
||||
let native = fonts::native_families();
|
||||
let variable = fonts::variable_families();
|
||||
let mut origin: std::collections::BTreeMap<String, &'static str> = Default::default();
|
||||
for f in fonts::all_faces() {
|
||||
origin.entry(f.info.family.clone()).or_insert(f.info.origin);
|
||||
|
|
@ -514,8 +516,9 @@ pub fn fonts(_s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let list: Vec<Value> = effectcraft_text::families()
|
||||
.into_iter()
|
||||
.filter(|(f, _)| query.is_empty() || f.to_lowercase().contains(&query) || native.get(f).is_some_and(|n| n.to_lowercase().contains(&query)))
|
||||
.filter(|(f, _)| !only_variable || variable.contains(f))
|
||||
.map(|(f, styles)| {
|
||||
let mut v = json!({"family": f, "styles": styles, "origin": origin.get(&f).copied().unwrap_or("system")});
|
||||
let mut v = json!({"family": f, "styles": styles, "origin": origin.get(&f).copied().unwrap_or("system"), "variable": variable.contains(&f)});
|
||||
if let Some(n) = native.get(&f) {
|
||||
v["nativeName"] = json!(n);
|
||||
}
|
||||
|
|
@ -559,7 +562,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"List Fonts",
|
||||
[],
|
||||
None,
|
||||
"{query?, rescan?} → {count, families: [{family, styles, origin: bundled|system|user, nativeName?}], added} (rescan picks up fonts installed since launch)",
|
||||
"{query?, variable?, rescan?} → {count, families: [{family, styles, origin: bundled|system|user, variable, nativeName?}], added} (variable: only variable fonts; rescan picks up fonts installed since launch)",
|
||||
always_ok,
|
||||
fonts
|
||||
),
|
||||
|
|
|
|||
|
|
@ -320,6 +320,21 @@ fn set_3d_view(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(switch_view(s, cid, v))
|
||||
}
|
||||
|
||||
/// The view of one pane of the View Layout: pane 0 is the main view (the same as Switch 3D View),
|
||||
/// panes 1 to 3 are the extra views of a 2- or 4-view layout. UI state, not part of the project.
|
||||
fn set_pane_view(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = comp_id(s, p)?;
|
||||
let v = view_p(p, "view.setPaneView")?;
|
||||
let pane = p.get("pane").and_then(Value::as_u64).filter(|n| *n <= 3).ok_or_else(|| bad("view.setPaneView", "pane: 0 (main view) to 3"))? as usize;
|
||||
if pane == 0 {
|
||||
switch_view(s, cid, v);
|
||||
} else {
|
||||
views(s, cid).set_pane(pane - 1, v);
|
||||
}
|
||||
let vs = views(s, cid);
|
||||
Ok(json!({"pane": pane, "view": v.id(), "views": [vs.current.id(), vs.pane(0).id(), vs.pane(1).id(), vs.pane(2).id()]}))
|
||||
}
|
||||
|
||||
macro_rules! view_cmd {
|
||||
($f:ident, $v:expr) => {
|
||||
fn $f(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
|
@ -706,6 +721,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
has_comp,
|
||||
set_3d_view
|
||||
),
|
||||
cmd!(
|
||||
"view.setPaneView",
|
||||
"Set View of Pane",
|
||||
[],
|
||||
None,
|
||||
"{pane: 0 (main) | 1 | 2 | 3, view: activeCamera|default|front|left|top|back|right|bottom|custom1|custom2|custom3, comp?}",
|
||||
has_comp,
|
||||
set_pane_view
|
||||
),
|
||||
cmd!("view.3d.activeCamera", "Active Camera", ["View", "Switch 3D View"], Some("F12"), "{comp?}", has_comp, view_active),
|
||||
cmd!("view.3d.default", "Default", ["View", "Switch 3D View"], None, "{comp?}", has_comp, view_default),
|
||||
cmd!("view.3d.front", "Front", ["View", "Switch 3D View"], Some("F10"), "{comp?}", has_comp, view_front),
|
||||
|
|
|
|||
|
|
@ -39,7 +39,10 @@ pub fn parse_display_time(s: &Session, comp: &Comp, text: &str, current: i64) ->
|
|||
effectcraft_time::parse_feet_frames(text, pf, current + start).map(|f| f - start).map_err(|e| e.0)
|
||||
}
|
||||
None => {
|
||||
let parsed = effectcraft_time::parse_timecode(text, fr, fr.supports_drop_frame(), current).map_err(|e| e.0)?;
|
||||
// The Timeline shows non-drop timecode (`:`), so typed `:`/`.` text is read as non-drop
|
||||
// too; only explicit `;` fields mean drop-frame (#890).
|
||||
let drop_frame = fr.supports_drop_frame() && text.contains(';');
|
||||
let parsed = effectcraft_time::parse_timecode(text, fr, drop_frame, current).map_err(|e| e.0)?;
|
||||
let relative = text.trim().starts_with('+') || text.trim().starts_with('-');
|
||||
if relative {
|
||||
Ok(parsed)
|
||||
|
|
@ -61,8 +64,9 @@ pub fn parse_display_time(s: &Session, comp: &Comp, text: &str, current: i64) ->
|
|||
|
||||
fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let comp = s.active_comp().ok_or(EngineError::NoComp)?;
|
||||
let t = if let Some(f) = p.get("frame").and_then(Value::as_i64) {
|
||||
comp.frame_rate.tick_of(f)
|
||||
// Dialog number fields send floats (`5.0`), so a frame is any JSON number, rounded.
|
||||
let t = if let Some(f) = f_p(p, "frame") {
|
||||
comp.frame_rate.tick_of(f.round() as i64)
|
||||
} else if let Some(tc) = str_p(p, "timecode") {
|
||||
let cur = comp.frame_rate.frame_at(s.time());
|
||||
let f = parse_display_time(s, comp, tc, cur).map_err(|e| super::bad("time.set", e))?;
|
||||
|
|
@ -169,10 +173,10 @@ fn with_to(p: &Value, to: &str) -> Value {
|
|||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("time.set", "Go to Time...", ["View"], Some("Alt+Shift+J"), "{time? (s) | frame? | timecode?}", has_comp, set),
|
||||
cmd!("time.nextFrame", "Next Frame", [], Some("PageDown"), "{}", has_comp, |s, _| step(s, &json!({"frames": 1}))),
|
||||
cmd!("time.previousFrame", "Previous Frame", [], Some("PageUp"), "{}", has_comp, |s, _| step(s, &json!({"frames": -1}))),
|
||||
cmd!("time.forward10", "Forward 10 Frames", [], Some("Shift+PageDown"), "{}", has_comp, |s, _| step(s, &json!({"frames": 10}))),
|
||||
cmd!("time.back10", "Back 10 Frames", [], Some("Shift+PageUp"), "{}", has_comp, |s, _| step(s, &json!({"frames": -10}))),
|
||||
cmd!("time.nextFrame", "Next Frame", [], ["PageDown", "Cmd+ArrowRight"], "{}", has_comp, |s, _| step(s, &json!({"frames": 1}))),
|
||||
cmd!("time.previousFrame", "Previous Frame", [], ["PageUp", "Cmd+ArrowLeft"], "{}", has_comp, |s, _| step(s, &json!({"frames": -1}))),
|
||||
cmd!("time.forward10", "Forward 10 Frames", [], ["Shift+PageDown", "Cmd+Shift+ArrowRight"], "{}", has_comp, |s, _| step(s, &json!({"frames": 10}))),
|
||||
cmd!("time.back10", "Back 10 Frames", [], ["Shift+PageUp", "Cmd+Shift+ArrowLeft"], "{}", has_comp, |s, _| step(s, &json!({"frames": -10}))),
|
||||
cmd!("time.step", "Step Frames", [], None, "{frames}", has_comp, step),
|
||||
cmd!("time.start", "Go to Start", [], Some("Home"), "{}", has_comp, |s, _| go(s, &json!({"to": "start"}))),
|
||||
cmd!("time.end", "Go to End", [], Some("End"), "{}", has_comp, |s, _| go(s, &json!({"to": "end"}))),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
//! View menu commands that change the project (guides) or headless-relevant editor state (the
|
||||
//! region of interest).
|
||||
//! View menu commands that change the project (guides, each comp's Resolution) or
|
||||
//! headless-relevant editor state (the region of interest).
|
||||
|
||||
use effectcraft_project::Guide;
|
||||
use effectcraft_project::{Guide, Resolution};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, comp_id, f_p, has_comp, str_p};
|
||||
|
|
@ -75,6 +75,24 @@ fn region_of_interest(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(json!(s.state.region_of_interest))
|
||||
}
|
||||
|
||||
/// View ▸ Resolution (and the Composition panel's Resolution popup): the comp's Resolution, saved
|
||||
/// with the project but no undo step ([`Session::set_resolution`]).
|
||||
fn set_resolution(s: &mut Session, p: &Value, r: Resolution) -> Result<Value> {
|
||||
let cid = comp_id(s, p)?;
|
||||
s.set_resolution(cid, r)?;
|
||||
Ok(json!({"comp": cid.0, "resolution": r.label(), "factor": r.factor()}))
|
||||
}
|
||||
|
||||
/// View ▸ Resolution ▸ Custom: `factor` renders every n-th pixel; without one the frontend asks
|
||||
/// for it (the Custom Resolution dialog).
|
||||
fn custom_resolution(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
match f_p(p, "factor") {
|
||||
Some(n) => set_resolution(s, p, Resolution::from_factor(n.round().clamp(1.0, 40.0) as u64)),
|
||||
None if p.get("factor").is_some_and(|v| !v.is_null()) => Err(bad("view.res.custom", "factor: a number from 1 to 40")),
|
||||
None => super::frontend(s, "view.res.custom", p),
|
||||
}
|
||||
}
|
||||
|
||||
fn view_layout(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let n = p.get("views").and_then(Value::as_u64).ok_or_else(|| bad("view.layout", "views: 1|2|4"))?;
|
||||
if !matches!(n, 1 | 2 | 4) {
|
||||
|
|
@ -101,12 +119,26 @@ fn extended_viewer(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(json!(v))
|
||||
}
|
||||
|
||||
/// A `view.res.*` command setting one [`Resolution`].
|
||||
macro_rules! res {
|
||||
($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $r:ident) => {
|
||||
cmd!($id, $label, [$($m),*], $sc, "{comp?}", has_comp, |s, p| set_resolution(s, p, Resolution::$r))
|
||||
};
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("view.addGuide", "Add Guide...", ["View"], None, "{orientation?: vertical|horizontal, position? (comp px)}", has_comp, add_guide),
|
||||
cmd!("view.clearGuides", "Clear Guides", ["View"], None, "{comp?}", has_guides, clear_guides),
|
||||
cmd!("view.importGuides", "Import Guides...", ["View"], None, "{path}", has_comp, import_guides),
|
||||
cmd!("view.exportGuides", "Export Guides...", ["View"], None, "{path}", has_guides, export_guides),
|
||||
res!("view.res.full", "Full", ["View", "Resolution"], Some("Cmd+J"), Full),
|
||||
res!("view.res.half", "Half", ["View", "Resolution"], Some("Cmd+Shift+J"), Half),
|
||||
res!("view.res.third", "Third", ["View", "Resolution"], None, Third),
|
||||
res!("view.res.quarter", "Quarter", ["View", "Resolution"], Some("Cmd+Alt+Shift+J"), Quarter),
|
||||
cmd!("view.res.custom", "Custom...", ["View", "Resolution"], None, "{factor?: 1..40 (render every n-th pixel), comp?}", has_comp, custom_resolution),
|
||||
// The Composition panel's Resolution popup (not in the View menu).
|
||||
res!("view.res.auto", "Resolution: Auto", [], None, Auto),
|
||||
cmd!("view.layout", "Switch View Layout", [], None, "{views: 1|2|4}", has_comp, view_layout),
|
||||
cmd!("view.shareViewOptions", "Share View Options", ["View", "Switch View Layout"], None, "{value?}", has_comp, share_view_options),
|
||||
cmd!("view.extendedViewer", "Extended Viewer", [], None, "{value?}", always, extended_viewer),
|
||||
|
|
|
|||
|
|
@ -210,7 +210,13 @@ mod tests {
|
|||
let mut paths: Vec<String> =
|
||||
o.project.items.values().filter_map(|i| if let ItemKind::Footage(f) = &i.kind { Some(f.path.clone()) } else { None }).collect();
|
||||
paths.sort();
|
||||
assert_eq!(paths, ["/nonexistent/ec/abs.png", clip.to_str().unwrap(), "media/ec-relative-gone.png"]);
|
||||
// Windows joins the stored "media/..." onto the project's folder and leaves a path with mixed
|
||||
// separators; `Path` equality compares components, so both spellings are the same file.
|
||||
let expected = ["/nonexistent/ec/abs.png", clip.to_str().unwrap(), "media/ec-relative-gone.png"];
|
||||
assert_eq!(paths.len(), expected.len(), "{paths:?}");
|
||||
for (got, want) in paths.iter().zip(expected) {
|
||||
assert_eq!(std::path::Path::new(got), std::path::Path::new(want), "{paths:?}");
|
||||
}
|
||||
assert!(!o.is_dirty(), "finding the files is no edit");
|
||||
let r = o.execute("footage.check", json!({"wait": true})).unwrap();
|
||||
assert_eq!(r["missing"], 2);
|
||||
|
|
|
|||
|
|
@ -341,7 +341,8 @@ impl Session {
|
|||
let k = new_line.iter().position(|&i| i == target).unwrap_or(0);
|
||||
let undo: Vec<(String, Arc<Project>)> = (0..k).map(|i| (a.label[new_line[i + 1]].clone(), a.state[new_line[i]].clone())).collect();
|
||||
let redo: Vec<(String, Arc<Project>)> = new_line[k + 1..].iter().rev().map(|&i| (a.label[i].clone(), a.state[i].clone())).collect();
|
||||
self.project = a.state[target].clone();
|
||||
let cur = std::mem::replace(&mut self.project, a.state[target].clone());
|
||||
self.keep_view_settings(&cur);
|
||||
self.history.undo = undo;
|
||||
self.history.redo = redo;
|
||||
self.history.branches = branches;
|
||||
|
|
|
|||
|
|
@ -350,6 +350,15 @@ pub enum Event {
|
|||
},
|
||||
/// Drop cached frames / renders (Edit ▸ Purge).
|
||||
PurgeCaches,
|
||||
/// Put an image on the system clipboard (Composition ▸ Copy Frame to Clipboard): `rgba` is
|
||||
/// `width` × `height` straight-alpha RGBA8 rows. Headless sessions ignore it; the pixels are
|
||||
/// not serialized.
|
||||
CopyImage {
|
||||
width: u32,
|
||||
height: u32,
|
||||
#[serde(skip)]
|
||||
rgba: Vec<u8>,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct Session {
|
||||
|
|
@ -698,9 +707,38 @@ impl Session {
|
|||
self.events.push(Event::ProjectChanged { revision: self.revision });
|
||||
}
|
||||
|
||||
/// Set a comp's Resolution. As in After Effects it is saved with the project (which becomes
|
||||
/// modified) but is no undo step, and it changes no pixels: the comp's cached frames stay
|
||||
/// valid ([`effectcraft_project::Comp::same_pixels`]).
|
||||
pub fn set_resolution(&mut self, comp: ItemId, r: effectcraft_project::Resolution) -> Result<()> {
|
||||
if self.project.comp(comp).ok_or(EngineError::NoComp)?.resolution == r {
|
||||
return Ok(());
|
||||
}
|
||||
Arc::make_mut(&mut self.project).comp_mut(comp).ok_or(EngineError::NoComp)?.resolution = r;
|
||||
self.bump();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Keep what lives outside the undo history (each comp's Resolution) as it is in `from`, the
|
||||
/// project before undo, redo or a history jump restored another state.
|
||||
pub(crate) fn keep_view_settings(&mut self, from: &Project) {
|
||||
let keep: Vec<(ItemId, effectcraft_project::Resolution)> =
|
||||
self.project.comps().filter_map(|(id, c)| from.comp(*id).map(|f| (*id, f.resolution)).filter(|(_, r)| *r != c.resolution)).collect();
|
||||
if keep.is_empty() {
|
||||
return;
|
||||
}
|
||||
let p = Arc::make_mut(&mut self.project);
|
||||
for (id, r) in keep {
|
||||
if let Some(c) = p.comp_mut(id) {
|
||||
c.resolution = r;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn undo(&mut self) -> bool {
|
||||
let Some((label, p)) = self.history.undo.pop() else { return false };
|
||||
let cur = std::mem::replace(&mut self.project, p);
|
||||
self.keep_view_settings(&cur);
|
||||
self.history.redo.push((label, cur));
|
||||
self.history.merge_key = None;
|
||||
self.sanitize_state();
|
||||
|
|
@ -711,6 +749,7 @@ impl Session {
|
|||
pub fn redo(&mut self) -> bool {
|
||||
let Some((label, p)) = self.history.redo.pop() else { return false };
|
||||
let cur = std::mem::replace(&mut self.project, p);
|
||||
self.keep_view_settings(&cur);
|
||||
self.history.undo.push((label, cur));
|
||||
self.history.merge_key = None;
|
||||
self.sanitize_state();
|
||||
|
|
@ -850,6 +889,26 @@ impl Session {
|
|||
self.events.push(Event::OpenComp(id));
|
||||
}
|
||||
|
||||
/// The open compositions, as a project file records them ([`effectcraft_project::SavedView`]).
|
||||
pub fn saved_view(&self) -> effectcraft_project::SavedView {
|
||||
effectcraft_project::SavedView { active_comp: self.state.active_comp, open_comps: self.state.open_comps.clone() }
|
||||
}
|
||||
|
||||
/// Reopen the compositions a project file was saved with (#748). Ids that no longer name a
|
||||
/// comp are skipped; with none left the project stays on the comp [`Session::replace_project`]
|
||||
/// opened (its first), as files from before the view was saved do.
|
||||
pub fn restore_view(&mut self, view: &effectcraft_project::SavedView) {
|
||||
let mut open: Vec<ItemId> = Vec::new();
|
||||
for id in view.open_comps.iter().copied().filter(|id| self.project.comp(*id).is_some()) {
|
||||
if !open.contains(&id) {
|
||||
open.push(id);
|
||||
}
|
||||
}
|
||||
let Some(active) = view.active_comp.filter(|id| self.project.comp(*id).is_some()).or_else(|| open.first().copied()) else { return };
|
||||
self.state.open_comps = open;
|
||||
self.open_comp(active);
|
||||
}
|
||||
|
||||
pub fn toast(&mut self, msg: impl Into<String>) {
|
||||
self.events.push(Event::Toast { message: msg.into(), error: false });
|
||||
}
|
||||
|
|
@ -980,6 +1039,7 @@ impl Session {
|
|||
self.roto_job = None;
|
||||
self.roto_pending.clear();
|
||||
self.drop_tasks();
|
||||
self.script_ui.project_epoch = self.script_ui.project_epoch.wrapping_add(1);
|
||||
p.fix_next_id();
|
||||
upgrade_effects(&mut p);
|
||||
self.project = Arc::new(p);
|
||||
|
|
@ -1046,6 +1106,8 @@ mod tests;
|
|||
#[cfg(test)]
|
||||
mod tests_3d;
|
||||
#[cfg(test)]
|
||||
mod tests_anim_presets;
|
||||
#[cfg(test)]
|
||||
mod tests_anim_tools;
|
||||
#[cfg(test)]
|
||||
mod tests_camera_track;
|
||||
|
|
@ -1068,10 +1130,14 @@ mod tests_fidelity;
|
|||
#[cfg(test)]
|
||||
mod tests_frame_export;
|
||||
#[cfg(test)]
|
||||
mod tests_fuzz_open;
|
||||
#[cfg(test)]
|
||||
mod tests_key_metadata_order;
|
||||
#[cfg(test)]
|
||||
mod tests_keylight;
|
||||
#[cfg(test)]
|
||||
mod tests_limits;
|
||||
#[cfg(test)]
|
||||
mod tests_lottie;
|
||||
#[cfg(test)]
|
||||
mod tests_m137;
|
||||
|
|
@ -1124,6 +1190,11 @@ pub fn text_presets() -> Vec<(String, String)> {
|
|||
commands::text_preset_list()
|
||||
}
|
||||
|
||||
/// Built-in animation presets (Effects & Presets ▸ * Animation Presets): (id, name, category).
|
||||
pub fn animation_presets() -> Vec<(String, String, String)> {
|
||||
commands::builtin_preset_list()
|
||||
}
|
||||
|
||||
pub fn text_families() -> Vec<String> {
|
||||
effectcraft_text::families().into_iter().map(|(f, _)| f).collect()
|
||||
}
|
||||
|
|
@ -1153,6 +1224,8 @@ pub struct FontRow {
|
|||
/// Settings ▸ Type ▸ Show Font Names in English is off.
|
||||
pub display: String,
|
||||
pub recent: bool,
|
||||
/// A variable font (the menu's Variable Fonts filter keeps only these).
|
||||
pub variable: bool,
|
||||
}
|
||||
|
||||
/// Outline polylines of "Sample" set in `family` at `size` px (baseline at y = 0, y down), for
|
||||
|
|
@ -1193,14 +1266,21 @@ pub fn font_preview(family: &str, size: f64) -> Vec<Vec<[f32; 2]>> {
|
|||
}
|
||||
|
||||
/// The Character panel's font menu: the recent fonts (Settings ▸ Type ▸ Number of Recent Fonts
|
||||
/// to Display), a separator, then every family.
|
||||
/// to Display), a separator, then every family. Each row says whether the family is variable,
|
||||
/// for the menu's Variable Fonts filter.
|
||||
pub fn font_menu(prefs: &prefs::Prefs) -> Vec<FontRow> {
|
||||
let all = text_families();
|
||||
let native = if prefs.type_.font_names_in_english { Default::default() } else { effectcraft_text::fonts::native_families() };
|
||||
let row = |f: &String, recent: bool| FontRow { family: f.clone(), display: native.get(f).cloned().unwrap_or_else(|| f.clone()), recent };
|
||||
let variable = effectcraft_text::fonts::variable_families();
|
||||
let row = |f: &String, recent: bool| FontRow {
|
||||
family: f.clone(),
|
||||
display: native.get(f).cloned().unwrap_or_else(|| f.clone()),
|
||||
recent,
|
||||
variable: variable.contains(f),
|
||||
};
|
||||
let mut out: Vec<FontRow> = prefs.recent_fonts_shown().iter().filter(|f| all.contains(f)).map(|f| row(f, true)).collect();
|
||||
if !out.is_empty() {
|
||||
out.push(FontRow { family: String::new(), display: "-".into(), recent: false });
|
||||
out.push(FontRow { family: String::new(), display: "-".into(), recent: false, variable: false });
|
||||
}
|
||||
out.extend(all.iter().map(|f| row(f, false)));
|
||||
out
|
||||
|
|
|
|||
|
|
@ -1,25 +1,47 @@
|
|||
//! Help ▸ Enable Logging / Reveal Logging File: a `log` logger that, while enabled, appends
|
||||
//! every record to `Logs/EffectCraft Log.txt` in the settings folder (or the temporary folder),
|
||||
//! and always keeps the last few hundred warnings and errors in memory for the System
|
||||
//! Compatibility Report.
|
||||
//! The `log` logger. Records go to standard error and, on the desktop, to a session log that
|
||||
//! every launch starts afresh: `Logs/effectcraft.log` in the settings folder, with the two runs
|
||||
//! before it kept as `effectcraft.1.log` and `effectcraft.2.log`, so the log of a run that
|
||||
//! crashed survives the next start. A launch from the Dock, the Start menu or a launcher has no
|
||||
//! terminal, so that file is what a bug report can attach. The last few hundred warnings and
|
||||
//! errors are also kept in memory for the System Compatibility Report.
|
||||
//!
|
||||
//! Frontends call [`install`] once at startup; [`set_enabled`] (the `help.enableLogging`
|
||||
//! command) turns file logging on and off. `RUST_LOG` (a level such as `debug`, or per target:
|
||||
//! `effectcraft=debug,wgpu=warn`) also prints what it lets through to stderr, so a start-up that
|
||||
//! fails before the window opens leaves a trace.
|
||||
//! Levels: `info` for EffectCraft's own crates, `warn` for everything else (wgpu and naga are
|
||||
//! chatty). `RUST_LOG` replaces that (a level such as `debug`, or per target:
|
||||
//! `effectcraft=debug,wgpu=warn`).
|
||||
//!
|
||||
//! Help ▸ Enable Logging ([`set_enabled`], the `help.enableLogging` command) additionally appends
|
||||
//! every EffectCraft record to `Logs/EffectCraft Log.txt`, across launches.
|
||||
//!
|
||||
//! Frontends call [`install`] once at startup and the desktop app [`attach_session_log`] once the
|
||||
//! settings folder is known; records logged before that are kept (up to [`MAX_PENDING`]) and
|
||||
//! written first. Writing never panics: a file that can't be created or written leaves standard
|
||||
//! error as the only sink. Never log the control-channel token.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::sync::{Mutex, OnceLock, PoisonError};
|
||||
|
||||
use log::LevelFilter;
|
||||
|
||||
/// The session log's name inside the `Logs` folder.
|
||||
pub const SESSION_LOG: &str = "effectcraft.log";
|
||||
/// How many previous session logs are kept (`effectcraft.1.log` … `effectcraft.<KEEP>.log`).
|
||||
pub const KEEP: usize = 2;
|
||||
/// The session log stops growing past this size (a runaway warning can't fill the disk).
|
||||
pub const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024;
|
||||
/// Records kept in memory until the session log is attached.
|
||||
pub const MAX_PENDING: usize = 512;
|
||||
/// The filter when `RUST_LOG` is unset or empty.
|
||||
pub const DEFAULT_FILTER: &str = "warn,effectcraft=info";
|
||||
|
||||
static ENABLED: AtomicBool = AtomicBool::new(false);
|
||||
/// The `RUST_LOG` filter for stderr: (target prefix or every target, level).
|
||||
static STDERR: OnceLock<Vec<(Option<String>, LevelFilter)>> = OnceLock::new();
|
||||
/// The filter for standard error and the session log: (target prefix or every target, level).
|
||||
static FILTER: OnceLock<Vec<(Option<String>, LevelFilter)>> = OnceLock::new();
|
||||
static STATE: Mutex<State> = Mutex::new(State { file: None, recent: VecDeque::new() });
|
||||
static SESSION: Mutex<Sink> = Mutex::new(Sink::new(MAX_FILE_BYTES));
|
||||
|
||||
struct State {
|
||||
file: Option<PathBuf>,
|
||||
|
|
@ -32,15 +54,16 @@ const RECENT: usize = 300;
|
|||
|
||||
impl log::Log for Logger {
|
||||
fn enabled(&self, m: &log::Metadata) -> bool {
|
||||
m.level() <= log::Level::Warn || (ENABLED.load(Ordering::Relaxed) && m.target().starts_with("effectcraft")) || to_stderr(m)
|
||||
m.level() <= log::Level::Warn || (ENABLED.load(Ordering::Relaxed) && m.target().starts_with("effectcraft")) || shown(m)
|
||||
}
|
||||
fn log(&self, r: &log::Record) {
|
||||
if !self.enabled(r.metadata()) {
|
||||
return;
|
||||
}
|
||||
let line = format!("{} {:<5} {}: {}", unix_secs(), r.level(), r.target(), r.args());
|
||||
if to_stderr(r.metadata()) {
|
||||
eprintln!("{line}");
|
||||
let line = format_line(×tamp(unix_millis()), r.level(), &thread_name(), r.target(), &r.args().to_string());
|
||||
if shown(r.metadata()) {
|
||||
let _ = writeln!(std::io::stderr(), "{line}");
|
||||
SESSION.lock().unwrap_or_else(PoisonError::into_inner).write(&format!("{line}\n"));
|
||||
}
|
||||
let Ok(mut st) = STATE.lock() else { return };
|
||||
if r.level() <= log::Level::Warn {
|
||||
|
|
@ -59,22 +82,62 @@ impl log::Log for Logger {
|
|||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
fn unix_secs() -> u64 {
|
||||
web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
||||
fn unix_millis() -> u64 {
|
||||
web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX)).unwrap_or(0)
|
||||
}
|
||||
|
||||
fn thread_name() -> String {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
return std::thread::current().name().unwrap_or("?").to_string();
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
return "main".to_string();
|
||||
}
|
||||
|
||||
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds) for `ms` milliseconds since 1970.
|
||||
fn timestamp(ms: u64) -> String {
|
||||
let secs = ms / 1000;
|
||||
let (days, rem) = (secs / 86_400, secs % 86_400);
|
||||
let (y, m, d) = civil_from_days(days);
|
||||
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, ms % 1000)
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
|
||||
/// `civil_from_days`, unsigned since the clock is clamped at the epoch). `days` is at most
|
||||
/// `u64::MAX / 86_400_000`, so none of the arithmetic below can overflow.
|
||||
fn civil_from_days(days: u64) -> (u64, u64, u64) {
|
||||
let z = days + 719_468;
|
||||
let era = z / 146_097;
|
||||
let doe = z % 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = yoe + era * 400 + u64::from(m <= 2);
|
||||
(y, m, d)
|
||||
}
|
||||
|
||||
/// One log line (without the newline): `<timestamp> <LEVEL> [<thread>] <target>: <message>`.
|
||||
fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String {
|
||||
let message = message.strip_suffix('\n').unwrap_or(message);
|
||||
format!("{ts} {level:<5} [{thread}] {target}: {message}")
|
||||
}
|
||||
|
||||
/// Install the logger (once; later calls and other installed loggers are left alone).
|
||||
pub fn install() {
|
||||
static LOGGER: Logger = Logger;
|
||||
if log::set_logger(&LOGGER).is_ok() {
|
||||
let stderr = STDERR.get_or_init(|| std::env::var("RUST_LOG").map(|s| parse_filter(&s)).unwrap_or_default());
|
||||
log::set_max_level(stderr.iter().map(|d| d.1).fold(LevelFilter::Info, Ord::max));
|
||||
let filter = FILTER.get_or_init(|| {
|
||||
let spec = std::env::var("RUST_LOG").ok().filter(|s| !s.trim().is_empty());
|
||||
parse_filter(spec.as_deref().unwrap_or(DEFAULT_FILTER))
|
||||
});
|
||||
log::set_max_level(filter.iter().map(|d| d.1).fold(LevelFilter::Info, Ord::max));
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `RUST_LOG` lets a record through to stderr.
|
||||
fn to_stderr(m: &log::Metadata) -> bool {
|
||||
STDERR.get().is_some_and(|f| m.level() <= level_for(f, m.target()))
|
||||
/// Whether the filter lets a record through to standard error and the session log.
|
||||
fn shown(m: &log::Metadata) -> bool {
|
||||
FILTER.get().is_some_and(|f| m.level() <= level_for(f, m.target()))
|
||||
}
|
||||
|
||||
/// `RUST_LOG`'s directives, as env_logger reads them: `level`, `target=level` or a bare target
|
||||
|
|
@ -110,9 +173,112 @@ pub fn install_panic_hook() {
|
|||
}));
|
||||
}
|
||||
|
||||
/// The log file inside `config_dir` (the settings folder), or the temporary folder.
|
||||
/// The `Logs` folder inside `config_dir` (the settings folder), or in the temporary folder.
|
||||
pub fn log_dir(config_dir: Option<&Path>) -> PathBuf {
|
||||
config_dir.map(Path::to_path_buf).unwrap_or_else(std::env::temp_dir).join("Logs")
|
||||
}
|
||||
|
||||
/// Help ▸ Enable Logging's file inside `config_dir` (the settings folder), or the temporary folder.
|
||||
pub fn log_path(config_dir: Option<&Path>) -> PathBuf {
|
||||
config_dir.map(Path::to_path_buf).unwrap_or_else(std::env::temp_dir).join("Logs").join("EffectCraft Log.txt")
|
||||
log_dir(config_dir).join("EffectCraft Log.txt")
|
||||
}
|
||||
|
||||
fn numbered(dir: &Path, n: usize) -> PathBuf {
|
||||
if n == 0 { dir.join(SESSION_LOG) } else { dir.join(format!("effectcraft.{n}.log")) }
|
||||
}
|
||||
|
||||
/// Shift the previous session logs up one (`.log` → `.1.log` → … → `.<KEEP>.log`, the oldest
|
||||
/// dropped) and create a fresh, empty log file in `dir` (created if missing).
|
||||
fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
for n in (1..=KEEP).rev() {
|
||||
match std::fs::rename(numbered(dir, n - 1), numbered(dir, n)) {
|
||||
Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let path = numbered(dir, 0);
|
||||
let file = File::create(&path)?;
|
||||
Ok((path, file))
|
||||
}
|
||||
|
||||
/// Rotate the session logs in `dir` (the `Logs` folder, see [`log_dir`]) and write records to
|
||||
/// a fresh `effectcraft.log` there, first the ones logged so far; returns its path.
|
||||
pub fn attach_session_log(dir: &Path) -> Result<PathBuf, String> {
|
||||
let (path, file) = rotate(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
let mut sink = SESSION.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
sink.attach(file);
|
||||
sink.path = Some(path.clone());
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// This run's session log (once [`attach_session_log`] succeeded).
|
||||
pub fn session_file() -> Option<PathBuf> {
|
||||
SESSION.lock().unwrap_or_else(PoisonError::into_inner).path.clone()
|
||||
}
|
||||
|
||||
/// Where lines go besides standard error: kept in memory until a file is attached, then
|
||||
/// appended to it until it reaches its size cap.
|
||||
struct Sink {
|
||||
file: Option<File>,
|
||||
path: Option<PathBuf>,
|
||||
pending: Vec<String>,
|
||||
dropped: usize,
|
||||
written: u64,
|
||||
max_bytes: u64,
|
||||
capped: bool,
|
||||
}
|
||||
|
||||
impl Sink {
|
||||
const fn new(max_bytes: u64) -> Sink {
|
||||
Sink { file: None, path: None, pending: Vec::new(), dropped: 0, written: 0, max_bytes, capped: false }
|
||||
}
|
||||
|
||||
/// Append `line` to the file, or keep it until [`Sink::attach`].
|
||||
fn write(&mut self, line: &str) {
|
||||
if self.file.is_none() && !self.capped {
|
||||
if self.pending.len() < MAX_PENDING {
|
||||
self.pending.push(line.to_owned());
|
||||
} else {
|
||||
self.dropped = self.dropped.saturating_add(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
self.append(line);
|
||||
}
|
||||
|
||||
/// Start writing to `file`, first the lines kept so far.
|
||||
fn attach(&mut self, file: File) {
|
||||
self.file = Some(file);
|
||||
for line in std::mem::take(&mut self.pending) {
|
||||
self.append(&line);
|
||||
}
|
||||
if self.dropped > 0 {
|
||||
let note = format!("{} earlier log lines were dropped before the log file was opened\n", self.dropped);
|
||||
self.dropped = 0;
|
||||
self.append(¬e);
|
||||
}
|
||||
}
|
||||
|
||||
fn append(&mut self, line: &str) {
|
||||
if self.capped {
|
||||
return;
|
||||
}
|
||||
let Some(file) = self.file.as_mut() else { return };
|
||||
let len = u64::try_from(line.len()).unwrap_or(u64::MAX);
|
||||
let text = if self.written.saturating_add(len) > self.max_bytes {
|
||||
self.capped = true;
|
||||
format!("log file reached {} bytes; later records go to standard error only\n", self.max_bytes)
|
||||
} else {
|
||||
self.written = self.written.saturating_add(len);
|
||||
line.to_owned()
|
||||
};
|
||||
// A full disk or a vanished file must not take the app down: drop the file sink.
|
||||
if file.write_all(text.as_bytes()).is_err() {
|
||||
self.file = None;
|
||||
self.capped = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn file logging on (writing to `file`) or off.
|
||||
|
|
@ -123,7 +289,7 @@ pub fn set_enabled(on: bool, file: PathBuf) -> std::io::Result<()> {
|
|||
std::fs::create_dir_all(d)?;
|
||||
}
|
||||
let mut f = std::fs::OpenOptions::new().create(true).append(true).open(&file)?;
|
||||
writeln!(f, "{} INFO effectcraft: logging enabled ({} {})", unix_secs(), std::env::consts::OS, env!("CARGO_PKG_VERSION"))?;
|
||||
writeln!(f, "{} INFO effectcraft: logging enabled ({} {})", timestamp(unix_millis()), std::env::consts::OS, env!("CARGO_PKG_VERSION"))?;
|
||||
}
|
||||
if let Ok(mut st) = STATE.lock() {
|
||||
st.file = Some(file);
|
||||
|
|
@ -136,7 +302,7 @@ pub fn is_enabled() -> bool {
|
|||
ENABLED.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// The current log file (set once logging was enabled).
|
||||
/// The current Enable Logging file (set once logging was enabled).
|
||||
pub fn current_file() -> Option<PathBuf> {
|
||||
STATE.lock().ok().and_then(|s| s.file.clone())
|
||||
}
|
||||
|
|
@ -148,7 +314,8 @@ pub fn recent() -> Vec<String> {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{LevelFilter, level_for, parse_filter};
|
||||
use super::*;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
|
||||
/// `RUST_LOG` picks what start-up prints to stderr (#234).
|
||||
#[test]
|
||||
|
|
@ -162,4 +329,111 @@ mod tests {
|
|||
assert_eq!(level_for(&parse_filter("wgpu=debug"), "winit"), LevelFilter::Off);
|
||||
assert!(parse_filter("").is_empty());
|
||||
}
|
||||
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
static N: AtomicUsize = AtomicUsize::new(0);
|
||||
let d = std::env::temp_dir().join(format!("effectcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
d
|
||||
}
|
||||
|
||||
fn read(path: &Path) -> String {
|
||||
std::fs::read_to_string(path).unwrap()
|
||||
}
|
||||
|
||||
/// Without `RUST_LOG`, EffectCraft's own info records and everyone's warnings are shown.
|
||||
#[test]
|
||||
fn the_default_filter_shows_effectcraft_info_and_other_crates_warnings() {
|
||||
let f = parse_filter(DEFAULT_FILTER);
|
||||
assert_eq!(level_for(&f, "effectcraft"), LevelFilter::Info);
|
||||
assert_eq!(level_for(&f, "effectcraft_ui_egui::app"), LevelFilter::Info);
|
||||
assert_eq!(level_for(&f, "wgpu_core::device"), LevelFilter::Warn);
|
||||
assert_eq!(level_for(&f, "naga"), LevelFilter::Warn);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timestamps_are_utc_with_milliseconds() {
|
||||
assert_eq!(timestamp(0), "1970-01-01T00:00:00.000Z");
|
||||
assert_eq!(timestamp(1_791_446_357_728), "2026-10-08T07:59:17.728Z");
|
||||
assert_eq!(timestamp(1_709_164_800_000), "2024-02-29T00:00:00.000Z");
|
||||
assert_eq!(timestamp(951_782_400_000), "2000-02-29T00:00:00.000Z");
|
||||
assert_eq!(timestamp(1_767_225_599_000), "2025-12-31T23:59:59.000Z");
|
||||
let _ = timestamp(u64::MAX);
|
||||
}
|
||||
|
||||
/// Each launch keeps the previous runs' logs, so the log of a run that crashed survives.
|
||||
#[test]
|
||||
fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() {
|
||||
let dir = temp_dir("rotate");
|
||||
for run in 1..=4 {
|
||||
let (path, _file) = rotate(&dir).unwrap();
|
||||
assert_eq!(path, dir.join(SESSION_LOG));
|
||||
assert_eq!(read(&path), "");
|
||||
std::fs::write(&path, format!("run {run}")).unwrap();
|
||||
}
|
||||
assert_eq!(read(&dir.join(SESSION_LOG)), "run 4");
|
||||
assert_eq!(read(&dir.join("effectcraft.1.log")), "run 3");
|
||||
assert_eq!(read(&dir.join("effectcraft.2.log")), "run 2");
|
||||
assert!(!dir.join("effectcraft.3.log").exists(), "only {KEEP} old logs are kept");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() {
|
||||
let dir = temp_dir("blocked");
|
||||
std::fs::write(&dir, "a file where the directory should be").unwrap();
|
||||
assert!(rotate(&dir).is_err());
|
||||
let _ = std::fs::remove_file(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() {
|
||||
let dir = temp_dir("pending");
|
||||
let (path, file) = rotate(&dir).unwrap();
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
sink.write("early 1\n");
|
||||
sink.write("early 2\n");
|
||||
sink.attach(file);
|
||||
sink.write("late\n");
|
||||
assert_eq!(read(&path), "early 1\nearly 2\nlate\n");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() {
|
||||
let dir = temp_dir("bounded");
|
||||
let (path, file) = rotate(&dir).unwrap();
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
for i in 0..MAX_PENDING + 5 {
|
||||
sink.write(&format!("line {i}\n"));
|
||||
}
|
||||
sink.attach(file);
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING);
|
||||
assert_eq!(text.matches("5 earlier log lines were dropped").count(), 1, "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_file_stops_at_its_size_cap_with_one_note() {
|
||||
let dir = temp_dir("cap");
|
||||
let (path, file) = rotate(&dir).unwrap();
|
||||
let mut sink = Sink::new(100);
|
||||
sink.attach(file);
|
||||
for i in 0..50 {
|
||||
sink.write(&format!("line {i:02} with some padding\n"));
|
||||
}
|
||||
let text = read(&path);
|
||||
assert!(text.lines().filter(|l| l.starts_with("line ")).count() < 5, "{text}");
|
||||
assert_eq!(text.matches("log file reached").count(), 1, "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_carries_time_level_thread_target_and_message() {
|
||||
assert_eq!(
|
||||
format_line("2026-10-08T07:59:17.728Z", log::Level::Warn, "main", "effectcraft_engine::autosave", "disk full\n"),
|
||||
"2026-10-08T07:59:17.728Z WARN [main] effectcraft_engine::autosave: disk full"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -194,10 +194,10 @@ pub struct Entry {
|
|||
}
|
||||
|
||||
const VIDEO: &[&str] = &["mp4", "m4v", "mov", "webm", "mkv", "avi", "mxf", "gif"];
|
||||
const IMAGE: &[&str] = &["png", "jpg", "jpeg", "tif", "tiff", "exr", "bmp", "webp", "psd", "psb", "svg", "tga", "dpx", "hdr"];
|
||||
const IMAGE: &[&str] = &["png", "jpg", "jpeg", "tif", "tiff", "exr", "bmp", "webp", "psd", "psb", "svg", "tga", "dpx", "cin", "hdr"];
|
||||
const AUDIO: &[&str] = &["wav", "aif", "aiff", "mp3", "m4a", "aac", "flac", "ogg", "opus"];
|
||||
const DATA: &[&str] = &["json", "csv", "tsv", "mgjson"];
|
||||
const MODEL: &[&str] = &["gltf", "glb", "obj"];
|
||||
const MODEL: &[&str] = &["gltf", "glb", "obj", "stl", "fbx"];
|
||||
|
||||
/// Kind of file from its extension.
|
||||
pub fn kind_of(path: &str) -> Option<&'static str> {
|
||||
|
|
|
|||
|
|
@ -322,6 +322,9 @@ pub fn checked(s: &Session, command: &str, params: &Value) -> Option<bool> {
|
|||
}
|
||||
"layer.markersLock" => Some(layer?.markers_locked),
|
||||
"layer.mask.hideLocked" => Some(s.state.hide_locked_masks),
|
||||
"view.res.full" | "view.res.half" | "view.res.third" | "view.res.quarter" | "view.res.custom" => {
|
||||
Some(comp.resolution.label().eq_ignore_ascii_case(command.trim_start_matches("view.res.")))
|
||||
}
|
||||
"view.layout" => Some(params.get("views").and_then(Value::as_u64) == Some(s.state.view_layout.max(1) as u64)),
|
||||
"view.shareViewOptions" => Some(s.state.share_view_options),
|
||||
"view.extendedViewer" => Some(s.prefs.three_d.extended_viewer),
|
||||
|
|
@ -693,6 +696,7 @@ Composition
|
|||
File... | comp.saveFrameAs
|
||||
Photoshop Layers... | comp.saveFrameAsPsd
|
||||
ProEXR... | comp.saveFrameAsExr
|
||||
Copy Frame to Clipboard | comp.copyFrame
|
||||
Pre-render... | render.preRender
|
||||
Save Current Preview... | render.saveCurrentPreview
|
||||
---
|
||||
|
|
@ -1021,6 +1025,14 @@ Animation
|
|||
Brightness | layer.addTextAnimator {"properties":["strokeBrightness"]}
|
||||
Opacity | layer.addTextAnimator {"properties":["strokeOpacity"]}
|
||||
Stroke Width | layer.addTextAnimator {"properties":["strokeWidth"]}
|
||||
Front
|
||||
Color | layer.addTextAnimator {"properties":["frontColor"]}
|
||||
Bevel
|
||||
Color | layer.addTextAnimator {"properties":["bevelColor"]}
|
||||
Side
|
||||
Color | layer.addTextAnimator {"properties":["sideColor"]}
|
||||
Back
|
||||
Color | layer.addTextAnimator {"properties":["backColor"]}
|
||||
---
|
||||
Tracking | layer.addTextAnimator {"properties":["tracking"]}
|
||||
Line Anchor | layer.addTextAnimator {"properties":["lineAnchor"]}
|
||||
|
|
|
|||
|
|
@ -32,11 +32,12 @@ pub const LANGUAGES: &[(&str, &str)] = &[
|
|||
("Українська", "uk"),
|
||||
("Español", "es"),
|
||||
("Português (Brasil)", "pt-br"),
|
||||
("Русский", "ru"),
|
||||
("Français", "fr"),
|
||||
("Deutsch", "de"),
|
||||
("Čeština", "cs"),
|
||||
];
|
||||
|
||||
/// Settings ▸ Appearance ▸ UI Scale: (label, `appearance.uiScale` percent).
|
||||
pub const UI_SCALES: &[(&str, &str)] = &[("75%", "75"), ("100%", "100"), ("125%", "125"), ("150%", "150"), ("175%", "175"), ("200%", "200")];
|
||||
|
||||
/// Settings ▸ Startup & Repair ▸ Window Graphics: (label, `startup.windowGraphics` value).
|
||||
pub const WINDOW_GRAPHICS: &[(&str, &str)] = &[("Automatic", "auto"), ("OpenGL (compatibility)", "gl")];
|
||||
|
||||
|
|
@ -175,6 +176,8 @@ page!(Appearance {
|
|||
dark_theme: String = "dark".into(),
|
||||
/// The theme used in light appearance: `light`.
|
||||
light_theme: String = "light".into(),
|
||||
/// Theme shortcuts in the appearance context menu.
|
||||
favorite_themes: Vec<String> = Vec::new(),
|
||||
/// User interface brightness, -1 (darker) … 1 (lighter).
|
||||
brightness: f64 = 0.0,
|
||||
/// The size of the whole interface in percent (75–200), on top of the display's own scale.
|
||||
|
|
@ -335,6 +338,42 @@ pub struct LabelPref {
|
|||
pub color: String,
|
||||
}
|
||||
|
||||
/// A Composition Settings preset the user saved (Composition Settings ▸ Preset ▸ Save): the
|
||||
/// frame size, pixel aspect and frame rate, as in After Effects' own presets.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CompPreset {
|
||||
pub name: String,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub pixel_aspect: f64,
|
||||
pub frame_rate: f64,
|
||||
}
|
||||
|
||||
impl CompPreset {
|
||||
/// Longest preset name kept.
|
||||
pub const MAX_NAME: usize = 128;
|
||||
|
||||
/// Values a composition accepts (a hand-edited settings file can hold anything).
|
||||
pub fn is_valid(&self) -> bool {
|
||||
let size = 4..=30000;
|
||||
!self.name.trim().is_empty()
|
||||
&& self.name.chars().count() <= Self::MAX_NAME
|
||||
&& size.contains(&self.width)
|
||||
&& size.contains(&self.height)
|
||||
&& self.pixel_aspect.is_finite()
|
||||
&& (0.01..=100.0).contains(&self.pixel_aspect)
|
||||
&& self.frame_rate.is_finite()
|
||||
&& (1.0..=999.0).contains(&self.frame_rate)
|
||||
}
|
||||
}
|
||||
|
||||
/// Saved composition presets, skipping entries that don't parse instead of losing the list.
|
||||
fn lenient_comp_presets<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<CompPreset>, D::Error> {
|
||||
let v = Vec::<Value>::deserialize(d)?;
|
||||
Ok(v.into_iter().filter_map(|x| serde_json::from_value(x).ok()).collect())
|
||||
}
|
||||
|
||||
fn default_labels() -> Vec<LabelPref> {
|
||||
Label::ALL.iter().skip(1).map(|l| LabelPref { name: l.name().to_string(), color: hex(l.rgb()) }).collect()
|
||||
}
|
||||
|
|
@ -393,6 +432,9 @@ pub struct Prefs {
|
|||
pub preview: crate::preview::PreviewSettings,
|
||||
/// View ▸ Simulate Output ▸ My Custom RGB.
|
||||
pub custom_rgb: crate::viewer::CustomRgb,
|
||||
/// Composition Settings presets the user saved, in the order saved.
|
||||
#[serde(deserialize_with = "lenient_comp_presets")]
|
||||
pub comp_presets: Vec<CompPreset>,
|
||||
/// Top-level keys this version doesn't know (kept for newer versions).
|
||||
#[serde(flatten)]
|
||||
pub extra: Extra,
|
||||
|
|
@ -428,6 +470,7 @@ impl Default for Prefs {
|
|||
recent_fonts: vec![],
|
||||
preview: crate::preview::PreviewSettings::default(),
|
||||
custom_rgb: crate::viewer::CustomRgb::default(),
|
||||
comp_presets: vec![],
|
||||
extra: Extra::new(),
|
||||
}
|
||||
}
|
||||
|
|
@ -553,6 +596,7 @@ impl Prefs {
|
|||
/// Clamp values into their valid ranges and fill missing labels.
|
||||
pub fn normalize(&mut self) {
|
||||
self.version = PREFS_VERSION;
|
||||
self.comp_presets.retain(CompPreset::is_valid);
|
||||
if !is_choice(WINDOW_GRAPHICS, &self.startup.window_graphics) {
|
||||
self.startup.window_graphics = Startup::default().window_graphics;
|
||||
}
|
||||
|
|
@ -567,6 +611,8 @@ impl Prefs {
|
|||
a.interval_minutes = a.interval_minutes.clamp(1, 240);
|
||||
a.max_versions = a.max_versions.clamp(1, 99);
|
||||
let ap = &mut self.appearance;
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
ap.favorite_themes.retain(|id| is_choice(THEMES, id) && seen.insert(id.clone()));
|
||||
if !is_choice(APPEARANCE_MODES, &ap.appearance_mode) {
|
||||
ap.appearance_mode = Appearance::default().appearance_mode;
|
||||
}
|
||||
|
|
@ -623,6 +669,23 @@ impl Prefs {
|
|||
let all: Vec<&str> = c.iter().map(|(_, v)| *v).collect();
|
||||
return Err(format!("`{key}` expects one of {}", all.join(", ")));
|
||||
}
|
||||
let favorites = if key == "appearance.favoriteThemes" {
|
||||
Some(&value)
|
||||
} else if key == "appearance" {
|
||||
value.get("favoriteThemes")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(favorites) = favorites {
|
||||
let values = favorites.as_array().ok_or("favorite themes must be a list")?;
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
for value in values {
|
||||
let id = value.as_str().ok_or("favorite theme must be a string")?;
|
||||
if !is_choice(THEMES, id) || !seen.insert(id) {
|
||||
return Err("unknown or duplicate favorite theme".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
// Older clients set the single theme (by key, or inside the page object): it still picks
|
||||
// a visible theme, fixing the mode to its family.
|
||||
let legacy_theme = key == "appearance.theme" || (key == "appearance" && value.get("theme").is_some() && value.get("appearanceMode").is_none());
|
||||
|
|
@ -642,8 +705,8 @@ impl Prefs {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Reset one page (`general`, `labels`, `autoSave`…) or everything (`None`). Open Recent is
|
||||
/// kept.
|
||||
/// Reset one page (`general`, `labels`, `autoSave`…) or everything (`None`). Open Recent and
|
||||
/// the saved composition presets are kept.
|
||||
pub fn reset(&mut self, page: Option<&str>) -> Result<(), String> {
|
||||
let d = Prefs::default();
|
||||
let Some(page) = page else {
|
||||
|
|
@ -653,6 +716,7 @@ impl Prefs {
|
|||
recent_presets: std::mem::take(&mut self.recent_presets),
|
||||
recent_fonts: std::mem::take(&mut self.recent_fonts),
|
||||
preview: std::mem::take(&mut self.preview),
|
||||
comp_presets: std::mem::take(&mut self.comp_presets),
|
||||
..d
|
||||
};
|
||||
return Ok(());
|
||||
|
|
@ -1170,7 +1234,7 @@ pub fn pages() -> Vec<Page> {
|
|||
s("appearance.lightTheme", "Light Theme", Kind::Choice(LIGHT_THEMES), true),
|
||||
s("appearance.darkTheme", "Dark Theme", Kind::Choice(DARK_THEMES), true),
|
||||
s("appearance.brightness", "Brightness", Kind::Slider(-1.0, 1.0), true),
|
||||
s("appearance.uiScale", "UI Scale", Kind::Choice(UI_SCALES), true),
|
||||
s("appearance.uiScale", "UI Scale", Kind::Int(75, 200, "%"), true),
|
||||
Section("Labels and Colors"),
|
||||
s("appearance.useLabelColorForHandles", "Use Label Color for Layer Handles and Paths", B, true),
|
||||
s("appearance.useLabelColorForTabs", "Use Label Color for Related Tabs", B, true),
|
||||
|
|
|
|||
|
|
@ -116,7 +116,9 @@ pub fn import(proj: &mut Project, psd: &Psd, path: &str, name: &str, retain: boo
|
|||
}
|
||||
|
||||
fn new_comp(cx: &Ctx) -> Comp {
|
||||
Comp::new(cx.psd.width, cx.psd.height, cx.rate, cx.duration)
|
||||
// A PSB can be 300,000 px a side; a composition is at most 30,000 (#788).
|
||||
let size = |v: u32| effectcraft_project::limits::size(v.into());
|
||||
Comp::new(size(cx.psd.width), size(cx.psd.height), cx.rate, cx.duration)
|
||||
}
|
||||
|
||||
fn build_comp(proj: &mut Project, cx: &mut Ctx, name: &str, nodes: &[Node], parent: Option<ItemId>) -> ItemId {
|
||||
|
|
|
|||
|
|
@ -203,6 +203,11 @@ pub fn bytes_per_frame(om: &OutputModule, w: u32, h: u32, fps: f64) -> f64 {
|
|||
};
|
||||
mbps(rate) * px / 29.97
|
||||
}
|
||||
// 24-bit RGB rows padded to even bytes, or 32-bit ARGB.
|
||||
OutputFormat::Uncompressed => {
|
||||
let bpp = if om.channels == effectcraft_project::render_queue::Channels::Rgba { 4 } else { 3 };
|
||||
((w as u64 * bpp).next_multiple_of(2) * h as u64) as f64
|
||||
}
|
||||
OutputFormat::WebM => mbps(8.0) * px / fps,
|
||||
OutputFormat::Gif => w as f64 * h as f64 * 0.5,
|
||||
OutputFormat::Wav | OutputFormat::Aiff => om.audio_sample_rate as f64 * 4.0 / fps,
|
||||
|
|
|
|||
|
|
@ -67,11 +67,11 @@ fn add_configure_render() {
|
|||
let r = s.execute("renderQueue.setOutputModule", json!({"item": id, "format": "png", "channels": "rgba"})).unwrap();
|
||||
assert!(r["outputPath"].as_str().unwrap().ends_with("_[#####].png"), "{r}");
|
||||
assert!(s.execute("renderQueue.setOutputModule", json!({"item": id, "format": "jpeg", "channels": "rgba"})).is_err());
|
||||
let r = s.execute("renderQueue.setOutput", json!({"item": id, "path": "/tmp/rq/out.mov"})).unwrap();
|
||||
let r = s.execute("renderQueue.setOutput", json!({"item": id, "path": crate::tests_stubs::tmp_out("rq/out.mov")})).unwrap();
|
||||
assert_eq!(r["output"]["format"], "ProRes", "Output To with .mov switches to ProRes");
|
||||
assert_eq!(std::path::Path::new(r["outputPath"].as_str().unwrap()), std::path::absolute("/tmp/rq/out.mov").unwrap());
|
||||
assert_eq!(std::path::Path::new(r["outputPath"].as_str().unwrap()), std::path::PathBuf::from(crate::tests_stubs::tmp_out("rq/out.mov")));
|
||||
// A second item that fails, a third that is unqueued.
|
||||
let b = s.execute("renderQueue.add", json!({"format": "gif", "output": "/tmp/rq/fail.gif"})).unwrap();
|
||||
let b = s.execute("renderQueue.add", json!({"format": "gif", "output": crate::tests_stubs::tmp_out("rq/fail.gif")})).unwrap();
|
||||
let c = s.execute("renderQueue.add", json!({"output": "/tmp/rq/skip.mp4"})).unwrap();
|
||||
s.execute("renderQueue.setRender", json!({"item": c["item"], "render": false})).unwrap();
|
||||
assert_eq!(s.project.render_queue[2].status.label(), "Unqueued");
|
||||
|
|
@ -79,12 +79,12 @@ fn add_configure_render() {
|
|||
assert_eq!(r["items"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(
|
||||
ex.log.lock().unwrap().iter().map(std::path::PathBuf::from).collect::<Vec<_>>(),
|
||||
[std::path::absolute("/tmp/rq/out.mov").unwrap(), std::path::absolute("/tmp/rq/fail.gif").unwrap()]
|
||||
[std::path::PathBuf::from(crate::tests_stubs::tmp_out("rq/out.mov")), std::path::PathBuf::from(crate::tests_stubs::tmp_out("rq/fail.gif"))]
|
||||
);
|
||||
let q = &s.project.render_queue;
|
||||
assert_eq!(q[0].status.label(), "Done");
|
||||
assert!(q[0].started.is_some() && q[0].render_time.is_some());
|
||||
assert_eq!(std::path::Path::new(q[0].last_output.as_deref().unwrap()), std::path::absolute("/tmp/rq/out.mov").unwrap());
|
||||
assert_eq!(std::path::Path::new(q[0].last_output.as_deref().unwrap()), std::path::PathBuf::from(crate::tests_stubs::tmp_out("rq/out.mov")));
|
||||
assert_eq!(q[1].status, RenderStatus::Failed("boom".into()));
|
||||
assert_eq!(q[2].status.label(), "Unqueued");
|
||||
assert!(s.drain_events().iter().any(|e| matches!(e, Event::Toast { error: true, .. })));
|
||||
|
|
@ -162,7 +162,7 @@ fn without_exporter() {
|
|||
s.execute("renderQueue.add", json!({})).unwrap();
|
||||
assert!(!s.is_enabled("renderQueue.render"));
|
||||
let f = s.execute("renderQueue.formats", json!({})).unwrap();
|
||||
assert_eq!(f["formats"].as_array().unwrap().len(), 12);
|
||||
assert_eq!(f["formats"].as_array().unwrap().len(), 13);
|
||||
assert_eq!(f["formats"][0]["available"], false);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -110,6 +110,10 @@ pub struct Widget {
|
|||
pub read_only: bool,
|
||||
/// Event handlers the script attached (`onClick`, `onChange`, `onChanging`…).
|
||||
pub handlers: Vec<String>,
|
||||
/// The mouse events ([`MOUSE_EVENTS`]) a script listener receives when they happen on this
|
||||
/// control: its own `addEventListener` listeners and its containers' (events bubble).
|
||||
/// Frontends send only these.
|
||||
pub mouse: Vec<String>,
|
||||
/// The active tab of a tabbed panel (child index).
|
||||
pub active_tab: usize,
|
||||
/// What the control's `onDraw` handler painted (ScriptUIGraphics), in control coordinates.
|
||||
|
|
@ -364,16 +368,26 @@ pub struct ScriptWindow {
|
|||
pub struct ScriptUiEvent {
|
||||
pub window: u32,
|
||||
pub widget: u32,
|
||||
/// `click`, `change`, `changing` or `close`.
|
||||
/// `click`, `change`, `changing`, `close`, or a mouse event ([`MOUSE_EVENTS`]).
|
||||
pub kind: String,
|
||||
/// The new value: text, number, bool, or selected index(es); for `close`, the result.
|
||||
/// The new value: text, number, bool, or selected index(es); for `close`, the result; for
|
||||
/// mouse events (and a mouse `click`), the pointer: `{x, y, button, detail, screenX, screenY,
|
||||
/// altKey, ctrlKey, metaKey, shiftKey}` with `x`/`y` relative to the control.
|
||||
pub value: Value,
|
||||
}
|
||||
|
||||
/// The ScriptUI mouse events scripts can listen for (`addEventListener`).
|
||||
pub const MOUSE_EVENTS: [&str; 6] = ["mousedown", "mouseup", "mousemove", "mouseover", "mouseout", "click"];
|
||||
|
||||
/// Hands an event to the script that owns the window; returns what the handler reported
|
||||
/// (`{ok, output, error}`) and updates [`Session::script_ui`].
|
||||
pub type ScriptUiDispatch = fn(&mut Session, &ScriptUiEvent) -> std::result::Result<Value, String>;
|
||||
|
||||
/// Runs the scripts' `app.scheduleTask` tasks that are due; returns
|
||||
/// `{ran, output, errors: [{message, line, file}], next (ms until the next task is due, or null)}`
|
||||
/// and updates [`Session::script_ui`]. Frontends call it every frame (see [`ScriptUi::run_tasks`]).
|
||||
pub type ScriptTaskRunner = fn(&mut Session) -> Value;
|
||||
|
||||
/// The open script windows.
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
|
|
@ -384,6 +398,16 @@ pub struct ScriptUi {
|
|||
/// The scripting engine's event hand-off (set with [`Session::script`]).
|
||||
#[serde(skip)]
|
||||
pub dispatch: Option<ScriptUiDispatch>,
|
||||
/// The scripting engine's scheduled-task runner (set with [`Session::script`]).
|
||||
#[serde(skip)]
|
||||
pub tasks: Option<ScriptTaskRunner>,
|
||||
/// Bumped whenever the project is closed or replaced (New / Open / Close Project): repeating
|
||||
/// tasks of scripts without an open window stop then.
|
||||
#[serde(skip)]
|
||||
pub project_epoch: u64,
|
||||
/// The script contexts of this session with scheduled tasks (kept by the scripting engine).
|
||||
#[serde(skip)]
|
||||
pub task_hosts: Vec<u32>,
|
||||
}
|
||||
|
||||
impl ScriptUi {
|
||||
|
|
@ -391,6 +415,17 @@ impl ScriptUi {
|
|||
self.windows.iter().find(|w| w.id == id)
|
||||
}
|
||||
|
||||
/// Run the scripts' due `app.scheduleTask` tasks ([`ScriptTaskRunner`]); `None` without a
|
||||
/// scripting engine.
|
||||
pub fn run_tasks(s: &mut Session) -> Option<Value> {
|
||||
let f = s.script_ui.tasks?;
|
||||
// A task may close its ScriptUI panel: forget it then.
|
||||
let open = crate::commands::scripts::open_panel_names(s);
|
||||
let r = f(s);
|
||||
crate::commands::scripts::forget_closed_panels(s, &open);
|
||||
Some(r)
|
||||
}
|
||||
|
||||
/// Replace the windows a script host published (laying them out).
|
||||
pub fn publish(&mut self, host: u32, mut windows: Vec<ScriptWindow>) {
|
||||
for w in &mut windows {
|
||||
|
|
@ -687,6 +722,48 @@ pub(crate) fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
dispatch(s, ScriptUiEvent { window, widget, kind: kind.into(), value })
|
||||
}
|
||||
|
||||
/// `script.runTasks`: run the scripts' `app.scheduleTask` tasks that are due now (frontends do
|
||||
/// it every frame; headless hosts and agents call this).
|
||||
pub(crate) fn run_tasks(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
ScriptUi::run_tasks(s).ok_or_else(|| EngineError::Other("scripting is not available in this build".into()))
|
||||
}
|
||||
|
||||
/// `scriptui.mouse`: a mouse event on a control, as the pointer would make it: the script's
|
||||
/// `addEventListener` listeners on the control and its containers receive a MouseEvent
|
||||
/// (`clientX`/`clientY` relative to the control, `button`, `detail` = click count, modifier
|
||||
/// keys). A `click` also does what clicking the control does (toggle a checkbox, press a button).
|
||||
pub(crate) fn mouse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "scriptui.mouse";
|
||||
let window = window_p(s, p, c)?;
|
||||
let widget = widget_p(s, window, p, c)?;
|
||||
let ty = p.get("type").and_then(Value::as_str).unwrap_or("click").to_ascii_lowercase();
|
||||
let Some(ty) = MOUSE_EVENTS.iter().find(|t| **t == ty) else {
|
||||
return Err(crate::commands::bad(c, format!("unknown mouse event `{ty}` (one of {})", MOUSE_EVENTS.join(", "))));
|
||||
};
|
||||
let w = s.script_ui.window(window).and_then(|w| w.root.find(widget)).cloned().unwrap_or_default();
|
||||
if !w.enabled {
|
||||
return Err(crate::commands::bad(c, format!("control {widget} is disabled")));
|
||||
}
|
||||
// Hostile input stays finite and bounded.
|
||||
const FAR: f64 = 1.0e6;
|
||||
let num = |k: &str, lo: f64, hi: f64, d: f64| p.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()).map_or(d, |v| v.clamp(lo, hi));
|
||||
let flag = |k: &str| p.get(k).and_then(Value::as_bool).unwrap_or(false);
|
||||
let clicks = if matches!(*ty, "mousemove" | "mouseover" | "mouseout") { 0.0 } else { 1.0 };
|
||||
let value = json!({
|
||||
"x": num("x", -FAR, FAR, w.bounds[2] / 2.0),
|
||||
"y": num("y", -FAR, FAR, w.bounds[3] / 2.0),
|
||||
"screenX": num("screenX", -FAR, FAR, 0.0),
|
||||
"screenY": num("screenY", -FAR, FAR, 0.0),
|
||||
"button": num("button", 0.0, 2.0, 0.0).round(),
|
||||
"detail": num("detail", 0.0, 1000.0, clicks).round(),
|
||||
"altKey": flag("altKey"),
|
||||
"ctrlKey": flag("ctrlKey"),
|
||||
"metaKey": flag("metaKey"),
|
||||
"shiftKey": flag("shiftKey"),
|
||||
});
|
||||
dispatch(s, ScriptUiEvent { window, widget, kind: (*ty).to_string(), value })
|
||||
}
|
||||
|
||||
/// `scriptui.close`: close a window (a dialog's `show()` returns `result`, default 2 = Cancel).
|
||||
pub(crate) fn close(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let window = window_p(s, p, "scriptui.close")?;
|
||||
|
|
|
|||
|
|
@ -88,7 +88,9 @@ pub fn bindables(ui: &[UiCommand]) -> Vec<Bindable> {
|
|||
let entries = crate::menus::entries();
|
||||
for (path, e) in &entries {
|
||||
let key = binding_key(&e.command, &e.params);
|
||||
let defaults = e.shortcut.iter().filter_map(|s| normalize(s)).collect();
|
||||
// A plain entry also gets its command's further default keys.
|
||||
let alt = if e.params.is_null() { crate::commands::find(&e.command).map_or(&[][..], |c| c.alt_shortcuts) } else { &[] };
|
||||
let defaults = e.shortcut.as_deref().into_iter().chain(alt.iter().copied()).filter_map(normalize).collect();
|
||||
push(
|
||||
Bindable {
|
||||
key,
|
||||
|
|
@ -106,7 +108,7 @@ pub fn bindables(ui: &[UiCommand]) -> Vec<Bindable> {
|
|||
if !c.journal || entries.iter().any(|(_, e)| e.command == c.id) {
|
||||
continue;
|
||||
}
|
||||
let defaults = c.shortcut.and_then(normalize).into_iter().collect();
|
||||
let defaults = c.shortcut.into_iter().chain(c.alt_shortcuts.iter().copied()).filter_map(normalize).collect();
|
||||
push(
|
||||
Bindable {
|
||||
key: c.id.to_string(),
|
||||
|
|
|
|||
|
|
@ -190,6 +190,25 @@ pub fn cpu_cores() -> usize {
|
|||
std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1)
|
||||
}
|
||||
|
||||
/// The graphics lines of Settings ▸ GPU Information and Help ▸ System Report: whether the GPU
|
||||
/// compositor runs (and on which adapter), else why compositing is on the CPU, and the adapter
|
||||
/// the window itself got. Without this a card the compositor couldn't use looked undetected (#596).
|
||||
pub fn gpu_summary(s: &crate::Session) -> String {
|
||||
let mut text = match s.accel.as_ref().map(|a| a.name()) {
|
||||
Some(name) => format!("Compositing: GPU, {name}.\n"),
|
||||
None => {
|
||||
let threads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1);
|
||||
let why = s.accel_note.as_deref().unwrap_or("no reason was recorded");
|
||||
format!("Compositing: CPU, {threads} threads (pure Rust, rayon).\nGPU compositing is off: {why}\n")
|
||||
}
|
||||
};
|
||||
match &s.window_adapter {
|
||||
Some(adapter) => text.push_str(&format!("Window graphics: {adapter}\n")),
|
||||
None => text.push_str("Window graphics: not known yet (the window has not drawn its first frame).\n"),
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
|
|||
|
|
@ -280,6 +280,57 @@ fn toolbar_fill_and_stroke_paint_selected_shape_layers() {
|
|||
let _ = s.render(cid, s.time(), Default::default());
|
||||
}
|
||||
|
||||
/// The toolbar's Fill Options / Stroke Options change a selected shape layer's paint kind (#732):
|
||||
/// None switches the fill off, a gradient turns the Fill into a Gradient Fill that keeps its
|
||||
/// opacity, Solid Color turns it back, and a Stroke kind adds a stroke where there is none.
|
||||
#[test]
|
||||
fn toolbar_fill_and_stroke_kinds_change_selected_shape_layers() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"width": 400, "height": 300, "duration": 2})).unwrap();
|
||||
let sh = s.execute("layer.newShape", json!({"kind": "rect", "fill": "#ff0000"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let items = |s: &Session| -> Vec<(String, bool)> {
|
||||
let l = s.active_comp().unwrap().layer(effectcraft_project::LayerId(sh)).unwrap();
|
||||
l.props.group("contents/group/contents").unwrap().groups().map(|g| (g.match_id.clone(), g.enabled)).collect()
|
||||
};
|
||||
let kinds = |s: &mut Session| {
|
||||
let p = s.execute("shape.fillStroke", json!({})).unwrap();
|
||||
(p["fillType"].as_str().unwrap().to_string(), p["strokeType"].as_str().unwrap().to_string())
|
||||
};
|
||||
assert_eq!(kinds(&mut s), ("solid".into(), "none".into()));
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let centre = |s: &Session| s.render(cid, s.time(), Default::default()).get(200, 150);
|
||||
let red = centre(&s);
|
||||
// None switches the fill off: nothing is drawn, and the item stays.
|
||||
s.execute("shape.fillStroke", json!({"fillType": "none"})).unwrap();
|
||||
assert_eq!(items(&s), [("rect".into(), true), ("fill".into(), false)]);
|
||||
assert_eq!(kinds(&mut s).0, "none");
|
||||
assert_ne!(centre(&s), red, "the fill is off");
|
||||
// A radial gradient: the Fill becomes a Gradient Fill (switched on) that keeps its opacity.
|
||||
s.execute("shape.fillStroke", json!({"fillType": "solid", "fill": "#ff0000"})).unwrap();
|
||||
assert_eq!(centre(&s), red);
|
||||
let opacity = |s: &Session, m: &str| {
|
||||
let l = s.active_comp().unwrap().layer(effectcraft_project::LayerId(sh)).unwrap();
|
||||
l.props.group(&format!("contents/group/contents/{m}")).unwrap().get("opacity").unwrap().value.as_f64()
|
||||
};
|
||||
s.execute("prop.set", json!({"layer": sh, "path": "contents/group/contents/fill/opacity", "value": 40})).unwrap();
|
||||
s.execute("shape.fillStroke", json!({"fillType": "radial"})).unwrap();
|
||||
assert_eq!(items(&s), [("rect".into(), true), ("gfill".into(), true)]);
|
||||
assert_eq!((kinds(&mut s).0.as_str(), opacity(&s, "gfill")), ("radial", 40.0));
|
||||
s.execute("shape.fillStroke", json!({"fillType": "linear"})).unwrap();
|
||||
assert_eq!(kinds(&mut s).0, "linear");
|
||||
s.execute("shape.fillStroke", json!({"fillType": "solid", "fill": "#00ff00"})).unwrap();
|
||||
assert_eq!(items(&s), [("rect".into(), true), ("fill".into(), true)]);
|
||||
assert_eq!(opacity(&s, "fill"), 40.0);
|
||||
// A Stroke kind adds a stroke (before the fill); None switches it off again; undo is per step.
|
||||
s.execute("shape.fillStroke", json!({"strokeType": "linear"})).unwrap();
|
||||
assert_eq!(items(&s), [("rect".into(), true), ("gstroke".into(), true), ("fill".into(), true)]);
|
||||
s.execute("shape.fillStroke", json!({"strokeType": "none"})).unwrap();
|
||||
assert_eq!(kinds(&mut s), ("solid".into(), "none".into()));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(kinds(&mut s), ("solid".into(), "linear".into()));
|
||||
assert!(s.execute("shape.fillStroke", json!({"fillType": "plaid"})).is_err());
|
||||
}
|
||||
|
||||
/// Selecting a mask in the Timeline (its row or its Mask Path) selects all of its points, so the
|
||||
/// viewer drags the whole mask (#203); a shape's Path item works the same way.
|
||||
#[test]
|
||||
|
|
@ -577,3 +628,61 @@ fn new_text_rejects_malformed_positions_without_creating_a_layer() {
|
|||
s.execute("layer.newText", json!({"text": "No explicit position"})).unwrap();
|
||||
assert_eq!(s.active_comp().unwrap().layers.len(), 2);
|
||||
}
|
||||
|
||||
/// The active comp and the open comp tabs are saved with the project and restored on open (#748).
|
||||
#[test]
|
||||
fn reopening_a_project_restores_its_active_comp() {
|
||||
use effectcraft_project::ItemId;
|
||||
let dir = std::env::temp_dir().join(format!("ec-test-748-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("view.ecproj").to_string_lossy().to_string();
|
||||
let mut s = Session::default();
|
||||
let a = s.execute("comp.new", json!({"name": "CompA"})).unwrap()["comp"].as_u64().unwrap();
|
||||
let b = s.execute("comp.new", json!({"name": "CompB"})).unwrap()["comp"].as_u64().unwrap();
|
||||
let c = s.execute("comp.new", json!({"name": "CompC"})).unwrap()["comp"].as_u64().unwrap();
|
||||
s.execute("comp.open", json!({"comp": "CompB"})).unwrap();
|
||||
s.state.open_comps.retain(|id| id.0 != c);
|
||||
s.execute("file.saveAs", json!({"path": path})).unwrap();
|
||||
let mut s2 = Session::default();
|
||||
s2.execute("file.open", json!({"path": path})).unwrap();
|
||||
assert_eq!(s2.state.active_comp, Some(ItemId(b)));
|
||||
assert_eq!(s2.state.open_comps, vec![ItemId(a), ItemId(b)]);
|
||||
assert!(!s2.is_dirty());
|
||||
|
||||
// A file from before the view was saved opens on its first comp, as it always did.
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
let mut old: serde_json::Value = serde_json::from_str(&text).unwrap();
|
||||
let obj = old.as_object_mut().unwrap();
|
||||
obj.remove("activeComp");
|
||||
obj.remove("openComps");
|
||||
std::fs::write(&path, serde_json::to_string_pretty(&old).unwrap()).unwrap();
|
||||
let mut s3 = Session::default();
|
||||
s3.execute("file.open", json!({"path": path})).unwrap();
|
||||
assert_eq!((s3.state.active_comp, s3.state.open_comps.clone()), (Some(ItemId(a)), vec![ItemId(a)]));
|
||||
|
||||
// Ids that no longer name a comp (edited by hand, or written by another tool) fall back.
|
||||
obj_set(&mut old, json!(999), json!([999, b, "x"]));
|
||||
std::fs::write(&path, serde_json::to_string_pretty(&old).unwrap()).unwrap();
|
||||
let mut s4 = Session::default();
|
||||
s4.execute("file.open", json!({"path": path})).unwrap();
|
||||
assert_eq!((s4.state.active_comp, s4.state.open_comps.clone()), (Some(ItemId(b)), vec![ItemId(b)]));
|
||||
obj_set(&mut old, json!(999), json!([]));
|
||||
std::fs::write(&path, serde_json::to_string_pretty(&old).unwrap()).unwrap();
|
||||
let mut s5 = Session::default();
|
||||
s5.execute("file.open", json!({"path": path})).unwrap();
|
||||
assert_eq!((s5.state.active_comp, s5.state.open_comps.clone()), (Some(ItemId(a)), vec![ItemId(a)]));
|
||||
|
||||
// XML copies carry it too.
|
||||
let xml = dir.join("view.ecprojx").to_string_lossy().to_string();
|
||||
s.execute("file.saveAs", json!({"path": xml})).unwrap();
|
||||
let mut s6 = Session::default();
|
||||
s6.execute("file.open", json!({"path": xml})).unwrap();
|
||||
assert_eq!(s6.state.active_comp, Some(ItemId(b)));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
|
||||
fn obj_set(v: &mut serde_json::Value, active: serde_json::Value, open: serde_json::Value) {
|
||||
let o = v.as_object_mut().unwrap();
|
||||
o.insert("activeComp".into(), active);
|
||||
o.insert("openComps".into(), open);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -243,3 +243,25 @@ fn demo_has_3d_showcase() {
|
|||
let lit = img.data.iter().filter(|p| p[3] > 0.99 && p[0] + p[1] + p[2] > 0.3).count();
|
||||
assert!(lit > 2_000, "{lit}");
|
||||
}
|
||||
|
||||
/// #670: the views of the panes of a multi-view layout are chosen per pane, kept per comp, and
|
||||
/// editor states saved before panes existed load with the default Top / Front / Right.
|
||||
#[test]
|
||||
fn layout_panes_choose_their_view() {
|
||||
use effectcraft_render::three_d::{View3D, Views3D};
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "C", "width": 640, "height": 360, "duration": 2})).unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
assert_eq!(s.state.views3d.get(&cid).cloned().unwrap_or_default().pane(0), View3D::Top);
|
||||
let r = s.execute("view.setPaneView", json!({"pane": 2, "view": "bottom"})).unwrap();
|
||||
assert_eq!(r["views"], json!(["activeCamera", "top", "bottom", "right"]));
|
||||
assert_eq!(s.state.views3d[&cid].current, View3D::ActiveCamera, "the main view is untouched");
|
||||
s.execute("view.setPaneView", json!({"pane": 0, "view": "custom1"})).unwrap();
|
||||
assert_eq!(s.state.views3d[&cid].current, View3D::Custom1);
|
||||
assert!(s.execute("view.setPaneView", json!({"pane": 4, "view": "top"})).is_err());
|
||||
assert!(s.execute("view.setPaneView", json!({"pane": 1, "view": "nope"})).is_err());
|
||||
let back: crate::EditorState = serde_json::from_value(serde_json::to_value(&s.state).unwrap()).unwrap();
|
||||
assert_eq!(back.views3d[&cid].pane(1), View3D::Bottom);
|
||||
let old: Views3D = serde_json::from_value(json!({"current": "Front"})).unwrap();
|
||||
assert_eq!((old.current, old.pane(0), old.pane(1), old.pane(2)), (View3D::Front, View3D::Top, View3D::Front, View3D::Right));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,74 @@
|
|||
//! Built-in animation presets (Effects & Presets ▸ * Animation Presets ▸ Transitions).
|
||||
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::render::RenderOpts;
|
||||
|
||||
/// A 160×90 comp with one white full-frame solid; returns (session, solid).
|
||||
fn session() -> (Session, u64) {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "T", "width": 160, "height": 90, "frameRate": 30, "duration": 5})).unwrap();
|
||||
let solid = s.execute("layer.newSolid", json!({"name": "S", "color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
|
||||
(s, solid)
|
||||
}
|
||||
|
||||
/// Share of the comp's pixels that are mostly opaque at `secs`.
|
||||
fn coverage(s: &Session, secs: f64) -> f64 {
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let img = s.render(cid, Tick::from_seconds_f64(secs), RenderOpts { scale: 1.0, ..Default::default() });
|
||||
img.data.iter().filter(|p| p[3] > 0.5).count() as f64 / img.data.len().max(1) as f64
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_transition_preset_applies_and_transitions() {
|
||||
let (mut s, solid) = session();
|
||||
let list = s.execute("anim.builtinPresets", json!({"category": "Transitions"})).unwrap();
|
||||
let list = list.as_array().unwrap().clone();
|
||||
assert!(list.len() >= 20, "{}", list.len());
|
||||
assert_eq!(crate::animation_presets().len(), list.len());
|
||||
let start = 0.5;
|
||||
for p in &list {
|
||||
let id = p["id"].as_str().unwrap();
|
||||
let incoming = match p["direction"].as_str() {
|
||||
Some("in") => true,
|
||||
Some("out") => false,
|
||||
d => panic!("{id}: direction {d:?}"),
|
||||
};
|
||||
s.execute("time.set", json!({"time": start})).unwrap();
|
||||
let r = s.execute("anim.applyBuiltinPreset", json!({"layers": [solid], "preset": id})).unwrap();
|
||||
assert_eq!(r["skipped"], json!([]), "{id}: every path in the preset exists and takes its values");
|
||||
let (a, b) = (coverage(&s, start), coverage(&s, start + 2.0));
|
||||
let (hidden, shown) = if incoming { (a, b) } else { (b, a) };
|
||||
assert!(hidden < 0.15, "{id}: hidden end covers {hidden:.3}");
|
||||
assert!(shown > 0.97, "{id}: shown end covers {shown:.3}");
|
||||
// Keys start at the current time: before it the layer holds the first key's state.
|
||||
assert!((coverage(&s, 0.0) - a).abs() < 0.02, "{id}");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
let l = &s.active_comp().unwrap().layers[0];
|
||||
assert_eq!(l.effects().map_or(0, |fx| fx.groups().count()), 0, "{id}: undo removes the effects");
|
||||
assert!(l.props.prop("transform/opacity").unwrap().keys.is_empty(), "{id}: undo removes the keys");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transition_preset_by_name_and_bad_input() {
|
||||
let (mut s, solid) = session();
|
||||
// By name; a second application numbers the effect like Effect ▸ apply does.
|
||||
s.execute("anim.applyBuiltinPreset", json!({"layers": [solid], "preset": "Wipe In from Left"})).unwrap();
|
||||
let r = s.execute("anim.applyBuiltinPreset", json!({"layers": [solid], "preset": "wipeInFromLeft"})).unwrap();
|
||||
assert_eq!(r["effects"].as_array().unwrap().len(), 1);
|
||||
let names: Vec<String> = s.active_comp().unwrap().layers[0].effects().unwrap().groups().map(|g| g.name.clone()).collect();
|
||||
assert_eq!(names, ["Linear Wipe", "Linear Wipe 2"]);
|
||||
let fx = s.active_comp().unwrap().layers[0].effects().unwrap().groups().next().unwrap().clone();
|
||||
let completion = fx.prop("completion").unwrap();
|
||||
assert_eq!(completion.keys.len(), 2);
|
||||
assert_eq!(completion.keys[0].time, Tick::ZERO);
|
||||
// Unknown presets and missing params are errors, not panics.
|
||||
assert!(s.execute("anim.applyBuiltinPreset", json!({"layers": [solid], "preset": "nope"})).is_err());
|
||||
assert!(s.execute("anim.applyBuiltinPreset", json!({"layers": [solid]})).is_err());
|
||||
// A layer without effects (a camera) refuses effect presets.
|
||||
let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||
assert!(s.execute("anim.applyBuiltinPreset", json!({"layers": [cam], "preset": "clockReveal"})).is_err());
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
//! Composition ▸ Save Frame As ▸ Photoshop Layers… / ProEXR….
|
||||
//! Composition ▸ Save Frame As ▸ Photoshop Layers… / ProEXR…, and Copy Frame to Clipboard.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
|
|
@ -121,3 +121,33 @@ fn proexr_values_at_or_below_one_are_unchanged() {
|
|||
assert!((got[0] - 1.0).abs() < 1e-4 && (got[1] - 0.1329).abs() < 1e-3 && got[2].abs() < 1e-6, "{got:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Composition ▸ Copy Frame to Clipboard (After Effects 26.3): the frame, as Save Frame As
|
||||
/// renders it, goes to the frontend as an image for the system clipboard.
|
||||
#[test]
|
||||
fn copy_frame_hands_the_rendered_frame_to_the_frontend() {
|
||||
let mut s = frame();
|
||||
s.drain_events();
|
||||
let r = s.execute("comp.copyFrame", json!({})).unwrap();
|
||||
assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(32), Some(32)));
|
||||
let events = s.drain_events();
|
||||
let Some((width, height, rgba)) = events.iter().find_map(|e| match e {
|
||||
crate::Event::CopyImage { width, height, rgba } => Some((*width, *height, rgba)),
|
||||
_ => None,
|
||||
}) else {
|
||||
panic!("no CopyImage event in {events:?}")
|
||||
};
|
||||
assert_eq!((width, height, rgba.len()), (32, 32, 32 * 32 * 4));
|
||||
// Red in the corner; red × (50% blue multiply) in the middle; opaque everywhere.
|
||||
assert_eq!(&rgba[..4], &[255, 0, 0, 255]);
|
||||
let mid = &rgba[(16 * 32 + 16) * 4..][..4];
|
||||
assert!(mid[0] > 100 && mid[0] < 160 && mid[2] < 13 && mid[3] == 255, "{mid:?}");
|
||||
assert!(rgba.chunks(4).all(|p| p[3] == 255));
|
||||
// The pixels are not serialized into the event stream.
|
||||
let json = serde_json::to_value(events.iter().find(|e| matches!(e, crate::Event::CopyImage { .. })).unwrap()).unwrap();
|
||||
assert!(json["CopyImage"].get("rgba").is_none(), "{json}");
|
||||
// Scaled, and refused without a composition.
|
||||
assert_eq!(s.execute("comp.copyFrame", json!({"scale": 0.5})).unwrap()["width"], 16);
|
||||
assert!(Session::default().execute("comp.copyFrame", json!({})).is_err());
|
||||
assert!(crate::menus::entries().iter().any(|(path, e)| e.command == "comp.copyFrame" && path.first().map(String::as_str) == Some("Composition")));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,342 @@
|
|||
//! A project-file fuzzer (gaps G3, #779): File ▸ Open must turn any bytes into a project or an
|
||||
//! error, never a panic, an abort or a hang (AGENTS.md "Never crash").
|
||||
//!
|
||||
//! Dependency-free, deterministic and seeded: seed projects (empty, and the demo cut down to one
|
||||
//! layer at a time, each as `.ecproj` JSON and as `.ecprojx` XML) are mutated byte-wise (flips,
|
||||
//! deletions, splices, truncation, interesting tokens, deep nesting) and structurally (a JSON
|
||||
//! node replaced by an extreme number, another type or another subtree; members dropped,
|
||||
//! elements duplicated), then opened through the real `file.open` path, followed by the queries
|
||||
//! the app and agents run on a fresh project and a thumbnail-size render of the first frame.
|
||||
//! Each input runs on its own thread under `catch_unwind` with a deadline, so a panic or a hang
|
||||
//! is reported (with the iteration to reproduce it) instead of taking the test run down; a stack
|
||||
//! overflow can't be caught and aborts the run, which fails it all the same.
|
||||
//!
|
||||
//! The default run is bounded (iterations and seconds) to stay fast in `cargo test`; a longer run:
|
||||
//! `EFFECTCRAFT_FUZZ_ITERS=200000 EFFECTCRAFT_FUZZ_SECS=600 cargo test -p effectcraft-engine fuzz_`
|
||||
//! (`EFFECTCRAFT_FUZZ_SEED` picks another sequence, `EFFECTCRAFT_FUZZ_START` the first iteration).
|
||||
//! The `mutate` + `open_input` pair is what a cargo-fuzz target would call.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{Services, Session};
|
||||
|
||||
const PATH: &str = "/fuzz/input.ecproj";
|
||||
|
||||
/// splitmix64: tiny, good enough for picking mutations.
|
||||
struct Rng(u64);
|
||||
|
||||
impl Rng {
|
||||
fn next(&mut self) -> u64 {
|
||||
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
let mut z = self.0;
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
z ^ (z >> 31)
|
||||
}
|
||||
/// `0..n` (`0` when `n` is 0).
|
||||
fn below(&mut self, n: usize) -> usize {
|
||||
if n == 0 { 0 } else { (self.next() % n as u64) as usize }
|
||||
}
|
||||
fn chance(&mut self, one_in: usize) -> bool {
|
||||
self.below(one_in) == 0
|
||||
}
|
||||
}
|
||||
|
||||
/// One file, served from memory; everything else is missing.
|
||||
struct OneFile(Vec<u8>);
|
||||
|
||||
impl Services for OneFile {
|
||||
fn read_file(&self, path: &str) -> std::io::Result<Vec<u8>> {
|
||||
if path == PATH { Ok(self.0.clone()) } else { Err(std::io::Error::new(std::io::ErrorKind::NotFound, path.to_string())) }
|
||||
}
|
||||
fn write_file(&self, _: &str, _: &[u8]) -> std::io::Result<()> {
|
||||
Err(std::io::Error::other("the fuzzer writes nothing"))
|
||||
}
|
||||
fn exists(&self, path: &str) -> bool {
|
||||
path == PATH
|
||||
}
|
||||
fn stamp(&self, _: &str) -> Option<(u64, u128)> {
|
||||
None
|
||||
}
|
||||
fn list_dir(&self, _: &str) -> Vec<String> {
|
||||
vec![]
|
||||
}
|
||||
fn is_dir(&self, _: &str) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Open `bytes` as a project file, then run what the app and agents read first: `Ok(true)` when
|
||||
/// it opened, `Ok(false)` when it was refused. `Err` only for a crash: a panic in the open
|
||||
/// itself, or one the command guard caught in a query.
|
||||
fn open_input(bytes: Vec<u8>) -> Result<bool, String> {
|
||||
let mut s = Session { services: Arc::new(OneFile(bytes)), ..Default::default() };
|
||||
if crate::commands::open_for_tests(&mut s, &json!({ "path": PATH })).is_err() {
|
||||
return Ok(false); // refused: fine
|
||||
}
|
||||
let crashed = |r: crate::Result<Value>| match r {
|
||||
Err(e) if e.to_string().contains("internal error") => Err(e.to_string()),
|
||||
_ => Ok(()),
|
||||
};
|
||||
crashed(s.execute("project.summary", json!({})))?;
|
||||
let comps: Vec<_> = s.project.comps().map(|(id, c)| (id.0, c.layers.iter().map(|l| l.id.0).collect::<Vec<_>>())).collect();
|
||||
for (comp, layers) in comps.into_iter().take(4) {
|
||||
crashed(s.execute("comp.info", json!({ "comp": comp })))?;
|
||||
for layer in layers.into_iter().take(8) {
|
||||
crashed(s.execute("layer.tree", json!({ "comp": comp, "layer": layer, "depth": 3, "time": 1.0 })))?;
|
||||
}
|
||||
}
|
||||
// The first frame the viewer shows, at thumbnail size.
|
||||
if let Some(id) = s.state.active_comp
|
||||
&& let Some(c) = s.project.comp(id)
|
||||
{
|
||||
let scale = (32.0 / f64::from(c.width.max(c.height).max(1))).min(1.0);
|
||||
effectcraft_render::render_frame(&s.project, id, effectcraft_time::Tick::from_seconds_f64(1.0), scale);
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Run one input on its own thread: a panic becomes its message, a hang a timeout.
|
||||
fn run_guarded(bytes: Vec<u8>, deadline: Duration) -> Result<bool, String> {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let spawned = std::thread::Builder::new().name("fuzz-open".into()).stack_size(8 << 20).spawn(move || {
|
||||
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| open_input(bytes)));
|
||||
let r = match r {
|
||||
Ok(r) => r,
|
||||
Err(p) => {
|
||||
Err(format!("panic: {}", p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default()))
|
||||
}
|
||||
};
|
||||
let _ = tx.send(r);
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
return Err(format!("couldn't start the fuzz thread: {e}"));
|
||||
}
|
||||
rx.recv_timeout(deadline).unwrap_or_else(|_| Err(format!("hang: no result within {deadline:?}")))
|
||||
}
|
||||
|
||||
/// Small projects covering what a project holds: the empty project, and the demo cut down to
|
||||
/// one layer of one comp at a time (shape, text, solid, precomp, camera, light, null, effects,
|
||||
/// masks, expressions; other comps keep their first layer), each as JSON and as XML. The whole
|
||||
/// demo (700 kB) would make every input slow.
|
||||
fn seeds() -> Vec<Vec<u8>> {
|
||||
let demo = crate::demo::demo_project();
|
||||
let mut projects = vec![effectcraft_project::Project::default()];
|
||||
for (cid, c) in demo.comps() {
|
||||
for i in 0..c.layers.len() {
|
||||
let mut p = demo.clone();
|
||||
for (id, item) in p.items.iter_mut() {
|
||||
if let effectcraft_project::ItemKind::Comp(c) = &mut item.kind {
|
||||
let keep = if id == cid { i } else { 0 };
|
||||
let layers = &mut Arc::make_mut(c).layers;
|
||||
*layers = layers.get(keep).cloned().into_iter().collect();
|
||||
}
|
||||
}
|
||||
projects.push(p);
|
||||
}
|
||||
}
|
||||
let mut out = vec![];
|
||||
for p in projects {
|
||||
let json = p.to_file_json().unwrap();
|
||||
out.push(crate::xml_project::to_xml(&json).unwrap().into_bytes());
|
||||
out.push(json.into_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[rustfmt::skip]
|
||||
const TOKENS: &[&str] = &[
|
||||
"{", "}", "[", "]", "\"", ",", ":", "null", "true", "-1", "0", "1e308", "-1e308", "1e-320", "18446744073709551615",
|
||||
"-9223372036854775808", "4294967295", "4294967296", "\\u0000", "\\ud800", "<", ">", "</", "/>", "<arr>", "</arr>", "<obj>",
|
||||
"<m n=\"", "</m>", "<num>", "</num>", "<str>", "</str>", "<null/>", "<!--", "-->", "<?", "?>", "&", ";", "�",
|
||||
"�", "&#;", "&#x;", "&", "\u{feff}", "é", "\u{1F600}",
|
||||
];
|
||||
|
||||
const NUMBERS: &[f64] = &[0.0, -0.0, 1.0, -1.0, 0.5, -0.5, 2.0, 1e-300, 1e15, 1e300, -1e300, 4294967295.0, 4294967296.0, 65536.0, 254016000000.0];
|
||||
const INTEGERS: &[i64] = &[0, -1, 1, 2, 3, 255, 256, 65535, 2147483647, -2147483648, i64::MAX, i64::MIN, 9_007_199_254_740_993];
|
||||
|
||||
/// Every node of `v`, depth first (the root is 0).
|
||||
fn count(v: &Value) -> usize {
|
||||
1 + match v {
|
||||
Value::Array(a) => a.iter().map(count).sum(),
|
||||
Value::Object(m) => m.values().map(count).sum(),
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The `k`-th node (depth first; the root is 0).
|
||||
fn nth<'a>(v: &'a mut Value, k: &mut usize) -> Option<&'a mut Value> {
|
||||
if *k == 0 {
|
||||
return Some(v);
|
||||
}
|
||||
*k -= 1;
|
||||
match v {
|
||||
Value::Array(a) => a.iter_mut().find_map(|x| nth(x, k)),
|
||||
Value::Object(m) => m.values_mut().find_map(|x| nth(x, k)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn node_mut(v: &mut Value, k: usize) -> Option<&mut Value> {
|
||||
nth(v, &mut { k })
|
||||
}
|
||||
|
||||
fn interesting(r: &mut Rng, around: &Value) -> Value {
|
||||
match r.below(7) {
|
||||
0 => json!(NUMBERS[r.below(NUMBERS.len())]),
|
||||
1 => json!(INTEGERS[r.below(INTEGERS.len())]),
|
||||
2 => match around.as_f64() {
|
||||
Some(x) => json!(x * [-1.0, 2.0, 1e6, 1e-6, 0.0][r.below(5)]),
|
||||
None => Value::Null,
|
||||
},
|
||||
3 => json!(["", "#999", "@0", "effects/#1", "../../x", "\u{0}", "transform/position", &"a".repeat(r.below(300))][r.below(8)]),
|
||||
4 => Value::Bool(r.chance(2)),
|
||||
5 => json!([]),
|
||||
_ => Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
/// A structural mutation of a project's JSON (when the input still parses).
|
||||
fn mutate_tree(r: &mut Rng, v: &mut Value) {
|
||||
let n = count(v);
|
||||
for _ in 0..1 + r.below(4) {
|
||||
let k = r.below(n.max(1));
|
||||
let donor = node_mut(v, r.below(n.max(1))).map(|d| d.clone()).unwrap_or(Value::Null);
|
||||
let Some(node) = node_mut(v, k) else { return };
|
||||
match r.below(6) {
|
||||
0 | 1 => *node = interesting(r, node),
|
||||
2 => *node = donor,
|
||||
3 => match node {
|
||||
Value::Object(m) if !m.is_empty() => {
|
||||
let key = m.keys().nth(r.below(m.len())).cloned().unwrap_or_default();
|
||||
m.remove(&key);
|
||||
}
|
||||
Value::Array(a) if !a.is_empty() => {
|
||||
a.remove(r.below(a.len()));
|
||||
}
|
||||
_ => *node = Value::Null,
|
||||
},
|
||||
4 => {
|
||||
if let Value::Array(a) = node
|
||||
&& !a.is_empty()
|
||||
{
|
||||
let i = r.below(a.len());
|
||||
let x = a[i].clone();
|
||||
a.insert(r.below(a.len() + 1), x);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if let Value::Object(m) = node {
|
||||
m.insert(["id", "parent", "source", "schema", "nextId", "layers", "items", "kind"][r.below(8)].into(), interesting(r, &Value::Null));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mutate_bytes(r: &mut Rng, b: &mut Vec<u8>) {
|
||||
for _ in 0..1 + r.below(3) {
|
||||
let len = b.len();
|
||||
match r.below(8) {
|
||||
0 if len > 0 => {
|
||||
let i = r.below(len);
|
||||
b[i] ^= 1 << r.below(8);
|
||||
}
|
||||
1 if len > 0 => {
|
||||
let i = r.below(len);
|
||||
b[i] = r.next() as u8;
|
||||
}
|
||||
2 if len > 0 => {
|
||||
let a = r.below(len);
|
||||
let e = (a + 1 + r.below(64)).min(len);
|
||||
b.drain(a..e);
|
||||
}
|
||||
3 if len > 0 => {
|
||||
let a = r.below(len);
|
||||
let e = (a + 1 + r.below(256)).min(len);
|
||||
let chunk = b[a..e].to_vec();
|
||||
let at = r.below(len + 1);
|
||||
b.splice(at..at, chunk);
|
||||
}
|
||||
4 => {
|
||||
let t = TOKENS[r.below(TOKENS.len())].as_bytes();
|
||||
let at = r.below(len + 1);
|
||||
b.splice(at..at, t.iter().copied());
|
||||
}
|
||||
5 if len > 0 => b.truncate(r.below(len)),
|
||||
6 => {
|
||||
// Deep nesting: what a recursive reader overflows its stack on.
|
||||
let (open, close) = [("[", "]"), ("<arr>", "</arr>"), ("<obj><m n=\"a\">", "</m></obj>")][r.below(3)];
|
||||
let depth = [200, 5_000, 200_000][r.below(3)];
|
||||
// Where the reader expects a value: after an opening token of the same kind.
|
||||
let opens: Vec<usize> = b.windows(open.len().min(5)).enumerate().filter(|(_, w)| *w == &open.as_bytes()[..w.len()]).map(|(i, _)| i).collect();
|
||||
let at = if opens.is_empty() { r.below(len + 1) } else { opens[r.below(opens.len())] };
|
||||
let mut nest = open.repeat(depth).into_bytes();
|
||||
nest.extend(close.repeat(if r.chance(2) { depth } else { 0 }).into_bytes());
|
||||
b.splice(at..at, nest);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One fuzz input from the seeds, decided by the iteration's own generator.
|
||||
fn mutate(r: &mut Rng, seeds: &[Vec<u8>]) -> Vec<u8> {
|
||||
let mut b = seeds[r.below(seeds.len())].clone();
|
||||
if r.chance(2) {
|
||||
let text = String::from_utf8_lossy(&b).to_string();
|
||||
let json = if crate::xml_project::is_xml(&text) { crate::xml_project::from_xml(&text).ok() } else { Some(text) };
|
||||
if let Some(mut v) = json.and_then(|j| serde_json::from_str::<Value>(&j).ok()) {
|
||||
mutate_tree(r, &mut v);
|
||||
let j = v.to_string();
|
||||
b = if r.chance(3) { crate::xml_project::to_xml(&j).unwrap_or(j).into_bytes() } else { j.into_bytes() };
|
||||
}
|
||||
}
|
||||
if r.chance(3) || b.is_empty() {
|
||||
mutate_bytes(r, &mut b);
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
fn env_u64(name: &str, default: u64) -> u64 {
|
||||
std::env::var(name).ok().and_then(|v| v.trim().parse().ok()).unwrap_or(default)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuzz_file_open_never_crashes() {
|
||||
let seed = env_u64("EFFECTCRAFT_FUZZ_SEED", 0x00EC_F022);
|
||||
let start = env_u64("EFFECTCRAFT_FUZZ_START", 0);
|
||||
let iters = env_u64("EFFECTCRAFT_FUZZ_ITERS", 3_000);
|
||||
let budget = Duration::from_secs(env_u64("EFFECTCRAFT_FUZZ_SECS", 6));
|
||||
let seeds = seeds();
|
||||
// The unmutated seeds open.
|
||||
for s in &seeds {
|
||||
assert_eq!(run_guarded(s.clone(), Duration::from_secs(30)), Ok(true));
|
||||
}
|
||||
let began = Instant::now();
|
||||
let mut failures = vec![];
|
||||
let (mut ran, mut opened) = (0, 0);
|
||||
for i in start..start + iters {
|
||||
if began.elapsed() > budget {
|
||||
break;
|
||||
}
|
||||
let mut r = Rng(seed ^ i.wrapping_mul(0xD6E8_FEB8_6659_FD93));
|
||||
let input = mutate(&mut r, &seeds);
|
||||
ran += 1;
|
||||
match run_guarded(input.clone(), Duration::from_secs(10)) {
|
||||
Ok(o) => opened += usize::from(o),
|
||||
Err(e) => {
|
||||
let head = String::from_utf8_lossy(&input[..input.len().min(200)]).to_string();
|
||||
failures.push(format!("iteration {i} (seed {seed:#x}, {} bytes): {e}\n input starts: {head:?}", input.len()));
|
||||
if failures.len() >= 5 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "File ▸ Open crashed on {} of {ran} fuzz inputs:\n{}", failures.len(), failures.join("\n"));
|
||||
eprintln!("fuzz_file_open: {ran} inputs ({opened} opened) in {:?}, no crashes", began.elapsed());
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
//! Sizes, frame rates and motion-blur samples stay within the project limits when a project is
|
||||
//! opened and when a command sets them (#788).
|
||||
|
||||
use effectcraft_project::{ItemKind, limits};
|
||||
use effectcraft_render::RenderOpts;
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{Event, Session};
|
||||
|
||||
fn tmp(name: &str) -> String {
|
||||
let d = std::env::temp_dir().join(format!("effectcraft-limits-tests-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d.join(name).to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
/// The saved project with `edit` applied to the JSON of every item of kind `kind`.
|
||||
fn tamper(text: &str, kind: &str, edit: impl Fn(&mut Value)) -> String {
|
||||
let mut v: Value = serde_json::from_str(text).unwrap();
|
||||
let items = v["items"].as_object_mut().unwrap();
|
||||
for it in items.values_mut().filter(|it| it["kind"]["type"] == kind) {
|
||||
edit(&mut it["kind"]);
|
||||
}
|
||||
serde_json::to_string(&v).unwrap()
|
||||
}
|
||||
|
||||
/// A project whose comp is 4 billion × 0 px at 10^18 fps, -5 s long with 4 billion motion-blur
|
||||
/// samples, holding a solid of 4 billion px.
|
||||
fn hostile_project() -> String {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 32, "frameRate": 30, "duration": 2})).unwrap();
|
||||
s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 64, "height": 2})).unwrap();
|
||||
let text = s.project.to_file_json().unwrap();
|
||||
let text = tamper(&text, "Comp", |c| {
|
||||
c["width"] = json!(4_000_000_000u64);
|
||||
c["height"] = json!(0);
|
||||
c["frame_rate"] = json!({"num": 1_000_000_000_000_000_000i64, "den": 1});
|
||||
c["duration"] = json!(-5);
|
||||
c["motion_blur_samples"] = json!(4_000_000_000u64);
|
||||
});
|
||||
tamper(&text, "Solid", |so| so["width"] = json!(4_000_000_000u64))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hostile_project_opens_within_the_limits_and_says_what_changed() {
|
||||
let json_path = tmp("Hostile.ecproj");
|
||||
let text = hostile_project();
|
||||
std::fs::write(&json_path, &text).unwrap();
|
||||
let xml_path = tmp("Hostile.ecprojx");
|
||||
std::fs::write(&xml_path, crate::xml_project::to_xml(&text).unwrap()).unwrap();
|
||||
for path in [json_path, xml_path] {
|
||||
let mut s = Session::default();
|
||||
let r = s.execute("file.open", json!({"path": path})).unwrap();
|
||||
let repaired = r["repaired"].as_array().unwrap();
|
||||
assert!(repaired.iter().any(|n| n.as_str().unwrap().contains("4000000000 × 0 px → 30000 × 1 px")), "{path}: {repaired:?}");
|
||||
let toast = s.drain_events().into_iter().any(|e| matches!(e, Event::Toast { message, .. } if message.contains("outside EffectCraft's limits")));
|
||||
assert!(toast, "{path}");
|
||||
let (cid, c) = s.project.comps().next().map(|(id, c)| (*id, c.clone())).unwrap();
|
||||
assert_eq!((c.width, c.height), (limits::MAX_SIZE, 1));
|
||||
assert_eq!(c.frame_rate.as_f64(), limits::MAX_FPS);
|
||||
assert_eq!(c.duration, c.frame_rate.frame_duration(), "at least one frame");
|
||||
assert_eq!(c.work_area, (Tick::ZERO, c.duration));
|
||||
assert_eq!(c.motion_blur_samples, limits::MOTION_BLUR_SAMPLES.1);
|
||||
let solid = s.project.items.values().find_map(|i| if let ItemKind::Solid(so) = &i.kind { Some(so.clone()) } else { None }).unwrap();
|
||||
assert_eq!((solid.width, solid.height), (limits::MAX_SIZE, 2));
|
||||
// The comp renders at Full resolution, at its (capped) size.
|
||||
let img = s.render(cid, Tick::ZERO, RenderOpts { scale: 1.0, ..Default::default() });
|
||||
assert_eq!((img.width, img.height), (limits::MAX_SIZE, 1));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_project_within_the_limits_opens_unchanged() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 32, "frameRate": 24, "duration": 2})).unwrap();
|
||||
let text = s.project.to_file_json().unwrap();
|
||||
let (p, notes) = effectcraft_project::Project::from_json_repaired(&text).unwrap();
|
||||
assert!(notes.is_empty(), "{notes:?}");
|
||||
assert_eq!(p, *s.project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_cap_sizes_at_30000_px() {
|
||||
let mut s = Session::default();
|
||||
// 2^32 + 10 used to wrap to 10 px.
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 4_294_967_306u64, "height": 32, "pixelAspect": 1e9})).unwrap();
|
||||
let c = s.active_comp().unwrap().clone();
|
||||
assert_eq!((c.width, c.height, c.pixel_aspect), (limits::MAX_SIZE, 32, limits::MAX_PIXEL_ASPECT));
|
||||
s.execute("comp.settings", json!({"width": 64, "height": 4_294_967_306u64})).unwrap();
|
||||
assert_eq!((s.active_comp().unwrap().width, s.active_comp().unwrap().height), (64, limits::MAX_SIZE));
|
||||
// New Solid: 0 and 4 billion px are brought to 1 and 30,000; the layer matches its solid.
|
||||
s.execute("layer.newSolid", json!({"width": 0, "height": 4_000_000_000u64})).unwrap();
|
||||
let solid = |s: &Session| s.project.items.values().find_map(|i| if let ItemKind::Solid(so) = &i.kind { Some(so.clone()) } else { None }).unwrap();
|
||||
assert_eq!((solid(&s).width, solid(&s).height), (1, limits::MAX_SIZE));
|
||||
// Layer Settings on the new (selected) solid layer.
|
||||
s.execute("layer.settings", json!({"width": 4_000_000_000u64, "height": 8})).unwrap();
|
||||
assert_eq!((solid(&s).width, solid(&s).height), (limits::MAX_SIZE, 8));
|
||||
s.execute("file.importSolid", json!({"width": 99_999_999, "height": 4})).unwrap();
|
||||
assert!(s.project.items.values().any(|i| matches!(&i.kind, ItemKind::Solid(so) if (so.width, so.height) == (limits::MAX_SIZE, 4))));
|
||||
// A placeholder can't ask for a huge frame or a zero frame rate either.
|
||||
s.execute("file.importPlaceholder", json!({"width": 4_000_000_000u64, "height": 8, "frameRate": 0})).unwrap();
|
||||
let f = s.project.items.values().find_map(|i| if let ItemKind::Footage(f) = &i.kind { Some(f.clone()) } else { None }).unwrap();
|
||||
assert_eq!((f.width, f.height, f.frame_rate), (limits::MAX_SIZE, 8, FrameRate::FPS_29_97));
|
||||
}
|
||||
|
|
@ -52,6 +52,41 @@ fn layer_quality_sampling_frame_blending_and_undo() {
|
|||
assert!(s.execute("layer.quality", json!({"quality": "fancy"})).is_err());
|
||||
}
|
||||
|
||||
/// `layer.setSwitch {others: true}` (Alt-click on an A/V feature): the layer stands alone, the
|
||||
/// other layers come back the next time.
|
||||
#[test]
|
||||
fn set_switch_others() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
let b = solid(&mut s, "#00ff00");
|
||||
let c = solid(&mut s, "#0000ff");
|
||||
let get = |s: &Session, f: fn(&effectcraft_project::Layer) -> bool| [a, b, c].map(|l| f(&layer(s, l)));
|
||||
s.execute("layer.setSwitch", json!({"layers": [b], "switch": "video", "value": false})).unwrap();
|
||||
// B is hidden: it is shown, alone; then the others come back; one undo step each.
|
||||
s.execute("layer.setSwitch", json!({"layers": [b], "switch": "video", "others": true})).unwrap();
|
||||
assert_eq!(get(&s, |l| l.switches.video), [false, true, false]);
|
||||
s.execute("layer.setSwitch", json!({"layers": [b], "switch": "video", "others": true})).unwrap();
|
||||
assert_eq!(get(&s, |l| l.switches.video), [true; 3]);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(get(&s, |l| l.switches.video), [false, true, false]);
|
||||
// Solo.
|
||||
s.execute("layer.setSwitch", json!({"layers": [a], "switch": "solo", "value": true})).unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [c], "switch": "solo", "others": true})).unwrap();
|
||||
assert_eq!(get(&s, |l| l.switches.solo), [false, false, true]);
|
||||
// Lock: the layer stays unlocked and the others lock, then unlock.
|
||||
s.execute("layer.setSwitch", json!({"layers": [a], "switch": "lock", "others": true})).unwrap();
|
||||
assert_eq!(get(&s, |l| l.switches.locked), [false, true, true]);
|
||||
s.execute("layer.setSwitch", json!({"layers": [a], "switch": "lock", "others": true})).unwrap();
|
||||
assert_eq!(get(&s, |l| l.switches.locked), [false; 3]);
|
||||
// A track matte keeps its video off.
|
||||
s.execute("layer.setTrackMatte", json!({"layer": b, "matte": a})).unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [a], "switch": "video", "value": false})).unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [c], "switch": "video", "others": true})).unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [c], "switch": "video", "others": true})).unwrap();
|
||||
assert_eq!(get(&s, |l| l.switches.video), [false, true, true]);
|
||||
assert!(s.execute("layer.setSwitch", json!({"layers": [c], "switch": "nope", "others": true})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_switches_menu() {
|
||||
let mut s = comp();
|
||||
|
|
@ -1416,3 +1451,16 @@ fn duplicate_with_shape_items_selected_duplicates_them_in_place() {
|
|||
s.execute("edit.duplicate", json!({})).unwrap();
|
||||
assert_eq!(s.active_comp().unwrap().layers.len(), n + 1);
|
||||
}
|
||||
|
||||
/// The Interpret Footage form sends "Loop (times)" as a float (3.0); it must still be applied (#909).
|
||||
#[test]
|
||||
fn interpret_footage_loop_from_form_float() {
|
||||
let mut s = comp();
|
||||
let p = s.execute("file.importPlaceholder", json!({"name": "Shot", "width": 320, "height": 240})).unwrap()["item"].as_u64().unwrap();
|
||||
s.state.project_selection = vec![effectcraft_project::ItemId(p)];
|
||||
s.execute("file.interpretFootage", json!({"loop": 3.0})).unwrap();
|
||||
match &s.project.item(effectcraft_project::ItemId(p)).unwrap().kind {
|
||||
ItemKind::Footage(f) => assert_eq!(f.loop_count, 3),
|
||||
_ => panic!(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -151,6 +151,44 @@ fn renderer_switch_adds_geometry_options() {
|
|||
assert_eq!(layer(&s, t).props.prop("geometryOptions/extrusionDepth").unwrap().value.as_f64(), 40.0);
|
||||
}
|
||||
|
||||
/// Contents ▸ Add ▸ Front / Bevel / Side / Back ▸ Color (#315): one surface item per group,
|
||||
/// reused by later adds, one undo step each; text gets the same as animator properties.
|
||||
#[test]
|
||||
fn surface_colors_for_extruded_shapes_and_text() {
|
||||
let mut s = session();
|
||||
s.execute("comp.renderer", json!({"renderer": "advanced3d"})).unwrap();
|
||||
let sh = s.execute("layer.newShape", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||
let r = s.execute("layer.addShapeItem", json!({"layer": sh, "kind": "sideColor"})).unwrap();
|
||||
assert_eq!(r["path"], "contents/side");
|
||||
let side = r["uid"].as_u64().unwrap();
|
||||
s.execute("prop.set", json!({"layer": sh, "path": "contents/side/color", "value": [0.0, 1.0, 0.0, 1.0]})).unwrap();
|
||||
// Adding Side ▸ Color again reuses the item (and keeps its colour).
|
||||
assert_eq!(s.execute("layer.addShapeItem", json!({"layer": sh, "kind": "sideColor"})).unwrap()["uid"].as_u64(), Some(side));
|
||||
let l = layer(&s, sh);
|
||||
let contents = l.props.sub("contents").unwrap();
|
||||
assert_eq!(contents.groups().filter(|g| g.match_id == "side").count(), 1);
|
||||
assert_eq!(l.props.prop("contents/side/color").unwrap().value, KV::Color([0.0, 1.0, 0.0, 1.0]));
|
||||
// Into a shape group.
|
||||
s.execute("layer.addShapeItem", json!({"layer": sh, "kind": "group"})).unwrap();
|
||||
let r = s.execute("layer.addShapeItem", json!({"layer": sh, "kind": "frontColor", "group": "contents/group"})).unwrap();
|
||||
assert_eq!(r["path"], "contents/group/contents/front");
|
||||
assert!(s.execute("layer.addShapeItem", json!({"layer": sh, "kind": "sideShininess"})).is_err());
|
||||
s.undo();
|
||||
assert!(layer(&s, sh).props.group("contents/group/contents/front").is_none());
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
s.execute("prop.set", json!({"layer": sh, "path": "geometryOptions/extrusionDepth", "value": 40})).unwrap();
|
||||
let _ = s.render(cid, s.time(), Default::default());
|
||||
// Text: Animate ▸ Front / Bevel / Side / Back ▸ Color.
|
||||
let t = s.execute("layer.newText", json!({"text": "Hi"})).unwrap()["layer"].as_u64().unwrap();
|
||||
for k in ["frontColor", "bevelColor", "sideColor", "backColor"] {
|
||||
s.execute("layer.addTextAnimator", json!({"layer": t, "property": k})).unwrap();
|
||||
}
|
||||
let l = layer(&s, t);
|
||||
assert!(l.props.prop("text/animators/#3/properties/sideColor").is_some());
|
||||
s.execute("prop.set", json!({"layer": t, "path": "geometryOptions/extrusionDepth", "value": 20})).unwrap();
|
||||
let _ = s.render(cid, s.time(), Default::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn environment_layer_toggle() {
|
||||
let mut s = session();
|
||||
|
|
|
|||
|
|
@ -185,6 +185,16 @@ fn scopes_read_the_comp_frame() {
|
|||
|
||||
// ---------------------------------------------------------------- Footage panel
|
||||
|
||||
#[test]
|
||||
fn footage_panel_overlay_keeps_full_length_past_the_comp_end() {
|
||||
let (mut s, cid, _clip, item) = setup(32, 24, 8.0, |_| Image::filled(32, 24, [0.5, 0.5, 0.5, 1.0]));
|
||||
s.execute("footage.open", json!({"item": item.0})).unwrap();
|
||||
let r = s.execute("footage.overlayEdit", json!({"item": item.0, "in": 0.0, "out": 4.0, "at": 6.0})).unwrap();
|
||||
let ov = LayerId(r["layer"].as_u64().unwrap());
|
||||
let l = s.project.comp(cid).unwrap().layer(ov).unwrap();
|
||||
assert_eq!((l.in_point.seconds(), l.out_point.seconds()), (6.0, 10.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn footage_panel_overlay_and_ripple_insert() {
|
||||
let (mut s, cid, clip, item) = setup(32, 24, 8.0, |_| Image::filled(32, 24, [0.5, 0.5, 0.5, 1.0]));
|
||||
|
|
@ -629,3 +639,36 @@ fn media_browser_over_a_virtual_tree() {
|
|||
assert_eq!(*b.actions.lock().unwrap(), ["openFolder"]);
|
||||
assert!(s.execute("mediaBrowser.action", json!({})).is_err());
|
||||
}
|
||||
|
||||
/// Footage the source can't decode is reported with its reason by `project.summary` and
|
||||
/// `footage.info`, so an agent can tell the user why the picture is black.
|
||||
#[test]
|
||||
fn footage_that_cannot_be_decoded_reports_why() {
|
||||
struct Broken;
|
||||
impl FootageSource for Broken {
|
||||
fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<Image>> {
|
||||
None
|
||||
}
|
||||
fn problem(&self, f: &Footage) -> Option<String> {
|
||||
Some(format!("{} isn't supported by the built-in decoder", f.codec))
|
||||
}
|
||||
}
|
||||
let mut s = Session { footage: Arc::new(Broken), ..Default::default() };
|
||||
s.execute("comp.new", json!({"name": "C", "width": 64, "height": 36, "duration": 2})).unwrap();
|
||||
let f = Footage {
|
||||
kind: FootageKind::Video,
|
||||
path: "a.mp4".into(),
|
||||
codec: "H.264 (High 4:2:2)".into(),
|
||||
width: 64,
|
||||
height: 36,
|
||||
has_video: true,
|
||||
..Default::default()
|
||||
};
|
||||
let item = std::sync::Arc::make_mut(&mut s.project).add_item("Clip", Default::default(), None, ItemKind::Footage(f));
|
||||
let sum = s.execute("project.summary", json!({})).unwrap();
|
||||
let it = sum["items"].as_array().unwrap().iter().find(|i| i["id"] == json!(item.0)).unwrap();
|
||||
assert_eq!(it["problem"], json!("H.264 (High 4:2:2) isn't supported by the built-in decoder"));
|
||||
assert!(sum["items"].as_array().unwrap().iter().filter(|i| i["id"] != json!(item.0)).all(|i| i.get("problem").is_none()), "only footage reports one");
|
||||
s.execute("footage.open", json!({"item": item.0})).unwrap();
|
||||
assert_eq!(s.execute("footage.info", json!({})).unwrap()["problem"], it["problem"]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -195,3 +195,15 @@ fn replace_footage_and_reload_keep_sequences() {
|
|||
assert_eq!((f.sequence.len(), f.frame_rate, f.start_timecode), (5, FrameRate::from_f64(12.0), Some(2)));
|
||||
assert_eq!(f.duration, FrameRate::from_f64(12.0).tick_of(5));
|
||||
}
|
||||
|
||||
/// #756: numbered DPX and Cineon scans import as sequences, as in After Effects (`.cin` was not
|
||||
/// an image to the Media Browser, so its runs came in one file at a time).
|
||||
#[test]
|
||||
fn dpx_and_cineon_runs_import_as_sequences() {
|
||||
let dir = folder(&["plate.0001.dpx", "plate.0002.dpx", "plate.0003.dpx", "scan_01.cin", "scan_02.cin"]);
|
||||
let mut s = session();
|
||||
let r = s.execute("file.import", json!({"paths": [at(&dir, "plate.0002.dpx"), at(&dir, "scan_01.cin")], "sequence": true})).unwrap();
|
||||
let mut got: Vec<(String, usize)> = r["items"].as_array().unwrap().iter().map(|i| footage(&s, i)).map(|(n, f)| (n, f.sequence.len())).collect();
|
||||
got.sort();
|
||||
assert_eq!(got, [("plate.[0001-0003].dpx".to_string(), 3), ("scan_[01-02].cin".to_string(), 2)], "{r}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ fn prefs_set_validates_keys_types_and_ranges() {
|
|||
}
|
||||
|
||||
/// Settings ▸ Appearance ▸ UI Scale (#284): a percentage, 100 by default, kept within 75–200;
|
||||
/// the dialog's choices set it as a number.
|
||||
/// the dialog types or drags any whole percentage in that range (#682).
|
||||
#[test]
|
||||
fn ui_scale_is_a_percentage_from_75_to_200() {
|
||||
let mut p = Prefs::default();
|
||||
|
|
@ -101,10 +101,8 @@ fn ui_scale_is_a_percentage_from_75_to_200() {
|
|||
p.set("appearance.uiScale", json!(10)).unwrap();
|
||||
assert_eq!(p.appearance.ui_scale, 75);
|
||||
let page = crate::prefs::pages().into_iter().find(|pg| pg.id == "appearance").unwrap();
|
||||
let listed = page.items.iter().any(|i| {
|
||||
matches!(i, crate::prefs::Item::Setting { key: "appearance.uiScale", kind: crate::prefs::Kind::Choice(c), live: true, .. } if c.contains(&("200%", "200")))
|
||||
});
|
||||
assert!(listed, "Settings ▸ Appearance offers it");
|
||||
let field = crate::prefs::Item::Setting { key: "appearance.uiScale", label: "UI Scale", kind: crate::prefs::Kind::Int(75, 200, "%"), live: true };
|
||||
assert!(page.items.contains(&field), "Settings ▸ Appearance offers it");
|
||||
// Older settings files without it get the default.
|
||||
assert_eq!(Prefs::from_json(r#"{"version": 2, "appearance": {"theme": "light"}}"#).appearance.ui_scale, 100);
|
||||
}
|
||||
|
|
@ -214,6 +212,50 @@ fn prefs_commands_get_set_reset_and_persist() {
|
|||
);
|
||||
}
|
||||
|
||||
/// Composition Settings presets the user saves live in the settings: they persist, a save with
|
||||
/// an existing name replaces it, they survive Reset All, and invalid ones (from a hand-edited
|
||||
/// settings file) are dropped on load.
|
||||
#[test]
|
||||
fn composition_presets_save_replace_delete_and_persist() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
let mut s = Session { config: Some(store.clone()), ..Default::default() };
|
||||
let p = |name: &str, w: u32, fps: f64| json!({"name": name, "width": w, "height": 1440, "pixelAspect": 1.0, "frameRate": fps});
|
||||
assert_eq!(s.execute_checked("comp.savePreset", p(" Edit 1440p 60 ", 2560, 60.0)).unwrap(), json!({"name": "Edit 1440p 60", "replaced": false}));
|
||||
s.execute_checked("comp.savePreset", p("Vertical", 1440, 30.0)).unwrap();
|
||||
assert_eq!(s.execute("comp.savePreset", p("Vertical", 1080, 30.0)).unwrap()["replaced"], json!(true));
|
||||
let list = s.execute("comp.presets", json!({})).unwrap();
|
||||
assert_eq!(list.as_array().unwrap().len(), 2);
|
||||
assert_eq!(list[0]["name"], "Edit 1440p 60");
|
||||
assert_eq!((list[0]["width"].clone(), list[0]["frameRate"].clone()), (json!(2560), json!(60.0)));
|
||||
assert_eq!(list[1]["width"], json!(1080), "replaced in place");
|
||||
for bad in [
|
||||
p("", 1920, 30.0),
|
||||
p("Tiny", 2, 30.0),
|
||||
p("Fast", 1920, 5000.0),
|
||||
json!({"name": "x", "width": 1920.5, "height": 1080, "pixelAspect": 1.0, "frameRate": 30}),
|
||||
json!({"name": "x"}),
|
||||
] {
|
||||
assert!(s.execute("comp.savePreset", bad.clone()).is_err(), "{bad}");
|
||||
}
|
||||
// Persisted, and kept by Reset All.
|
||||
let mut s2 = Session { config: Some(store.clone()), ..Default::default() };
|
||||
s2.load_settings();
|
||||
assert_eq!(s2.prefs.comp_presets, s.prefs.comp_presets);
|
||||
s2.execute("prefs.reset", json!({})).unwrap();
|
||||
assert_eq!(s2.prefs.comp_presets.len(), 2);
|
||||
s2.execute_checked("comp.deletePreset", json!({"name": "Vertical"})).unwrap();
|
||||
assert!(s2.execute("comp.deletePreset", json!({"name": "Vertical"})).is_err(), "already gone");
|
||||
let mut s3 = Session { config: Some(store.clone()), ..Default::default() };
|
||||
s3.load_settings();
|
||||
assert_eq!(s3.prefs.comp_presets.iter().map(|q| q.name.as_str()).collect::<Vec<_>>(), ["Edit 1440p 60"]);
|
||||
// A hand-edited settings file: the broken preset is dropped, the good one kept.
|
||||
let mut v: serde_json::Value = serde_json::from_str(&store.read(PREFS_FILE).unwrap()).unwrap();
|
||||
v["compPresets"].as_array_mut().unwrap().push(json!({"name": "Broken", "width": 0, "height": 1080, "pixelAspect": 1.0, "frameRate": f64::NAN}));
|
||||
v["compPresets"].as_array_mut().unwrap().push(json!({"name": 7}));
|
||||
let back = Prefs::from_json(&v.to_string());
|
||||
assert_eq!(back.comp_presets.iter().map(|q| q.name.as_str()).collect::<Vec<_>>(), ["Edit 1440p 60"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_levels_are_honoured() {
|
||||
let mut s = session_with_comp();
|
||||
|
|
@ -333,6 +375,24 @@ fn defaults_have_no_conflicts_and_match_the_menus() {
|
|||
assert!(t.bindables.len() > 500);
|
||||
}
|
||||
|
||||
/// Cmd+Left / Cmd+Right Arrow step a frame like Page Up / Page Down (Shift: 10 frames).
|
||||
#[test]
|
||||
fn cmd_arrows_step_frames_like_page_keys() {
|
||||
let s = Session::default();
|
||||
let t = s.shortcuts();
|
||||
for (id, keys) in [
|
||||
("time.nextFrame", ["PageDown", "Cmd+ArrowRight"]),
|
||||
("time.previousFrame", ["PageUp", "Cmd+ArrowLeft"]),
|
||||
("time.forward10", ["Shift+PageDown", "Cmd+Shift+ArrowRight"]),
|
||||
("time.back10", ["Shift+PageUp", "Cmd+Shift+ArrowLeft"]),
|
||||
] {
|
||||
assert_eq!(t.keys.get(id).map(Vec::as_slice), Some(keys.map(String::from).as_slice()), "{id}");
|
||||
// The menus and tooltips keep showing the Page key.
|
||||
assert_eq!(t.shortcut_of(id, &json!(null)), Some(keys[0]));
|
||||
}
|
||||
assert!(t.all_conflicts().is_empty(), "{:?}", t.all_conflicts());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortcut_edits_detect_conflicts_and_create_a_custom_preset() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
|
|
@ -589,7 +649,7 @@ fn interface_language_is_validated_persisted_and_backward_compatible() {
|
|||
assert_eq!(s.execute("prefs.get", json!({"key": "general.language"})).unwrap(), json!("pt-br"));
|
||||
s.execute("prefs.set", json!({"key": "general.language", "value": "ja"})).unwrap();
|
||||
let saved = store.read(PREFS_FILE).unwrap();
|
||||
for bad in [json!("fr"), json!(""), json!(17), json!(null)] {
|
||||
for bad in [json!("xx"), json!(""), json!(17), json!(null)] {
|
||||
assert!(s.execute("prefs.set", json!({"key": "general.language", "value": bad})).is_err());
|
||||
assert_eq!(s.prefs.general.language, "ja");
|
||||
assert_eq!(store.read(PREFS_FILE).unwrap(), saved);
|
||||
|
|
@ -615,6 +675,14 @@ fn interface_language_is_validated_persisted_and_backward_compatible() {
|
|||
let mut es = Session { config: Some(store.clone()), ..Default::default() };
|
||||
es.load_settings();
|
||||
assert_eq!(es.prefs.general.language, "es");
|
||||
// Czech uses the same validation, persistence and JSON round-trip as every catalog.
|
||||
assert!(crate::prefs::LANGUAGES.contains(&("Čeština", "cs")));
|
||||
s.execute("prefs.set", json!({"key": "general.language", "value": "cs"})).unwrap();
|
||||
assert_eq!(s.execute("prefs.get", json!({"key": "general.language"})).unwrap(), json!("cs"));
|
||||
let mut cs = Session { config: Some(store.clone()), ..Default::default() };
|
||||
cs.load_settings();
|
||||
assert_eq!(cs.prefs.general.language, "cs");
|
||||
assert_eq!(Prefs::from_json(r#"{"general":{"language":"cs"}}"#).general.language, "cs");
|
||||
s.execute("prefs.set", json!({"key": "general.language", "value": "ja"})).unwrap();
|
||||
let mut reloaded = Session { config: Some(store), ..Default::default() };
|
||||
reloaded.load_settings();
|
||||
|
|
@ -650,3 +718,44 @@ fn render_backend_says_why_it_renders_on_the_cpu() {
|
|||
assert_eq!(why(&mut s), json!("headless: renders on the CPU unless started with --gpu"));
|
||||
assert_eq!(s.execute("render.backend", json!({"backend": "cpu"})).unwrap()["why"], json!("the project's renderer is Mercury Software Only"));
|
||||
}
|
||||
|
||||
/// Settings ▸ GPU Information names what the GPU compositor runs on, or why it is off, and the
|
||||
/// adapter the window got, so a card the compositor couldn't use doesn't look undetected (#596).
|
||||
#[test]
|
||||
fn gpu_summary_names_the_adapter_or_says_why_there_is_none() {
|
||||
use crate::sysinfo::gpu_summary;
|
||||
use effectcraft_render::{Accelerator, FxStep, Renderer};
|
||||
|
||||
struct Named;
|
||||
impl Accelerator for Named {
|
||||
fn name(&self) -> String {
|
||||
"NVIDIA GeForce RTX 4070 (Vulkan)".into()
|
||||
}
|
||||
fn comp_frame(&self, _: &Renderer, _: crate::project::ItemId, _: effectcraft_time::Tick) -> Option<effectcraft_raster::Image> {
|
||||
None
|
||||
}
|
||||
fn supports_effect(&self, _: &str) -> bool {
|
||||
false
|
||||
}
|
||||
fn effects(&self, _: &[FxStep], _: &effectcraft_effects::Buf, _: Option<f32>) -> Option<effectcraft_effects::Buf> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
let mut s = Session::default();
|
||||
let off = gpu_summary(&s);
|
||||
assert!(off.contains("Compositing: CPU"), "{off}");
|
||||
assert!(off.contains("GPU compositing is off: no reason was recorded"), "{off}");
|
||||
assert!(off.contains("Window graphics: not known yet"), "{off}");
|
||||
|
||||
s.accel_note = Some("GPU compositor unavailable: no compute shaders".into());
|
||||
s.window_adapter = Some("NVIDIA GeForce RTX 4070 (Dx12, DiscreteGpu)".into());
|
||||
let declined = gpu_summary(&s);
|
||||
assert!(declined.contains("GPU compositing is off: GPU compositor unavailable: no compute shaders"), "{declined}");
|
||||
assert!(declined.contains("Window graphics: NVIDIA GeForce RTX 4070 (Dx12, DiscreteGpu)"), "the card the window got is listed: {declined}");
|
||||
|
||||
s.accel = Some(std::sync::Arc::new(Named));
|
||||
let on = gpu_summary(&s);
|
||||
assert!(on.starts_with("Compositing: GPU, NVIDIA GeForce RTX 4070 (Vulkan)."), "{on}");
|
||||
assert!(!on.contains("compositing is off"), "{on}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -270,18 +270,26 @@ fn rq() -> (Session, Arc<MockExporter>) {
|
|||
(s, ex)
|
||||
}
|
||||
|
||||
/// An output path that is absolute on every platform. `/tmp/...` has no drive letter on Windows,
|
||||
/// where it means "the current drive" to the OS but resolves against the home folder's drive in
|
||||
/// the render queue, so a test cannot know which drive to expect.
|
||||
pub(crate) fn tmp_out(rel: &str) -> String {
|
||||
let base = std::env::temp_dir().join("effectcraft-rq-tests");
|
||||
rel.split('/').fold(base, |p, part| p.join(part)).to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_output_module_encodes_twice() {
|
||||
let (mut s, ex) = rq();
|
||||
assert!(!s.is_enabled("render.addOutputModule"));
|
||||
s.execute("renderQueue.add", json!({"output": "/tmp/om/main.mp4"})).unwrap();
|
||||
s.execute("renderQueue.add", json!({"output": tmp_out("om/main.mp4")})).unwrap();
|
||||
let r = s.execute("render.addOutputModule", json!({"format": "png"})).unwrap();
|
||||
assert_eq!(r["module"], 2);
|
||||
assert_eq!(r["outputModules"], 2);
|
||||
let extra = r["extraOutputs"][0]["outputPath"].as_str().unwrap().to_string();
|
||||
assert!(extra.ends_with(".png"), "{extra}");
|
||||
// Module 2 is editable on its own.
|
||||
s.execute("renderQueue.setOutput", json!({"index": 1, "module": 2, "path": "/tmp/om/second.mov"})).unwrap();
|
||||
s.execute("renderQueue.setOutput", json!({"index": 1, "module": 2, "path": tmp_out("om/second.mov")})).unwrap();
|
||||
assert!(s.execute("renderQueue.setOutput", json!({"index": 1, "module": 3, "path": "/tmp/x.mov"})).is_err());
|
||||
s.execute("renderQueue.render", json!({})).unwrap();
|
||||
assert_eq!(
|
||||
|
|
@ -294,10 +302,10 @@ fn add_output_module_encodes_twice() {
|
|||
(format.to_string(), std::path::PathBuf::from(path))
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
[("H264".to_string(), std::path::absolute("/tmp/om/main.mp4").unwrap()), ("ProRes".to_string(), std::path::absolute("/tmp/om/second.mov").unwrap())]
|
||||
[("H264".to_string(), std::path::PathBuf::from(tmp_out("om/main.mp4"))), ("ProRes".to_string(), std::path::PathBuf::from(tmp_out("om/second.mov")))]
|
||||
);
|
||||
assert_eq!(s.project.render_queue[0].status.label(), "Done");
|
||||
assert_eq!(std::path::Path::new(s.project.render_queue[0].last_output.as_deref().unwrap()), std::path::absolute("/tmp/om/main.mp4").unwrap());
|
||||
assert_eq!(std::path::Path::new(s.project.render_queue[0].last_output.as_deref().unwrap()), std::path::PathBuf::from(tmp_out("om/main.mp4")));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.project.render_queue[0].extra_outputs.len(), 1, "undo the Output To edit");
|
||||
}
|
||||
|
|
@ -311,7 +319,7 @@ fn pre_render_imports_and_replaces() {
|
|||
let outer = s.active_comp_id().unwrap();
|
||||
s.execute("layer.addItem", json!({"item": inner.0})).unwrap();
|
||||
s.open_comp(inner);
|
||||
let r = s.execute("render.preRender", json!({"output": "/tmp/pre/inner.mov"})).unwrap();
|
||||
let r = s.execute("render.preRender", json!({"output": tmp_out("pre/inner.mov")})).unwrap();
|
||||
assert_eq!(r["postRenderAction"], "Import & Replace Usage");
|
||||
assert_eq!(s.project.render_queue[0].post_render, PostRenderAction::ImportAndReplace);
|
||||
s.execute("renderQueue.render", json!({})).unwrap();
|
||||
|
|
@ -319,7 +327,7 @@ fn pre_render_imports_and_replaces() {
|
|||
let l = &s.project.comp(outer).unwrap().layers[0];
|
||||
let LayerSource::Footage { item } = l.source else { panic!("not replaced: {:?}", l.source) };
|
||||
assert!(
|
||||
matches!(&s.project.item(item).unwrap().kind, effectcraft_project::ItemKind::Footage(f) if std::path::Path::new(&f.path) == std::path::absolute("/tmp/pre/inner.mov").unwrap())
|
||||
matches!(&s.project.item(item).unwrap().kind, effectcraft_project::ItemKind::Footage(f) if std::path::Path::new(&f.path) == std::path::PathBuf::from(tmp_out("pre/inner.mov")))
|
||||
);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(matches!(s.project.comp(outer).unwrap().layers[0].source, LayerSource::Comp { .. }));
|
||||
|
|
@ -331,23 +339,23 @@ fn post_render_actions_import_and_set_proxy() {
|
|||
let cid = s.active_comp_id().unwrap();
|
||||
let items_before = s.project.items.len();
|
||||
// Import: the rendered file becomes a footage item.
|
||||
let a = s.execute("renderQueue.add", json!({"output": "/tmp/post/a.mov"})).unwrap();
|
||||
let a = s.execute("renderQueue.add", json!({"output": tmp_out("post/a.mov")})).unwrap();
|
||||
s.execute("renderQueue.setOutputModule", json!({"item": a["item"], "postRenderAction": "import"})).unwrap();
|
||||
assert_eq!(s.project.render_queue[0].post_render, PostRenderAction::Import);
|
||||
s.execute("renderQueue.render", json!({})).unwrap();
|
||||
assert_eq!(s.project.items.len(), items_before + 1);
|
||||
assert!(s.project.items.values().any(
|
||||
|i| matches!(&i.kind, effectcraft_project::ItemKind::Footage(f) if std::path::Path::new(&f.path) == std::path::absolute("/tmp/post/a.mov").unwrap())
|
||||
|i| matches!(&i.kind, effectcraft_project::ItemKind::Footage(f) if std::path::Path::new(&f.path) == std::path::PathBuf::from(tmp_out("post/a.mov")))
|
||||
));
|
||||
// Set Proxy: the comp gets the render as its proxy.
|
||||
let b = s.execute("renderQueue.add", json!({"output": "/tmp/post/b.mov"})).unwrap();
|
||||
let b = s.execute("renderQueue.add", json!({"output": tmp_out("post/b.mov")})).unwrap();
|
||||
s.execute("renderQueue.setOutputModule", json!({"item": b["item"], "postRenderAction": "Set Proxy"})).unwrap();
|
||||
s.execute("renderQueue.render", json!({})).unwrap();
|
||||
let px = s.project.item(cid).unwrap().proxy.clone().expect("proxy set");
|
||||
assert_eq!(std::path::Path::new(&px.footage.path), std::path::absolute("/tmp/post/b.mov").unwrap());
|
||||
assert_eq!(std::path::Path::new(&px.footage.path), std::path::PathBuf::from(tmp_out("post/b.mov")));
|
||||
// None: nothing happens.
|
||||
let n = s.project.items.len();
|
||||
let c = s.execute("renderQueue.add", json!({"output": "/tmp/post/c.mov"})).unwrap();
|
||||
let c = s.execute("renderQueue.add", json!({"output": tmp_out("post/c.mov")})).unwrap();
|
||||
s.execute("renderQueue.setOutputModule", json!({"item": c["item"], "postRenderAction": "none"})).unwrap();
|
||||
s.execute("renderQueue.render", json!({})).unwrap();
|
||||
assert_eq!(s.project.items.len(), n);
|
||||
|
|
@ -426,9 +434,9 @@ fn save_frame_as_queues_with_the_frame_defaults() {
|
|||
#[test]
|
||||
fn log_notify_and_overflow_settings() {
|
||||
let (mut s, _) = rq();
|
||||
let a = s.execute("renderQueue.add", json!({"output": "/tmp/n/a.mp4", "log": "plusPerFrameInfo"})).unwrap();
|
||||
let a = s.execute("renderQueue.add", json!({"output": tmp_out("n/a.mp4"), "log": "plusPerFrameInfo"})).unwrap();
|
||||
assert_eq!(a["logLabel"], "Plus Per Frame Info");
|
||||
assert_eq!(std::path::Path::new(a["logPath"].as_str().unwrap()), std::path::absolute("/tmp/n/a_RenderLog.txt").unwrap());
|
||||
assert_eq!(std::path::Path::new(a["logPath"].as_str().unwrap()), std::path::PathBuf::from(tmp_out("n/a_RenderLog.txt")));
|
||||
s.execute("renderQueue.setLog", json!({"item": a["item"], "log": "plusSettings"})).unwrap();
|
||||
assert_eq!(s.project.render_queue[0].log, effectcraft_project::render_queue::RenderLog::PlusSettings);
|
||||
s.execute("renderQueue.setOverflowFolders", json!({"folders": ["/tmp/o1", " ", "/tmp/o2"]})).unwrap();
|
||||
|
|
|
|||
|
|
@ -420,3 +420,19 @@ fn camera_focus_commands_undo_and_serde() {
|
|||
assert!(s.execute("camera.linkFocusToLayer", json!({})).is_err());
|
||||
assert!(s.execute("camera.linkFocusToPoi", json!({})).is_ok());
|
||||
}
|
||||
|
||||
/// An id sent as a string (`"98"`, as MCP clients often do) resolves as an id when no name matches.
|
||||
#[test]
|
||||
fn numeric_string_resolves_as_id() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "T", "width": 320, "height": 180, "duration": 2.0, "frameRate": 30})).unwrap();
|
||||
let l = s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
assert_eq!(s.resolve_comp(Some(&json!(cid.0.to_string()))).unwrap(), cid);
|
||||
assert!(s.resolve_comp(Some(&json!("999999"))).is_err());
|
||||
let comp = s.project.comp(cid).unwrap();
|
||||
assert_eq!(crate::commands::resolve_layer(comp, &json!(l.to_string())), Some(LayerId(l)));
|
||||
assert_eq!(crate::commands::resolve_layer(comp, &json!("999999")), None);
|
||||
let r = s.execute("layer.tree", json!({"layer": l.to_string(), "comp": cid.0.to_string()})).unwrap();
|
||||
assert_eq!(r["id"].as_u64(), Some(l), "{r}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -260,6 +260,63 @@ fn fonts_lists_bundled_and_installed_families() {
|
|||
assert_eq!(crate::font_styles("No Such Family").len(), 5);
|
||||
}
|
||||
|
||||
/// A font file of just `name` (the family, Regular) and an `fvar` table with one axis pinned at
|
||||
/// 400 (so the helpers that look for an installed font with a real axis range never pick it).
|
||||
fn pinned_variable_font(family: &str) -> Vec<u8> {
|
||||
let u16s = |v: &[u16]| -> Vec<u8> { v.iter().flat_map(|x| x.to_be_bytes()).collect() };
|
||||
let strings: Vec<Vec<u8>> = [family, "Regular"].iter().map(|s| s.encode_utf16().flat_map(|u| u.to_be_bytes()).collect()).collect();
|
||||
let mut name = u16s(&[0, 2, 30]);
|
||||
let mut off = 0;
|
||||
for (id, st) in strings.iter().enumerate() {
|
||||
name.extend(u16s(&[3, 1, 0x409, id as u16 + 1, st.len() as u16, off]));
|
||||
off += st.len() as u16;
|
||||
}
|
||||
strings.iter().for_each(|st| name.extend(st));
|
||||
let mut fvar = u16s(&[1, 0, 16, 2, 1, 20, 0, 8]);
|
||||
fvar.extend(b"wght".iter().copied().chain(u16s(&[0x190, 0, 0x190, 0, 0x190, 0, 0, 256])));
|
||||
let mut out = u16s(&[1, 0, 2, 0, 0, 0]);
|
||||
let mut body: Vec<u8> = vec![];
|
||||
for (tag, data) in [(b"fvar", fvar), (b"name", name)] {
|
||||
out.extend(tag);
|
||||
out.extend([0u8; 4]);
|
||||
out.extend(((12 + 32 + body.len()) as u32).to_be_bytes());
|
||||
out.extend((data.len() as u32).to_be_bytes());
|
||||
body.extend(data);
|
||||
}
|
||||
out.extend(body);
|
||||
out
|
||||
}
|
||||
|
||||
/// The font menu's Variable Fonts filter (After Effects 26.3): `text.fonts {variable: true}`
|
||||
/// lists only families with variation axes, every listing says which families are variable, and
|
||||
/// the font menu's rows carry the flag the filter uses.
|
||||
#[test]
|
||||
fn fonts_filter_variable_families() {
|
||||
const FAMILY: &str = "EffectCraft Variable Filter Test";
|
||||
assert_eq!(effectcraft_text::fonts::add_font_data(pinned_variable_font(FAMILY)).len(), 1);
|
||||
let mut s = Session::default();
|
||||
let all = s.execute("text.fonts", json!({})).unwrap();
|
||||
let fams = all["families"].as_array().unwrap();
|
||||
let flag = |name: &str| fams.iter().find(|f| f["family"] == name).map(|f| f["variable"].clone());
|
||||
assert_eq!(flag("Inter"), Some(json!(false)), "the bundled fonts are static");
|
||||
assert_eq!(flag(FAMILY), Some(json!(true)));
|
||||
let var = s.execute("text.fonts", json!({"variable": true})).unwrap();
|
||||
let listed = var["families"].as_array().unwrap();
|
||||
assert_eq!(var["count"].as_u64(), Some(listed.len() as u64));
|
||||
assert!(listed.iter().all(|f| f["variable"] == true), "{var}");
|
||||
assert!(listed.iter().any(|f| f["family"] == FAMILY), "{var}");
|
||||
assert!(listed.len() < fams.len(), "the filter drops the static families");
|
||||
// Combined with the name query.
|
||||
let both = s.execute("text.fonts", json!({"variable": true, "query": "variable filter"})).unwrap();
|
||||
assert_eq!(both["families"].as_array().map(Vec::len), Some(1), "{both}");
|
||||
let none = s.execute("text.fonts", json!({"variable": true, "query": "jetbrains mono"})).unwrap();
|
||||
assert!(!none["families"].as_array().unwrap().iter().any(|f| f["family"] == "JetBrains Mono"), "{none}");
|
||||
// The font menu's rows.
|
||||
let menu = crate::font_menu(&s.prefs);
|
||||
assert!(menu.iter().any(|r| r.family == FAMILY && r.variable));
|
||||
assert!(menu.iter().any(|r| r.family == "Inter" && !r.variable));
|
||||
}
|
||||
|
||||
/// `layer.setText`'s schema lists every OpenType key as optional, so checked execution (control
|
||||
/// channel, MCP) accepts them and requires none (#520).
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -414,6 +414,25 @@ fn time_set_snaps_to_the_nearest_frame_and_ae_timecode() {
|
|||
assert_eq!(effectcraft_time::format_timecode_ae(75, fr, true), "0;00;02;15");
|
||||
}
|
||||
|
||||
/// #890: at 29.97 / 59.94 fps the displayed (non-drop) timecode was read back as drop-frame, so
|
||||
/// committing the current-time field moved the playhead to another frame from minute 1 on.
|
||||
#[test]
|
||||
fn time_set_round_trips_the_displayed_timecode_at_drop_frame_rates() {
|
||||
for fps in [29.97, 59.94] {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "T", "width": 64, "height": 64, "frameRate": fps, "duration": 4000})).unwrap();
|
||||
for frame in [0_i64, 1799, 1800, 1801, 3600, 17982, 17983, 100_000] {
|
||||
let r = s.execute("time.set", json!({"frame": frame})).unwrap();
|
||||
let display = r["display"].as_str().unwrap().to_string();
|
||||
let r = s.execute("time.set", json!({"timecode": display})).unwrap();
|
||||
assert_eq!(r["frame"], frame, "{fps} fps: displayed {display}");
|
||||
}
|
||||
// Explicit drop-frame fields still mean drop-frame.
|
||||
let r = s.execute("time.set", json!({"timecode": "0;01;00;02"})).unwrap();
|
||||
assert_eq!(r["frame"], if fps > 30.0 { 3598 } else { 1800 });
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_set_keeps_the_frame_shown_in_the_current_time_field() {
|
||||
let mut s = Session::default();
|
||||
|
|
@ -712,6 +731,89 @@ fn deleting_time_remap_turns_time_remapping_off() {
|
|||
assert_eq!(prop(&s, l, "timeRemap").keys.len(), 1);
|
||||
}
|
||||
|
||||
/// U (keyframes), the animation reveal and UU (modified) take in Time Remap, which sits at the
|
||||
/// top of the layer's tree rather than in a group.
|
||||
#[test]
|
||||
fn reveal_shortcuts_take_in_time_remap() {
|
||||
let (mut s, l) = precomp_setup();
|
||||
let revealed = |s: &mut Session, kind: &str| -> Vec<u64> {
|
||||
let r = s.execute("anim.reveal", json!({"kind": kind})).unwrap();
|
||||
r["props"].as_array().unwrap().iter().filter(|p| p["layer"] == l).filter_map(|p| p["prop"].as_u64()).collect()
|
||||
};
|
||||
for kind in ["keyframes", "animation", "modified"] {
|
||||
assert!(revealed(&mut s, kind).is_empty(), "{kind}: nothing to reveal before remapping");
|
||||
}
|
||||
s.execute("layer.enableTimeRemap", json!({})).unwrap();
|
||||
let uid = prop(&s, l, "timeRemap").uid;
|
||||
for kind in ["keyframes", "animation", "modified"] {
|
||||
assert_eq!(revealed(&mut s, kind), vec![uid], "{kind}");
|
||||
}
|
||||
}
|
||||
|
||||
/// UU on a layer nothing was changed on shows nothing, whatever its kind: cameras, lights, text
|
||||
/// and nulls have their own Transform defaults, and Separate Dimensions keeps the comp centre.
|
||||
#[test]
|
||||
fn modified_reveal_is_empty_for_untouched_layers() {
|
||||
let (mut s, solid) = setup();
|
||||
let mut layers = vec![("solid", solid)];
|
||||
for (cmd, params) in [
|
||||
("layer.newText", json!({"text": "Hi"})),
|
||||
("layer.newShape", json!({})),
|
||||
("layer.newNull", json!({})),
|
||||
("layer.newCamera", json!({})),
|
||||
("layer.newLight", json!({})),
|
||||
("layer.newLight", json!({"type": "ambient"})),
|
||||
] {
|
||||
let r = s.execute(cmd, params.clone()).unwrap_or_else(|e| panic!("{cmd} {params}: {e}"));
|
||||
layers.push((cmd, r["layer"].as_u64().unwrap()));
|
||||
}
|
||||
s.execute("prop.separateDimensions", json!({"layer": solid})).unwrap();
|
||||
for (what, l) in layers {
|
||||
s.execute("layer.select", json!({"layers": [l]})).unwrap();
|
||||
let r = s.execute("anim.reveal", json!({"kind": "modified"})).unwrap();
|
||||
let paths: Vec<String> = r["props"].as_array().unwrap().iter().filter_map(|p| p["prop"].as_u64()).map(|u| path_of(&s, l, u)).collect();
|
||||
assert!(paths.is_empty(), "{what}: {paths:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Match-id path of property or group `uid` of `layer`.
|
||||
fn path_of(s: &Session, layer: u64, uid: u64) -> String {
|
||||
let l = s.active_comp().unwrap().layer(effectcraft_project::LayerId(layer)).unwrap();
|
||||
let mut out = String::new();
|
||||
for n in l.props.node_chain(uid).unwrap_or_default() {
|
||||
out.push('/');
|
||||
out.push_str(n.match_id());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// UU takes in static changes outside Transform: Material, Camera and Light Options, a
|
||||
/// separated position, and layer styles in full.
|
||||
#[test]
|
||||
fn modified_reveal_takes_in_options_and_layer_styles() {
|
||||
let (mut s, solid) = setup();
|
||||
let modified = |s: &mut Session, l: u64| -> Vec<String> {
|
||||
s.execute("layer.select", json!({"layers": [l]})).unwrap();
|
||||
let r = s.execute("anim.reveal", json!({"kind": "modified"})).unwrap();
|
||||
r["props"].as_array().unwrap().iter().filter_map(|p| p["prop"].as_u64()).map(|u| path_of(s, l, u)).collect()
|
||||
};
|
||||
s.execute("prop.set", json!({"layer": solid, "path": "materialOptions/diffuse", "value": 80})).unwrap();
|
||||
assert_eq!(modified(&mut s, solid), ["/materialOptions/diffuse"]);
|
||||
s.execute("prop.separateDimensions", json!({"layer": solid})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": solid, "path": "transform/positionX", "value": 10})).unwrap();
|
||||
assert_eq!(modified(&mut s, solid), ["/transform/positionX", "/materialOptions/diffuse"]);
|
||||
s.execute("layer.style.dropShadow", json!({"layers": [solid]})).unwrap();
|
||||
assert!(modified(&mut s, solid).contains(&"/layerStyles/dropShadow".to_string()), "{:?}", modified(&mut s, solid));
|
||||
|
||||
let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("prop.set", json!({"layer": cam, "path": "cameraOptions/blurLevel", "value": 50})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": cam, "path": "transform/rotation", "value": 15})).unwrap();
|
||||
assert_eq!(modified(&mut s, cam), ["/transform/rotation", "/cameraOptions/blurLevel"]);
|
||||
let light = s.execute("layer.newLight", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("prop.set", json!({"layer": light, "path": "lightOptions/intensity", "value": 40})).unwrap();
|
||||
assert_eq!(modified(&mut s, light), ["/lightOptions/intensity"]);
|
||||
}
|
||||
|
||||
/// Enable Time Remapping on a layer extended past its source's end (its last frame frozen) puts
|
||||
/// the end key at the source's end, not at the extended out point (#290).
|
||||
#[test]
|
||||
|
|
@ -883,3 +985,138 @@ fn property_edits_apply_to_every_selected_layer() {
|
|||
s.execute("prop.set", json!({"layer": a, "path": "transform/rotation", "value": 30})).unwrap();
|
||||
assert_eq!(prop(&s, b, "transform/rotation").value.as_f64(), 0.0);
|
||||
}
|
||||
|
||||
/// #770: Proportional Scrubbing (After Effects 26.2, Ctrl+Alt / Cmd+Opt scrub). The change is
|
||||
/// spread over the selected layers by selection order (not stacking order): the first keeps its
|
||||
/// value, the last takes all of it, the one between half; whichever layer is scrubbed. One undo
|
||||
/// step; a keyframed property gets a key at the current time, the unchanged first layer none.
|
||||
#[test]
|
||||
fn proportional_scrub_spreads_the_change_by_selection_order() {
|
||||
let (mut s, a) = setup();
|
||||
let b = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap();
|
||||
let c = s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("prop.set", json!({"layer": b, "path": "transform/rotation", "value": 20})).unwrap();
|
||||
// Selection order c, a, b.
|
||||
s.execute("layer.select", json!({"layers": [c]})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [a], "add": true})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [b], "toggle": true})).unwrap();
|
||||
let rot = |s: &Session, l: u64| prop(s, l, "transform/rotation").value.as_f64();
|
||||
let uid = prop(&s, a, "transform/rotation").uid;
|
||||
|
||||
// Scrub layer a (the middle one) by +10 a frame, three frames: the UI sends a's shown value
|
||||
// plus the drag.
|
||||
let undo = s.history.undo.len();
|
||||
for _ in 0..3 {
|
||||
let v = rot(&s, a) + 10.0;
|
||||
s.execute("prop.set", json!({"layer": a, "prop": uid, "value": v, "selected": true, "offset": true, "proportional": true, "merge": "scrub-r"}))
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(rot(&s, c), 0.0, "the first selected layer keeps its value");
|
||||
assert_eq!(rot(&s, a), 15.0, "the middle one moves by half");
|
||||
assert_eq!(rot(&s, b), 50.0, "the last one moves by the whole change");
|
||||
assert_eq!(s.history.undo.len(), undo + 1, "one undo step for the scrub");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!((rot(&s, c), rot(&s, a), rot(&s, b)), (0.0, 0.0, 20.0));
|
||||
|
||||
// Vectors and keyframes: Position animated on every layer, scrubbed on the last layer at 1 s.
|
||||
for l in [a, b, c] {
|
||||
s.execute("prop.set", json!({"layer": l, "path": "transform/position", "value": [100, 100], "time": 0.0})).unwrap();
|
||||
s.execute("prop.toggleAnimation", json!({"layer": l, "path": "transform/position"})).unwrap();
|
||||
}
|
||||
s.execute("time.set", json!({"time": 1.0})).unwrap();
|
||||
let pos_uid = prop(&s, b, "transform/position").uid;
|
||||
s.execute("prop.set", json!({"layer": b, "prop": pos_uid, "value": [140, 80], "selected": true, "offset": true, "proportional": true})).unwrap();
|
||||
let at1 = |s: &Session, l: u64| {
|
||||
let p = prop(s, l, "transform/position");
|
||||
(p.keys.len(), p.value_at(effectcraft_time::Tick::from_seconds_f64(1.0)).components()[..2].to_vec())
|
||||
};
|
||||
assert_eq!(at1(&s, c), (1, vec![100.0, 100.0]), "no key on the first selected layer");
|
||||
assert_eq!(at1(&s, a), (2, vec![120.0, 90.0]));
|
||||
assert_eq!(at1(&s, b), (2, vec![140.0, 80.0]));
|
||||
|
||||
// Without `proportional` a scrub still moves them all by the same change.
|
||||
s.execute("prop.set", json!({"layer": a, "prop": uid, "value": 5, "selected": true, "offset": true})).unwrap();
|
||||
assert_eq!((rot(&s, c), rot(&s, a), rot(&s, b)), (5.0, 5.0, 25.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proportional_share_is_linear_in_selection_order() {
|
||||
use crate::commands::prop::proportional_share;
|
||||
assert_eq!((0..5).map(|i| proportional_share(i, 5)).collect::<Vec<_>>(), [0.0, 0.25, 0.5, 0.75, 1.0]);
|
||||
// One layer (or a bad count) takes the whole change; an index past the end the last share.
|
||||
assert_eq!(proportional_share(0, 1), 1.0);
|
||||
assert_eq!(proportional_share(0, 0), 1.0);
|
||||
assert_eq!(proportional_share(usize::MAX, 3), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_item_keeps_footage_longer_than_the_comp_at_full_length() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "T", "width": 400, "height": 300, "frameRate": 30, "duration": 4})).unwrap();
|
||||
let f = effectcraft_project::Footage {
|
||||
width: 100,
|
||||
height: 100,
|
||||
kind: effectcraft_project::FootageKind::Video,
|
||||
has_video: true,
|
||||
duration: effectcraft_time::Tick::from_seconds_f64(10.0),
|
||||
..Default::default()
|
||||
};
|
||||
let item = std::sync::Arc::make_mut(&mut s.project).add_item("long.mp4", effectcraft_color::Label::None, None, effectcraft_project::ItemKind::Footage(f));
|
||||
let l = s.execute("layer.addItem", json!({"item": item.0})).unwrap()["layer"].as_u64().unwrap();
|
||||
let layer = s.active_comp().unwrap().layer(effectcraft_project::LayerId(l)).unwrap();
|
||||
assert_eq!(layer.out_point.seconds(), 10.0);
|
||||
}
|
||||
|
||||
/// View > Go to Time's dialog sends its Frame field as a float; `time.set` used to reject it
|
||||
/// with "need `time`, `frame` or `timecode`".
|
||||
#[test]
|
||||
fn go_to_time_accepts_a_float_frame_from_the_dialog() {
|
||||
let (mut s, _) = setup();
|
||||
let r = s.execute("time.set", json!({"frame": 12.0})).unwrap();
|
||||
assert_eq!(r["frame"], json!(12));
|
||||
let r = s.execute("time.set", json!({"frame": 7})).unwrap();
|
||||
assert_eq!(r["frame"], json!(7));
|
||||
}
|
||||
|
||||
/// Dragging a trim edge with several layers selected trims every selected layer by the same
|
||||
/// amount from its own in/out point (as in AE), clamped to the comp and to one frame of layer;
|
||||
/// a whole merged drag is one undo step.
|
||||
#[test]
|
||||
fn relative_trim_moves_every_selected_layer_by_the_same_delta() {
|
||||
let (mut s, a) = setup();
|
||||
let b = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.timing", json!({"layers": [a], "in": 0.5, "out": 2.0})).unwrap();
|
||||
s.execute("layer.timing", json!({"layers": [b], "in": 1.0, "out": 3.5})).unwrap();
|
||||
let io = |s: &Session, l: u64| {
|
||||
let l = s.active_comp().unwrap().layer(effectcraft_project::LayerId(l)).unwrap();
|
||||
(l.in_point.seconds(), l.out_point.seconds())
|
||||
};
|
||||
let near = |x: f64, y: f64| (x - y).abs() < 1e-9;
|
||||
let undo_len = s.history.undo.len();
|
||||
// In-point drag in frame steps, merged into one edit.
|
||||
for _ in 0..3 {
|
||||
s.execute("layer.timing", json!({"layers": [a, b], "inDelta": 0.1, "merge": "bar-trim"})).unwrap();
|
||||
}
|
||||
assert_eq!(s.history.undo.len(), undo_len + 1);
|
||||
let ((ai, ao), (bi, bo)) = (io(&s, a), io(&s, b));
|
||||
assert!(near(ai, 0.8) && near(ao, 2.0) && near(bi, 1.3) && near(bo, 3.5), "{ai} {ao} {bi} {bo}");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(near(io(&s, a).0, 0.5) && near(io(&s, b).0, 1.0));
|
||||
s.history.merge_key = None;
|
||||
// Out-point drag: b hits the comp end (4 s) while a keeps going.
|
||||
s.execute("layer.timing", json!({"layers": [a, b], "outDelta": 1.0})).unwrap();
|
||||
assert!(near(io(&s, a).1, 3.0) && near(io(&s, b).1, 4.0), "{:?} {:?}", io(&s, a), io(&s, b));
|
||||
// In-point drag left: a stops at the comp start.
|
||||
s.execute("layer.timing", json!({"layers": [a, b], "inDelta": -0.8})).unwrap();
|
||||
assert!(near(io(&s, a).0, 0.0) && near(io(&s, b).0, 0.2), "{:?} {:?}", io(&s, a), io(&s, b));
|
||||
// Trimming past the other edge keeps one frame of layer.
|
||||
s.execute("layer.timing", json!({"layers": [a, b], "inDelta": 10.0})).unwrap();
|
||||
let fd = 1.0 / 30.0;
|
||||
assert!(near(io(&s, a).0, 3.0 - fd) && near(io(&s, b).0, 4.0 - fd), "{:?} {:?}", io(&s, a), io(&s, b));
|
||||
s.execute("layer.timing", json!({"layers": [a, b], "outDelta": -10.0})).unwrap();
|
||||
assert!(near(io(&s, a).1, 3.0) && near(io(&s, b).1, 4.0), "{:?} {:?}", io(&s, a), io(&s, b));
|
||||
// A point already outside the comp trims from where it is instead of jumping onto the edge.
|
||||
s.execute("layer.timing", json!({"layers": [a], "in": -1.0})).unwrap();
|
||||
s.execute("layer.timing", json!({"layers": [a], "inDelta": 0.1})).unwrap();
|
||||
assert!(near(io(&s, a).0, -0.9), "{:?}", io(&s, a));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -235,3 +235,51 @@ fn create_masks_from_text() {
|
|||
assert!(s.execute_checked("layer.create", json!({"op": "shapesFromVector", "layers": [lid]})).is_err());
|
||||
assert!(s.execute_checked("layer.create", json!({"op": "nullTracing", "layers": [lid]})).is_err());
|
||||
}
|
||||
|
||||
/// Edit ▸ Paste of SVG markup (After Effects 26.3): a shape layer with the SVG's shapes, its
|
||||
/// canvas centred in the comp, above the selected layer, one undo step.
|
||||
#[test]
|
||||
fn paste_svg_markup_as_a_shape_layer() {
|
||||
let (mut s, lid) = text_session();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
s.execute("layer.select", json!({"layers": [lid]})).unwrap();
|
||||
let svg = r##"<?xml version="1.0"?>
|
||||
<!-- copied from a design tool -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20" viewBox="0 0 40 20">
|
||||
<rect x="0" y="0" width="20" height="20" fill="#ff0000"/>
|
||||
<rect x="20" y="0" width="20" height="20" fill="#0000ff"/>
|
||||
</svg>"##;
|
||||
let r = s.execute_checked("edit.pasteSvg", json!({"svg": svg})).unwrap();
|
||||
let sid = r["layer"].as_u64().unwrap();
|
||||
let c = s.active_comp().unwrap();
|
||||
assert_eq!(c.layers.len(), 2);
|
||||
assert_eq!(c.layers[0].id.0, sid, "above the selected layer");
|
||||
assert!(matches!(c.layers[0].source, LayerSource::Shape));
|
||||
assert_eq!(c.layers[0].name, "Shape Layer 1");
|
||||
assert_eq!(s.state.selected_layers, [effectcraft_project::LayerId(sid)]);
|
||||
assert_eq!(c.layers[0].props.sub("contents").unwrap().groups().count(), 2);
|
||||
// Hide the text so only the pasted shapes draw: the 40×20 canvas is centred in the
|
||||
// 320×120 comp (x 140–180, y 50–70), red on the left, blue on the right.
|
||||
s.execute("layer.setSwitch", json!({"layers": [lid], "switch": "video", "value": false})).unwrap();
|
||||
let img = s.render(cid, s.time(), RenderOpts::default());
|
||||
let px = |x: usize, y: usize| img.data[y * img.width as usize + x];
|
||||
let red = px(150, 60);
|
||||
assert!(red[3] > 0.99 && red[0] > 0.99 && red[2] < 0.01, "{red:?}");
|
||||
let blue = px(170, 60);
|
||||
assert!(blue[3] > 0.99 && blue[2] > 0.99 && blue[0] < 0.01, "{blue:?}");
|
||||
assert!(px(130, 60)[3] < 0.01 && px(160, 45)[3] < 0.01, "nothing outside the canvas");
|
||||
// One undo step removes it.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.active_comp().unwrap().layers.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_svg_rejects_text_that_is_not_usable_svg() {
|
||||
let (mut s, _) = text_session();
|
||||
for bad in ["hello", "<svg", "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"10\" height=\"10\"></svg>", "<html><svg></html>"] {
|
||||
assert!(s.execute_checked("edit.pasteSvg", json!({"svg": bad})).is_err(), "{bad}");
|
||||
}
|
||||
assert!(s.execute_checked("edit.pasteSvg", json!({})).is_err());
|
||||
assert_eq!(s.active_comp().unwrap().layers.len(), 1, "nothing pasted");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -574,3 +574,66 @@ fn graph_transform_box_scales_key_times_and_values() {
|
|||
assert_eq!(p.keys[1].value.as_f64(), 50.0);
|
||||
assert!(s.execute("keys.transform", json!({"timeScale": 0})).is_err());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- resolution
|
||||
|
||||
#[test]
|
||||
fn each_comp_keeps_its_resolution_outside_the_undo_history_and_in_the_file() {
|
||||
use effectcraft_project::Resolution;
|
||||
let mut s = comp();
|
||||
let a = s.active_comp_id().unwrap();
|
||||
s.execute("layer.newSolid", json!({"name": "S", "color": "#ff0000", "width": 10, "height": 10})).unwrap();
|
||||
let path = std::env::temp_dir().join(format!("ec-resolution-{}.ecproj", std::process::id()));
|
||||
s.execute("file.saveAs", json!({"path": path.to_string_lossy()})).unwrap();
|
||||
assert_eq!(s.active_comp().unwrap().resolution, Resolution::Auto);
|
||||
let (steps, pixels) = (s.history.undo.len(), s.active_comp_arc().unwrap());
|
||||
let r = s.execute_checked("view.res.half", json!({})).unwrap();
|
||||
assert_eq!((r["resolution"].clone(), r["factor"].clone()), (json!("Half"), json!(2)));
|
||||
// No undo step, but the project is modified (it's saved with it).
|
||||
assert_eq!(s.history.undo.len(), steps);
|
||||
assert!(s.is_dirty());
|
||||
assert!(s.active_comp().unwrap().same_pixels(&pixels));
|
||||
// Another comp has its own; switching back shows comp A's again.
|
||||
s.execute("comp.new", json!({"name": "B", "width": 320, "height": 180, "frameRate": 30, "duration": 5})).unwrap();
|
||||
let b = s.active_comp_id().unwrap();
|
||||
assert_ne!(a, b);
|
||||
assert_eq!(s.active_comp().unwrap().resolution, Resolution::Auto);
|
||||
s.execute_checked("view.res.custom", json!({"factor": 6})).unwrap();
|
||||
s.open_comp(a);
|
||||
assert_eq!(s.active_comp().unwrap().resolution, Resolution::Half);
|
||||
assert_eq!(s.execute("comp.info", json!({})).unwrap()["resolution"], json!("Half"));
|
||||
assert_eq!(crate::menus::checked(&s, "view.res.half", &json!({})), Some(true));
|
||||
assert_eq!(crate::menus::checked(&s, "view.res.full", &json!({})), Some(false));
|
||||
// Undo and redo restore the comps, not their resolutions.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(s.project.comp(b).is_none(), "undid New Composition");
|
||||
assert_eq!(s.project.comp(a).unwrap().resolution, Resolution::Half);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(s.project.comp(b).unwrap().resolution, Resolution::Custom(6));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(s.active_comp().unwrap().layers.is_empty(), "undid the solid");
|
||||
assert_eq!(s.project.comp(a).unwrap().resolution, Resolution::Half);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
// The comp's explicit `comp` parameter; the same value again changes nothing.
|
||||
s.execute_checked("view.res.quarter", json!({"comp": b.0})).unwrap();
|
||||
let rev = s.revision;
|
||||
s.execute_checked("view.res.quarter", json!({"comp": b.0})).unwrap();
|
||||
assert_eq!(s.revision, rev);
|
||||
// Saved with the project and read back.
|
||||
s.execute("file.save", json!({})).unwrap();
|
||||
assert!(!s.is_dirty());
|
||||
let mut o = Session::default();
|
||||
o.execute("file.open", json!({"path": path.to_string_lossy()})).unwrap();
|
||||
assert_eq!(o.project.comp(a).unwrap().resolution, Resolution::Half);
|
||||
assert_eq!(o.project.comp(b).unwrap().resolution, Resolution::Quarter);
|
||||
let _ = std::fs::remove_file(path);
|
||||
// Custom without a factor asks the frontend (the Custom Resolution dialog).
|
||||
s.drain_events();
|
||||
s.execute_checked("view.res.custom", json!({})).unwrap();
|
||||
assert!(s.drain_events().iter().any(|e| matches!(e, crate::Event::Frontend { command, .. } if command == "view.res.custom")));
|
||||
assert!(s.execute_checked("view.res.custom", json!({"factor": "big"})).is_err());
|
||||
s.execute_checked("view.res.auto", json!({})).unwrap();
|
||||
assert_eq!(s.active_comp().unwrap().resolution, Resolution::Auto);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -250,6 +250,12 @@ pub fn vector_clip_masks(doc: &Doc) -> (Vec<BezPath>, Doc) {
|
|||
(masks, out)
|
||||
}
|
||||
|
||||
/// Whether text (the system clipboard's, say) is SVG markup: Edit ▸ Paste makes it a shape
|
||||
/// layer (`edit.pasteSvg`).
|
||||
pub fn is_svg_text(text: &str) -> bool {
|
||||
effectcraft_svg::looks_like_svg(text.as_bytes())
|
||||
}
|
||||
|
||||
/// Whether footage is a vector file (SVG / PDF / AI / EPS).
|
||||
pub fn is_vector(f: &effectcraft_project::Footage) -> bool {
|
||||
effectcraft_render::is_vector_footage(f) || f.path.to_ascii_lowercase().ends_with(".svg")
|
||||
|
|
@ -471,6 +477,32 @@ pub fn image_layer(proj: &mut Project, comp: &Comp, item: ItemId, img: &VectorIm
|
|||
/// are kept with Merge Paths (see [`svg_contents`]).
|
||||
pub fn shapes_from_vector(proj: &mut Project, comp: &Comp, src: &Layer, doc: &Doc) -> Layer {
|
||||
let mut l = build::layer(proj, comp, &format!("{} Outlines", src.name), LayerSource::Shape, (0, 0), None);
|
||||
add_doc_shapes(proj, &mut l, doc);
|
||||
copy_transform(proj, src, &mut l);
|
||||
like_source(&mut l, src);
|
||||
l.name = comp.unique_layer_name(&format!("{} Outlines", src.name));
|
||||
l
|
||||
}
|
||||
|
||||
/// Edit ▸ Paste of SVG markup (After Effects 26.3 pastes copied vector artwork as shape layers):
|
||||
/// a shape layer named `name` with the document's shapes, its Anchor Point at the centre of the
|
||||
/// SVG canvas and its Position at the comp centre, so the canvas lands centred in the comp.
|
||||
pub fn shapes_from_svg(proj: &mut Project, comp: &Comp, doc: &Doc, name: &str) -> Layer {
|
||||
let mut l = build::layer(proj, comp, name, LayerSource::Shape, (0, 0), None);
|
||||
add_doc_shapes(proj, &mut l, doc);
|
||||
let (w, h) = (doc.width, doc.height);
|
||||
if w.is_finite()
|
||||
&& h.is_finite()
|
||||
&& let Some(a) = l.props.sub_mut("transform").and_then(|t| t.get_mut("anchor"))
|
||||
{
|
||||
a.value = Value::Vec3([w / 2.0, h / 2.0, 0.0]);
|
||||
}
|
||||
l
|
||||
}
|
||||
|
||||
/// The shapes of `doc` into shape layer `l`'s Contents; clips enclosing the whole document
|
||||
/// become its masks (Add, then Intersect).
|
||||
fn add_doc_shapes(proj: &mut Project, l: &mut Layer, doc: &Doc) {
|
||||
let (clips, doc) = vector_clip_masks(doc);
|
||||
let mut ids = Ids(&mut proj.next_id);
|
||||
let items = svg_contents(&mut ids, &doc);
|
||||
|
|
@ -488,10 +520,6 @@ pub fn shapes_from_vector(proj: &mut Project, comp: &Comp, src: &Layer, doc: &Do
|
|||
}
|
||||
}
|
||||
}
|
||||
copy_transform(proj, src, &mut l);
|
||||
like_source(&mut l, src);
|
||||
l.name = comp.unique_layer_name(&format!("{} Outlines", src.name));
|
||||
l
|
||||
}
|
||||
|
||||
/// Layer ▸ Create ▸ Create Shapes from Text: one group per character with its outline, fill and
|
||||
|
|
|
|||
|
|
@ -3,13 +3,15 @@
|
|||
//!
|
||||
//! Nothing here touches the project; edits go through commands.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
use effectcraft_color::{ColorSpace, space};
|
||||
use effectcraft_geom::{Mat3, vec2};
|
||||
use effectcraft_project::{Layer, LayerId};
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::{Layer, LayerId, Property};
|
||||
use effectcraft_raster::Image;
|
||||
use effectcraft_render::EvalCtx;
|
||||
use effectcraft_render::{EvalCtx, ExprHost};
|
||||
use effectcraft_time::Tick;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
|
|
@ -274,10 +276,45 @@ pub struct SnapOptions {
|
|||
pub grid_spacing: f64,
|
||||
}
|
||||
|
||||
type SnapValues = HashMap<(u64, u64, u64, i64), Result<Value, String>>;
|
||||
|
||||
/// Bounds, vertices and transforms can ask for the same expression repeatedly. Share its
|
||||
/// value only within this immutable snap query; a new pointer update starts a fresh cache.
|
||||
struct SnapExpressions<'a> {
|
||||
inner: &'a dyn ExprHost,
|
||||
values: Mutex<SnapValues>,
|
||||
}
|
||||
|
||||
impl ExprHost for SnapExpressions<'_> {
|
||||
fn eval(&self, ctx: &EvalCtx, layer: &Layer, prop: &Property, value: &Value) -> Result<Value, String> {
|
||||
let key = (ctx.comp_id.0, layer.id.0, prop.uid, ctx.time.0);
|
||||
if let Some(value) = self.values.lock().unwrap_or_else(PoisonError::into_inner).get(&key).cloned() {
|
||||
return value;
|
||||
}
|
||||
// Never hold the lock while evaluating: expressions may query other properties.
|
||||
let result = self.inner.eval(ctx, layer, prop, value);
|
||||
let mut values = self.values.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if values.len() < 8192 {
|
||||
values.insert(key, result.clone());
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn eval_text_selector(&self, ctx: &EvalCtx, layer: &Layer, prop: &Property, index: usize, total: usize, selector: [f64; 3]) -> Result<[f64; 3], String> {
|
||||
self.inner.eval_text_selector(ctx, layer, prop, index, total, selector)
|
||||
}
|
||||
}
|
||||
|
||||
/// Snap targets of a comp at the context time: other visible layers' features and vertices and
|
||||
/// the comp's edges and centre (with `layers`, as the Snapping options allow), and guides and
|
||||
/// grid lines (when on). Layers in `exclude` (the dragged ones) are skipped.
|
||||
pub fn targets(ctx: &EvalCtx, exclude: &[LayerId], opts: SnapOptions) -> Vec<SnapTarget> {
|
||||
let memo = ctx.expr.map(|inner| SnapExpressions { inner, values: Mutex::default() });
|
||||
let ctx = EvalCtx { expr: memo.as_ref().map(|h| h as &dyn ExprHost), ..*ctx };
|
||||
targets_in(&ctx, exclude, opts)
|
||||
}
|
||||
|
||||
fn targets_in(ctx: &EvalCtx, exclude: &[LayerId], opts: SnapOptions) -> Vec<SnapTarget> {
|
||||
let comp = ctx.comp;
|
||||
let (w, h) = (comp.width as f64, comp.height as f64);
|
||||
let f = opts.features;
|
||||
|
|
@ -1063,3 +1100,45 @@ pub struct Snapshot {
|
|||
pub scale: f64,
|
||||
pub image: Arc<Image>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod snap_expression_tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
struct PositionHost(AtomicUsize);
|
||||
impl ExprHost for PositionHost {
|
||||
fn eval(&self, ctx: &EvalCtx, _: &Layer, _: &Property, value: &Value) -> Result<Value, String> {
|
||||
self.0.fetch_add(1, Ordering::Relaxed);
|
||||
let v = value.components();
|
||||
Ok(Value::Vec3([v[0] + 10. * ctx.time.seconds(), v[1], 0.]))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapping_reuses_expressions_within_one_query_but_refreshes_time_and_edits() {
|
||||
let mut s = crate::Session::default();
|
||||
s.execute("comp.new", json!({"width":320,"height":180,"duration":5})).unwrap();
|
||||
let lid = s.execute("layer.newSolid", json!({"width":40,"height":20})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("prop.setExpression", json!({"layer":lid,"path":"transform/position","expression":"value"})).unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let host = PositionHost(AtomicUsize::new(0));
|
||||
let opts = SnapOptions { layers: true, ..Default::default() };
|
||||
let query = |s: &crate::Session, time, cached| {
|
||||
let ctx = EvalCtx { expr: Some(&host), ..EvalCtx::new(&s.project, cid, s.project.comp(cid).unwrap(), time) };
|
||||
if cached { targets(&ctx, &[], opts) } else { targets_in(&ctx, &[], opts) }
|
||||
};
|
||||
let expected = query(&s, Tick::ZERO, false);
|
||||
assert!(host.0.swap(0, Ordering::Relaxed) > 1, "fixture must visit the same expression for multiple geometry features");
|
||||
assert_eq!(query(&s, Tick::ZERO, true), expected);
|
||||
assert_eq!(host.0.swap(0, Ordering::Relaxed), 1);
|
||||
let moved = query(&s, Tick::from_seconds_f64(1.), true);
|
||||
assert_ne!(moved, expected, "a new time must not reuse stale targets");
|
||||
assert_eq!(host.0.swap(0, Ordering::Relaxed), 1);
|
||||
s.execute("prop.set", json!({"layer":lid,"path":"transform/position","value":[200,100,0]})).unwrap();
|
||||
let edited = query(&s, Tick::ZERO, true);
|
||||
assert_ne!(edited, expected, "a new project revision must not reuse stale targets");
|
||||
assert_eq!(host.0.swap(0, Ordering::Relaxed), 1);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,11 @@ use serde_json::{Map, Value};
|
|||
|
||||
const ROOT: &str = "EffectCraftProject";
|
||||
|
||||
/// How deep `arr`/`obj` elements may nest: the JSON reader's own limit (`serde_json` stops at
|
||||
/// 128), so every project the JSON form can hold reads from XML too. Without a limit a deeply
|
||||
/// nested file overflowed the stack and aborted the app instead of failing to open (#779).
|
||||
const MAX_DEPTH: usize = 128;
|
||||
|
||||
/// Whether `text` looks like an XML project.
|
||||
pub fn is_xml(text: &str) -> bool {
|
||||
let t = text.trim_start_matches('\u{feff}').trim_start();
|
||||
|
|
@ -158,7 +163,10 @@ impl<'a> P<'a> {
|
|||
self.i += end;
|
||||
t
|
||||
}
|
||||
fn value(&mut self) -> Result<Value, String> {
|
||||
fn value(&mut self, depth: usize) -> Result<Value, String> {
|
||||
if depth > MAX_DEPTH {
|
||||
return self.err(&format!("elements nested deeper than {MAX_DEPTH} levels"));
|
||||
}
|
||||
let (name, _, closed) = self.open()?;
|
||||
let v = match (name.as_str(), closed) {
|
||||
("null", _) => Value::Null,
|
||||
|
|
@ -175,7 +183,7 @@ impl<'a> P<'a> {
|
|||
if self.rest().starts_with("</") {
|
||||
break;
|
||||
}
|
||||
a.push(self.value()?);
|
||||
a.push(self.value(depth + 1)?);
|
||||
}
|
||||
Value::Array(a)
|
||||
}
|
||||
|
|
@ -190,7 +198,7 @@ impl<'a> P<'a> {
|
|||
if tag != "m" {
|
||||
return self.err("expected <m n=\"…\">");
|
||||
}
|
||||
let v = self.value()?;
|
||||
let v = self.value(depth + 1)?;
|
||||
self.close("m")?;
|
||||
m.insert(key.unwrap_or_default(), v);
|
||||
}
|
||||
|
|
@ -243,7 +251,7 @@ pub fn from_xml(text: &str) -> Result<String, String> {
|
|||
if root != ROOT {
|
||||
return Err(format!("not an EffectCraft XML project (root <{root}>)"));
|
||||
}
|
||||
let v = p.value()?;
|
||||
let v = p.value(0)?;
|
||||
p.close(ROOT)?;
|
||||
serde_json::to_string(&v).map_err(|e| e.to_string())
|
||||
}
|
||||
|
|
@ -262,4 +270,18 @@ mod tests {
|
|||
assert_eq!(back, j);
|
||||
assert!(from_xml("<Other/>").is_err());
|
||||
}
|
||||
|
||||
/// #779 (found by the project-file fuzzer): a file of deeply nested elements overflowed the
|
||||
/// stack, which aborts the app; it is now refused. Nesting the JSON reader takes still reads.
|
||||
#[test]
|
||||
fn deep_nesting_is_refused_not_a_stack_overflow() {
|
||||
let nested = |n: usize| format!("<{ROOT}>{}<null/>{}</{ROOT}>", "<arr>".repeat(n), "</arr>".repeat(n));
|
||||
let e = from_xml(&nested(1_000_000)).unwrap_err();
|
||||
assert!(e.contains("nested deeper"), "{e}");
|
||||
let ok = from_xml(&nested(100)).unwrap();
|
||||
assert!(serde_json::from_str::<Value>(&ok).is_ok());
|
||||
let json = format!("{}null{}", "[".repeat(MAX_DEPTH - 1), "]".repeat(MAX_DEPTH - 1));
|
||||
let v: Value = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Value>(&from_xml(&to_xml(&json).unwrap()).unwrap()).unwrap(), v);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "effectcraft-export"
|
||||
description = "Render Queue jobs and output modules: H.264 MP4 (+AAC) and ProRes MOV (+PCM) via FilmCraft, HEVC / AV1 MP4 (+AAC) and WebM (VP9 or AV1 + Opus) via EffectCraft encoders, PNG/JPEG/TIFF/EXR sequences, animated GIF, WAV/AIFF"
|
||||
description = "Render Queue jobs and output modules: H.264 MP4 (+AAC), ProRes and uncompressed MOV (+PCM) via FilmCraft, HEVC / AV1 MP4 (+AAC) and WebM (VP9 or AV1 + Opus) via EffectCraft encoders, PNG/JPEG/TIFF/EXR sequences, animated GIF, WAV/AIFF"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
//! Movie export: FilmCraft H.264, HEVC (`effectcraft-hevcenc`) and AV1 (`effectcraft-av1enc`)
|
||||
//! → MP4 (+ AAC) and ProRes → MOV (+ PCM), muxed by
|
||||
//! → MP4 (+ AAC), ProRes and uncompressed `raw ` → MOV (+ PCM), muxed by
|
||||
//! FilmCraft's ISO BMFF / QuickTime writer.
|
||||
|
||||
use std::io::Write;
|
||||
|
|
@ -253,16 +253,17 @@ pub(crate) async fn movie(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut St
|
|||
let rate = job.settings.rate(comp);
|
||||
let fmt = job.output.format;
|
||||
let channels = match job.output.channels {
|
||||
Channels::Rgba if fmt != OutputFormat::ProRes => Channels::Rgb,
|
||||
Channels::Rgba if !matches!(fmt, OutputFormat::ProRes | OutputFormat::Uncompressed) => Channels::Rgb,
|
||||
c => c,
|
||||
};
|
||||
let mut venc: Box<dyn VideoEncoder> = match fmt {
|
||||
OutputFormat::H264 => Box::new(H264::new(w, h, rate, job.output.bitrate_kbps)?),
|
||||
OutputFormat::Hevc => Box::new(crate::hevc_av1::Hevc::new(w, h, rate, job.output)?),
|
||||
OutputFormat::Av1 => Box::new(crate::hevc_av1::Av1::new(w, h, rate, job.output)?),
|
||||
OutputFormat::Uncompressed => Box::new(crate::uncompressed::Raw::new(w, h, channels == Channels::Rgba)?),
|
||||
_ => Box::new(ProRes::new(w, h, job.output.prores_profile, if channels == Channels::Alpha { Channels::Rgb } else { channels })),
|
||||
};
|
||||
let brand = if fmt == OutputFormat::ProRes { Brand::Mov } else { Brand::Mp4 };
|
||||
let brand = if matches!(fmt, OutputFormat::ProRes | OutputFormat::Uncompressed) { Brand::Mov } else { Brand::Mp4 };
|
||||
let batch = batch_size();
|
||||
let render = |_: u64, img: effectcraft_raster::Image| -> Vec<u8> { job.pixels(&img, comp, channels, w, h) };
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
//! |---|---|---|---|---|
|
||||
//! | H.264 | MP4 | FilmCraft H.264 High, 8-bit 4:2:0 BT.709 limited, VBR | no | AAC-LC (FilmCraft), mono/stereo |
|
||||
//! | ProRes | QuickTime MOV | FilmCraft ProRes 422 Proxy/LT/422/HQ, 10-bit; 4444/4444 XQ 4:4:4 | 4444 (16-bit alpha coding) | PCM 16/24-bit or 32-bit float, mono/stereo |
|
||||
//! | Uncompressed | QuickTime MOV (`raw `) | 8-bit RGB (depth 24, rows padded to even bytes) or ARGB (depth 32) | RGB + Alpha (straight or premultiplied) | PCM 16/24-bit or 32-bit float, mono/stereo |
|
||||
//! | PNG / JPEG / TIFF sequence | one file per frame | 8-bit | PNG, TIFF | — |
|
||||
//! | OpenEXR sequence | one file per frame | 32-bit float, linear light, premultiplied | yes | — |
|
||||
//! | Animated GIF | GIF89a | 256-colour palette per frame (NeuQuant) | 1-bit | — |
|
||||
|
|
@ -35,6 +36,7 @@ mod encode;
|
|||
mod hevc_av1;
|
||||
mod out;
|
||||
mod pipeline;
|
||||
mod uncompressed;
|
||||
mod webm;
|
||||
mod webm_av1;
|
||||
|
||||
|
|
@ -192,7 +194,9 @@ async fn export_with(cx: &Cx<'_>, progress: &mut dyn FnMut(&Progress) -> bool, t
|
|||
st.advance(0)?;
|
||||
let (w, h) = output_size(comp, cx.settings, cx.output);
|
||||
let mut report = match cx.output.format {
|
||||
OutputFormat::H264 | OutputFormat::Hevc | OutputFormat::Av1 | OutputFormat::ProRes => encode::movie(cx, comp, w, h, &mut st).await?,
|
||||
OutputFormat::H264 | OutputFormat::Hevc | OutputFormat::Av1 | OutputFormat::ProRes | OutputFormat::Uncompressed => {
|
||||
encode::movie(cx, comp, w, h, &mut st).await?
|
||||
}
|
||||
OutputFormat::Gif => gif_export(cx, comp, w, h, &mut st).await?,
|
||||
OutputFormat::WebM => webm::webm(cx, comp, w, h, &mut st).await?,
|
||||
OutputFormat::Wav => webm::audio_file(cx, comp, false, &mut st)?,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,132 @@
|
|||
//! Uncompressed QuickTime video (`raw `): 8-bit RGB ("Millions of Colors", depth 24) or ARGB
|
||||
//! ("Millions of Colors+", depth 32), one frame per sample, muxed by FilmCraft's QuickTime writer
|
||||
//! next to PCM audio like ProRes.
|
||||
//!
|
||||
//! Layout from the QuickTime File Format specification (Apple, "Video Sample Description" and
|
||||
//! "Uncompressed RGB" formats): pixels are stored top to bottom, components big-endian in
|
||||
//! A R G B / R G B order, and each row is padded to an even number of bytes (only depth 24 at odd
|
||||
//! widths needs it).
|
||||
|
||||
use filmcraft_isobmff::{CodecConfig, FourCc, SampleEntry, VideoParams};
|
||||
|
||||
use crate::encode::{Packet, VideoEncoder};
|
||||
use crate::{ExportError, Result};
|
||||
|
||||
pub(crate) struct Raw {
|
||||
w: u32,
|
||||
h: u32,
|
||||
alpha: bool,
|
||||
}
|
||||
|
||||
impl Raw {
|
||||
/// `alpha`: write ARGB (depth 32); otherwise RGB (depth 24).
|
||||
pub(crate) fn new(w: u32, h: u32, alpha: bool) -> Result<Raw> {
|
||||
if w == 0 || h == 0 || w > u16::MAX as u32 || h > u16::MAX as u32 {
|
||||
return Err(ExportError::Unsupported(format!("uncompressed QuickTime frames must be 1–65535 px, not {w}×{h}")));
|
||||
}
|
||||
Ok(Raw { w, h, alpha })
|
||||
}
|
||||
|
||||
fn depth(&self) -> u16 {
|
||||
if self.alpha { 32 } else { 24 }
|
||||
}
|
||||
|
||||
/// Bytes per stored row (even).
|
||||
fn row_bytes(&self) -> usize {
|
||||
let n = self.w as usize * if self.alpha { 4 } else { 3 };
|
||||
n + (n & 1)
|
||||
}
|
||||
|
||||
/// The `raw ` video sample description payload (after the 8-byte box header).
|
||||
fn description(&self) -> Vec<u8> {
|
||||
let mut b = Vec::with_capacity(78);
|
||||
b.extend_from_slice(&[0; 6]); // reserved
|
||||
b.extend_from_slice(&1u16.to_be_bytes()); // data reference index
|
||||
b.extend_from_slice(&[0; 4]); // version, revision level
|
||||
b.extend_from_slice(&[0; 4]); // vendor
|
||||
b.extend_from_slice(&0u32.to_be_bytes()); // temporal quality
|
||||
b.extend_from_slice(&1024u32.to_be_bytes()); // spatial quality: codecLosslessQuality
|
||||
b.extend_from_slice(&(self.w as u16).to_be_bytes());
|
||||
b.extend_from_slice(&(self.h as u16).to_be_bytes());
|
||||
b.extend_from_slice(&0x0048_0000u32.to_be_bytes()); // 72 dpi
|
||||
b.extend_from_slice(&0x0048_0000u32.to_be_bytes());
|
||||
b.extend_from_slice(&0u32.to_be_bytes()); // data size
|
||||
b.extend_from_slice(&1u16.to_be_bytes()); // frames per sample
|
||||
let name = b"None";
|
||||
let mut nb = [0u8; 32];
|
||||
nb[0] = name.len() as u8;
|
||||
nb[1..=name.len()].copy_from_slice(name);
|
||||
b.extend_from_slice(&nb);
|
||||
b.extend_from_slice(&self.depth().to_be_bytes());
|
||||
b.extend_from_slice(&(-1i16).to_be_bytes()); // no colour table
|
||||
b
|
||||
}
|
||||
}
|
||||
|
||||
impl VideoEncoder for Raw {
|
||||
fn sample_entry(&self) -> SampleEntry {
|
||||
let fourcc = FourCc(*b"raw ");
|
||||
SampleEntry {
|
||||
format: fourcc,
|
||||
data_reference_index: 1,
|
||||
// Written verbatim; `video` makes the track a video track with the right size.
|
||||
codec: CodecConfig::Unknown { fourcc, raw: self.description() },
|
||||
video: Some(VideoParams { width: self.w as u16, height: self.h as u16, depth: self.depth(), ..Default::default() }),
|
||||
audio: None,
|
||||
bitrate: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn encode(&mut self, rgba: &[u8], _index: u64) -> Result<Vec<Packet>> {
|
||||
let (w, h) = (self.w as usize, self.h as usize);
|
||||
let row = self.row_bytes();
|
||||
let size = row.checked_mul(h).ok_or_else(|| ExportError::Encode("frame too large".into()))?;
|
||||
let mut data = vec![0u8; size];
|
||||
for (y, out) in data.chunks_exact_mut(row).enumerate() {
|
||||
let src = rgba.get(y * w * 4..(y + 1) * w * 4).ok_or_else(|| ExportError::Encode("short frame".into()))?;
|
||||
for (p, o) in src.as_chunks::<4>().0.iter().zip(out.chunks_exact_mut(if self.alpha { 4 } else { 3 })) {
|
||||
if self.alpha {
|
||||
o.copy_from_slice(&[p[3], p[0], p[1], p[2]]);
|
||||
} else {
|
||||
o.copy_from_slice(&p[..3]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(vec![Packet { data, key: true, cto: 0 }])
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> Result<Vec<Packet>> {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rgb_rows_are_padded_to_even_bytes() {
|
||||
let mut r = Raw::new(3, 2, false).unwrap();
|
||||
let rgba: Vec<u8> = (0..24).collect();
|
||||
let p = r.encode(&rgba, 0).unwrap();
|
||||
assert_eq!(p[0].data, [0, 1, 2, 4, 5, 6, 8, 9, 10, 0, 12, 13, 14, 16, 17, 18, 20, 21, 22, 0]);
|
||||
assert_eq!(r.sample_entry().video.unwrap().depth, 24);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn argb_moves_alpha_first() {
|
||||
let mut r = Raw::new(1, 1, true).unwrap();
|
||||
assert_eq!(r.encode(&[10, 20, 30, 40], 0).unwrap()[0].data, [40, 10, 20, 30]);
|
||||
let e = r.sample_entry();
|
||||
let CodecConfig::Unknown { raw, .. } = e.codec else { panic!("raw entry") };
|
||||
assert_eq!(raw.len(), 78);
|
||||
assert_eq!(&raw[74..76], &32u16.to_be_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_sizes_are_errors() {
|
||||
assert!(Raw::new(0, 1, false).is_err());
|
||||
assert!(Raw::new(70_000, 1, true).is_err());
|
||||
assert!(Raw::new(2, 2, true).unwrap().encode(&[0; 4], 0).is_err());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,222 @@
|
|||
//! Uncompressed QuickTime (`raw `) output (#372): every frame stored is exactly the frame the
|
||||
//! lossless PNG sequence of the same job writes, read back with FilmCraft's demuxer and — when
|
||||
//! installed — decoded by ffmpeg as an external oracle. Audio rides along as PCM.
|
||||
//!
|
||||
//! The comp is 33×20 so 24-bit rows (99 bytes) need their padding byte, and a 16-px solid centred
|
||||
//! in it sits on a half pixel, so its edges carry partial alpha.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_export::render_queue::{AudioFormat, AudioOutput, Channels, OutputFormat, OutputModule, RenderSettings, TimeSpan, sequence_path};
|
||||
use effectcraft_export::{Job, export};
|
||||
use effectcraft_media::MediaPool;
|
||||
use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, Project, Solid, build};
|
||||
use effectcraft_render::FootageSource;
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
use filmcraft_isobmff::TrackKind;
|
||||
|
||||
const W: u32 = 33;
|
||||
const H: u32 = 20;
|
||||
const FRAMES: u64 = 6;
|
||||
|
||||
fn out_dir(name: &str) -> PathBuf {
|
||||
let d = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out/export").join(name);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).expect("mkdir");
|
||||
d
|
||||
}
|
||||
|
||||
/// 16-bit stereo 48 kHz WAV: a 440 Hz sine at 0.5 on the left, silence on the right.
|
||||
fn write_tone(path: &Path, secs: f64) {
|
||||
let sr = 48_000u32;
|
||||
let n = (secs * sr as f64) as usize;
|
||||
let mut data = Vec::with_capacity(n * 4);
|
||||
for i in 0..n {
|
||||
let s = (0.5 * (2.0 * std::f64::consts::PI * 440.0 * i as f64 / sr as f64).sin() * 32767.0).round() as i16;
|
||||
data.extend_from_slice(&s.to_le_bytes());
|
||||
data.extend_from_slice(&0i16.to_le_bytes());
|
||||
}
|
||||
let mut w = Vec::new();
|
||||
w.extend_from_slice(b"RIFF");
|
||||
w.extend_from_slice(&(36 + data.len() as u32).to_le_bytes());
|
||||
w.extend_from_slice(b"WAVEfmt ");
|
||||
w.extend_from_slice(&16u32.to_le_bytes());
|
||||
w.extend_from_slice(&1u16.to_le_bytes());
|
||||
w.extend_from_slice(&2u16.to_le_bytes());
|
||||
w.extend_from_slice(&sr.to_le_bytes());
|
||||
w.extend_from_slice(&(sr * 4).to_le_bytes());
|
||||
w.extend_from_slice(&4u16.to_le_bytes());
|
||||
w.extend_from_slice(&16u16.to_le_bytes());
|
||||
w.extend_from_slice(b"data");
|
||||
w.extend_from_slice(&(data.len() as u32).to_le_bytes());
|
||||
w.extend_from_slice(&data);
|
||||
std::fs::write(path, w).expect("write wav");
|
||||
}
|
||||
|
||||
fn project(audio: Option<&Path>) -> (Project, ItemId) {
|
||||
let mut p = Project::default();
|
||||
let mut comp = Comp::new(W, H, FrameRate::new(10, 1), Tick::from_seconds_f64(FRAMES as f64 / 10.0));
|
||||
comp.background = [0.1, 0.3, 0.6];
|
||||
let sid = p.add_item("Red", Label::Red, None, ItemKind::Solid(Solid { color: [0.9, 0.2, 0.1], width: 16, height: 12, pixel_aspect: 1.0 }));
|
||||
let mut l = build::layer(&mut p, &comp, "Red", LayerSource::Solid { item: sid }, (16, 12), None);
|
||||
l.in_point = comp.frame_rate.tick_of(2);
|
||||
comp.layers.push(l);
|
||||
if let Some(path) = audio {
|
||||
let f = effectcraft_media::probe(path).expect("probe wav");
|
||||
let fid = p.add_item("tone.wav", Label::SeaFoam, None, ItemKind::Footage(f));
|
||||
let l = build::layer(&mut p, &comp, "tone", LayerSource::Footage { item: fid }, (0, 0), None);
|
||||
comp.layers.push(l);
|
||||
}
|
||||
let cid = p.add_item("Comp 1", Label::Sandstone, None, ItemKind::Comp(comp.into()));
|
||||
(p, cid)
|
||||
}
|
||||
|
||||
fn run(p: &Project, cid: ItemId, footage: &dyn FootageSource, om: &OutputModule, path: &Path) -> effectcraft_export::Report {
|
||||
let s = RenderSettings { time_span: TimeSpan::LengthOfComp, ..Default::default() };
|
||||
let path = path.to_string_lossy().to_string();
|
||||
let job = Job {
|
||||
project: p,
|
||||
footage,
|
||||
expr: None,
|
||||
accel: None,
|
||||
comp: cid,
|
||||
settings: &s,
|
||||
output: om,
|
||||
path: &path,
|
||||
sink: None,
|
||||
options: Default::default(),
|
||||
nested_switches: true,
|
||||
};
|
||||
export(&job, &mut |_| true).expect("export")
|
||||
}
|
||||
|
||||
/// The job's frames as the PNG sequence writes them (lossless 8-bit), as RGBA.
|
||||
fn reference_frames(p: &Project, cid: ItemId, footage: &dyn FootageSource, om: &OutputModule, dir: &Path) -> Vec<Vec<u8>> {
|
||||
let png = OutputModule { channels: om.channels, alpha_mode: om.alpha_mode, ..OutputModule::for_format(OutputFormat::PngSequence) };
|
||||
let pattern = dir.join("ref_[#####].png").to_string_lossy().to_string();
|
||||
run(p, cid, footage, &png, Path::new(&pattern));
|
||||
(0..FRAMES).map(|k| image::open(sequence_path(&pattern, k as i64)).expect("png").to_rgba8().into_raw()).collect()
|
||||
}
|
||||
|
||||
/// The frames as stored in the movie, unpacked to RGBA (rows padded to even bytes).
|
||||
fn stored_frames(path: &Path, alpha: bool) -> Vec<Vec<u8>> {
|
||||
let bytes = std::fs::read(path).expect("read movie");
|
||||
let mov = filmcraft_isobmff::open(bytes.as_slice()).expect("demux");
|
||||
assert!(mov.is_quicktime);
|
||||
let t = mov.tracks.iter().find(|t| t.kind == TrackKind::Video).expect("video track");
|
||||
let e = &t.entries[0];
|
||||
assert_eq!(&e.format.0, b"raw ");
|
||||
let v = e.video.as_ref().expect("visual entry");
|
||||
assert_eq!((v.width, v.height, v.depth), (W as u16, H as u16, if alpha { 32 } else { 24 }));
|
||||
assert_eq!(t.samples.len() as u64, FRAMES);
|
||||
let bpp = if alpha { 4 } else { 3 };
|
||||
let row = (W as usize * bpp).next_multiple_of(2);
|
||||
t.samples
|
||||
.iter()
|
||||
.map(|s| {
|
||||
assert_eq!(s.size as usize, row * H as usize);
|
||||
let data = &bytes[s.offset as usize..][..s.size as usize];
|
||||
let mut rgba = Vec::new();
|
||||
for r in data.chunks_exact(row) {
|
||||
for p in r[..W as usize * bpp].chunks_exact(bpp) {
|
||||
rgba.extend_from_slice(&if alpha { [p[1], p[2], p[3], p[0]] } else { [p[0], p[1], p[2], 255] });
|
||||
}
|
||||
}
|
||||
rgba
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// All frames decoded by ffmpeg (external oracle) as RGBA, or `None` without ffmpeg.
|
||||
fn ffmpeg_frames(path: &Path) -> Option<Vec<Vec<u8>>> {
|
||||
let out =
|
||||
Command::new("ffmpeg").args(["-hide_banner", "-loglevel", "error", "-i"]).arg(path).args(["-f", "rawvideo", "-pix_fmt", "rgba", "-"]).output().ok()?;
|
||||
assert!(out.status.success(), "ffmpeg failed to decode {}: {}", path.display(), String::from_utf8_lossy(&out.stderr));
|
||||
Some(out.stdout.as_chunks::<{ (W * H * 4) as usize }>().0.iter().map(|f| f.to_vec()).collect())
|
||||
}
|
||||
|
||||
fn ffprobe_streams(path: &Path) -> Option<String> {
|
||||
let out = Command::new("ffprobe")
|
||||
.args([
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"stream=codec_type,codec_name,codec_tag_string,pix_fmt,width,height,sample_rate,channels",
|
||||
"-of",
|
||||
"compact=p=0:nk=1",
|
||||
])
|
||||
.arg(path)
|
||||
.output()
|
||||
.ok()?;
|
||||
assert!(out.status.success(), "ffprobe rejected {}", path.display());
|
||||
Some(String::from_utf8_lossy(&out.stdout).to_string())
|
||||
}
|
||||
|
||||
fn check(name: &str, channels: Channels) {
|
||||
let d = out_dir(name);
|
||||
let (p, cid) = project(None);
|
||||
let om = OutputModule { channels, ..OutputModule::for_format(OutputFormat::Uncompressed) };
|
||||
let path = d.join("comp.mov");
|
||||
let r = run(&p, cid, &effectcraft_render::NoFootage, &om, &path);
|
||||
assert_eq!((r.width, r.height, r.frames, r.audio), (W, H, FRAMES, false));
|
||||
let mut want_rgba = reference_frames(&p, cid, &effectcraft_render::NoFootage, &om, &d);
|
||||
let alpha = channels == Channels::Rgba;
|
||||
if !alpha {
|
||||
// The PNG is RGB (opaque); `to_rgba8` already filled alpha with 255.
|
||||
assert!(want_rgba.iter().all(|f| f.as_chunks::<4>().0.iter().all(|p| p[3] == 255)));
|
||||
} else {
|
||||
let a: Vec<u8> = want_rgba[3].as_chunks::<4>().0.iter().map(|p| p[3]).collect();
|
||||
assert!(a.contains(&0) && a.contains(&255) && a.iter().any(|v| *v > 0 && *v < 255), "partial alpha at the solid's edges");
|
||||
}
|
||||
assert_eq!(stored_frames(&path, alpha), want_rgba, "stored frames are the rendered frames");
|
||||
if let Some(got) = ffmpeg_frames(&path) {
|
||||
assert_eq!(got.len() as u64, FRAMES);
|
||||
want_rgba.truncate(got.len());
|
||||
assert!(got == want_rgba, "ffmpeg decodes the rendered frames exactly");
|
||||
let s = ffprobe_streams(&path).unwrap_or_default();
|
||||
let fmt = if alpha { "argb" } else { "rgb24" };
|
||||
assert_eq!(s.trim(), format!("rawvideo|video|raw |{W}|{H}|{fmt}"), "{s}");
|
||||
} else {
|
||||
eprintln!("ffmpeg not found: skipping oracle decode");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uncompressed_rgb_odd_width() {
|
||||
check("uncompressed-rgb", Channels::Rgb);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uncompressed_rgba_straight() {
|
||||
check("uncompressed-rgba", Channels::Rgba);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uncompressed_with_pcm_audio() {
|
||||
let d = out_dir("uncompressed-audio");
|
||||
let wav = d.join("tone.wav");
|
||||
write_tone(&wav, 1.0);
|
||||
let (p, cid) = project(Some(&wav));
|
||||
let pool = MediaPool::new();
|
||||
let om = OutputModule { audio: AudioOutput::Auto, audio_format: AudioFormat::S24, ..OutputModule::for_format(OutputFormat::Uncompressed) };
|
||||
let path = d.join("comp.mov");
|
||||
assert!(run(&p, cid, &pool, &om, &path).audio);
|
||||
let bytes = std::fs::read(&path).expect("read");
|
||||
let mov = filmcraft_isobmff::open(bytes.as_slice()).expect("demux");
|
||||
let a = mov.tracks.iter().find(|t| t.kind == TrackKind::Audio).expect("audio track");
|
||||
assert_eq!(a.timescale, 48_000);
|
||||
// 0.6 s of audio at 48 kHz.
|
||||
let frames: u64 = a.samples.iter().map(|s| s.duration as u64).sum();
|
||||
assert!((frames as i64 - 28_800).abs() <= 1, "{frames} audio frames");
|
||||
if let Some(s) = ffprobe_streams(&path) {
|
||||
assert!(s.lines().any(|l| l == "pcm_s24le|audio|lpcm|48000|2"), "{s}");
|
||||
let out = Command::new("ffmpeg").args(["-hide_banner", "-loglevel", "error", "-i"]).arg(&path).args(["-f", "f32le", "-"]).output().expect("ffmpeg");
|
||||
let pcm: Vec<f32> = out.stdout.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes(*b)).collect();
|
||||
let rms = |v: Vec<f32>| (v.iter().map(|x| (*x as f64).powi(2)).sum::<f64>() / v.len().max(1) as f64).sqrt();
|
||||
let left = rms(pcm.iter().step_by(2).copied().collect());
|
||||
// A sine of amplitude 0.5 → RMS ≈ 0.354.
|
||||
assert!((left - 0.3536).abs() < 0.01, "left RMS {left}");
|
||||
}
|
||||
}
|
||||
|
|
@ -22,7 +22,10 @@
|
|||
//! `seedRandom`), so frames render identically on every thread and platform.
|
||||
//! * **Runtime**: one boa `Context` per thread with compiled scripts cached by text. Scripts read
|
||||
//! the project through memoized host requests (see [`host`]), so nothing borrowed ever enters
|
||||
//! the JS heap. Expressions reading each other nest up to [`MAX_DEPTH`] levels.
|
||||
//! the JS heap. Expressions reading each other nest up to [`MAX_DEPTH`] levels; one that needs
|
||||
//! its own value fails at once, and one evaluation (reads included) runs at most [`MAX_RUNS`]
|
||||
//! scripts for at most [`MAX_TIME`]. Text nested deeper than the parser's stack allows is refused
|
||||
//! ([`rewrite::MAX_NESTING`]); a seeded fuzzer (`tests/fuzz.rs`) keeps all of this honest.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
|
|
@ -38,12 +41,39 @@ use effectcraft_render::{EvalCtx, ExprHost};
|
|||
use effectcraft_time::Tick;
|
||||
|
||||
use host::{Own, Req, Resolver, prop_dims, value_resp};
|
||||
use runtime::{Begin, FRAMES, Frame, Run};
|
||||
use runtime::{Begin, FRAMES, Frame, Owner, RUNS, Run};
|
||||
|
||||
/// Maximum nesting of expressions that read other expressions (cycles stop here).
|
||||
pub const MAX_DEPTH: usize = 16;
|
||||
/// Maximum script re-runs while answering host requests.
|
||||
const MAX_PASSES: usize = 2000;
|
||||
/// Maximum script runs for one outermost evaluation, the expressions it reads included: reads
|
||||
/// that fan out (an expression reading two values whose expressions each read two more…) grow
|
||||
/// exponentially with [`MAX_DEPTH`] and would hang the frame.
|
||||
pub const MAX_RUNS: usize = 20_000;
|
||||
/// Longest one outermost evaluation may take (its script runs and the expressions it reads),
|
||||
/// checked between runs and while a script runs: endless or very long loops, and a script that
|
||||
/// loops a while and then reads many values (re-running the loop once per value), stop here.
|
||||
/// Native builds only (the web build has no clock here; loops stop at the loop limit there).
|
||||
pub const MAX_TIME: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
|
||||
/// Start the work budget of an outermost evaluation.
|
||||
fn budget_start() {
|
||||
RUNS.with(|r| r.set(0));
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
runtime::STARTED.with(|s| s.set(Some(std::time::Instant::now())));
|
||||
}
|
||||
|
||||
/// An error once the outermost evaluation has used up its runs or its time.
|
||||
fn budget_left() -> Result<(), String> {
|
||||
if RUNS.with(|r| r.get()) >= MAX_RUNS {
|
||||
return Err("Error: expression reads too many other expressions' values (circular or fanning-out references?)".into());
|
||||
}
|
||||
if runtime::out_of_time() {
|
||||
return Err(runtime::TIMED_OUT.into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The expression engine (stateless; runtimes are per thread).
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
|
|
@ -111,10 +141,23 @@ fn evaluate_out(
|
|||
text_sel: [f64; 2],
|
||||
selector: [f64; 3],
|
||||
) -> Result<runtime::Out, String> {
|
||||
let depth = FRAMES.with(|f| f.borrow().len());
|
||||
let owner = Owner { comp: ctx.comp_id.0, layer: layer.id.0, uid: prop.uid, time: ctx.time, text: text.to_string() };
|
||||
let (depth, cycle) = FRAMES.with(|f| {
|
||||
let f = f.borrow();
|
||||
(f.len(), f.iter().any(|fr| fr.owner.as_ref() == Some(&owner)))
|
||||
});
|
||||
if depth == 0 {
|
||||
budget_start();
|
||||
}
|
||||
if cycle {
|
||||
// Needs its own result: fails at once (the reader falls back to the keyframed value)
|
||||
// instead of nesting to `MAX_DEPTH` once per read on the way.
|
||||
return Err("Error: circular expression reference (the expression needs its own value)".into());
|
||||
}
|
||||
if depth >= MAX_DEPTH {
|
||||
return Err("Error: expressions reference each other too deeply (circular reference?)".into());
|
||||
}
|
||||
budget_left()?;
|
||||
let ctx = EvalCtx { project: ctx.project, comp_id: ctx.comp_id, comp: ctx.comp, time: ctx.time, expr: Some(host), footage: ctx.footage };
|
||||
let comp = ctx.comp_id.0;
|
||||
let path = layer.props.path_of(prop.uid).unwrap_or_else(|| format!("@{}", prop.uid));
|
||||
|
|
@ -133,7 +176,7 @@ fn evaluate_out(
|
|||
selector,
|
||||
};
|
||||
// Answer the requests nearly every expression makes up front.
|
||||
let mut frame = Frame::default();
|
||||
let mut frame = Frame { owner: Some(owner), ..Frame::default() };
|
||||
for req in [Req::CompInfo { comp }, Req::LayerInfo { comp, layer: layer.id.0 }, Req::PropInfo { comp, layer: layer.id.0, path: path.clone() }] {
|
||||
let r = resolver.resolve(&req);
|
||||
frame.memo.insert(req, r);
|
||||
|
|
@ -141,6 +184,8 @@ fn evaluate_out(
|
|||
FRAMES.with(|f| f.borrow_mut().push(frame));
|
||||
let _guard = FrameGuard;
|
||||
for _ in 0..MAX_PASSES {
|
||||
budget_left()?;
|
||||
RUNS.with(|r| r.set(r.get().saturating_add(1)));
|
||||
match runtime::run(text, &begin) {
|
||||
Run::Done(r) => return r,
|
||||
Run::Pending => {
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ pub fn noise1(seed: u64, x: f64) -> f64 {
|
|||
let i = x.floor();
|
||||
let u = fade(x - i);
|
||||
let i = i as i64;
|
||||
let (a, b) = (lattice(seed, i), lattice(seed, i + 1));
|
||||
let (a, b) = (lattice(seed, i), lattice(seed, i.wrapping_add(1)));
|
||||
a + (b - a) * u
|
||||
}
|
||||
|
||||
|
|
@ -50,7 +50,7 @@ pub fn noise3(x: f64, y: f64, z: f64) -> f64 {
|
|||
let (fx, fy, fz) = (x.floor(), y.floor(), z.floor());
|
||||
let (u, v, w) = (fade(x - fx), fade(y - fy), fade(z - fz));
|
||||
let (ix, iy, iz) = (fx as i64, fy as i64, fz as i64);
|
||||
let g = |dx: i64, dy: i64, dz: i64| unit(mix(&[(ix + dx) as u64, (iy + dy) as u64, (iz + dz) as u64])) * 2.0 - 1.0;
|
||||
let g = |dx: i64, dy: i64, dz: i64| unit(mix(&[ix.wrapping_add(dx) as u64, iy.wrapping_add(dy) as u64, iz.wrapping_add(dz) as u64])) * 2.0 - 1.0;
|
||||
let l = |a: f64, b: f64, t: f64| a + (b - a) * t;
|
||||
let x00 = l(g(0, 0, 0), g(1, 0, 0), u);
|
||||
let x10 = l(g(0, 1, 0), g(1, 1, 0), u);
|
||||
|
|
@ -99,6 +99,18 @@ mod tests {
|
|||
assert!((-1.0..=1.0).contains(&noise3(0.3, 4.2, -7.5)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_inputs_do_not_overflow() {
|
||||
// Found by the expression fuzzer (#786): `wiggle(9007199254740993, 30)` at a late time
|
||||
// put the lattice index at `i64::MAX`, and its neighbour overflowed.
|
||||
let w = wiggle(1, 2, 9_007_199_254_740_993.0, 30.0, 1.0, 0.5, 1e6);
|
||||
assert!(w.iter().all(|v| v.is_finite() && v.abs() <= 30.0));
|
||||
for x in [1e300, -1e300, 9.3e18, -9.3e18] {
|
||||
assert!(noise1(5, x).is_finite());
|
||||
assert!(noise3(x, -x, x).is_finite());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wiggle_bounds() {
|
||||
for i in 0..2000 {
|
||||
|
|
|
|||
|
|
@ -548,55 +548,230 @@ fn push_piece(out: &mut String, p: Piece) {
|
|||
out.push_str(&p.body);
|
||||
}
|
||||
|
||||
/// The deepest nesting (brackets, plus chains of prefix operators) an expression may have,
|
||||
/// before and after the rewrite (which nests a call per arithmetic operator). The JavaScript
|
||||
/// parser recurses per level and a 2 MiB thread overflows its stack (aborting the app) at
|
||||
/// about 60 levels, so deeper expressions are rejected with an error instead.
|
||||
/// The deepest nesting an expression may have, in levels of brackets, before and after the
|
||||
/// rewrite (which nests a call per arithmetic operator). The JavaScript parser and compiler
|
||||
/// recurse per level and a 2 MiB thread overflows its stack (aborting the app) at about 75
|
||||
/// nested brackets, so deeper expressions are rejected with an error instead. Constructs that
|
||||
/// nest without brackets count as part of a level ([`too_nested`]).
|
||||
pub const MAX_NESTING: usize = 32;
|
||||
|
||||
fn nesting(toks: &[Tok]) -> usize {
|
||||
let (mut depth, mut prefix, mut max) = (0usize, 0usize, 0usize);
|
||||
for t in toks {
|
||||
if t.kind == Kind::Punct && matches!(t.text.as_str(), "(" | "[" | "{") {
|
||||
depth += 1;
|
||||
} else if t.kind == Kind::Punct && matches!(t.text.as_str(), ")" | "]" | "}") {
|
||||
depth = depth.saturating_sub(1);
|
||||
/// Stack cost of one level of brackets (also of a template substitution, a regex group and a
|
||||
/// prefix operator, which cost the parser about as much or are never chained in real code).
|
||||
const LEVEL: usize = 256;
|
||||
/// Stack cost of a construct the parser recurses on without a bracket: `?:`, `=>`, assignments,
|
||||
/// `**`, `||`, labels, `if`/`else`/`while`/`for`/`with`/`do` without a block (about 1/8 of a
|
||||
/// bracket: 256 of them in a row are allowed; boa overflows from about 750).
|
||||
const HEAVY: usize = 32;
|
||||
/// Stack cost of a link in a left-deep chain (binary operators, member access, calls, indexing,
|
||||
/// the comma operator): the tree is built in a loop but compiled recursively (overflows from
|
||||
/// about 3 800 links).
|
||||
const LIGHT: usize = 6;
|
||||
|
||||
/// One bracket level of [`too_nested`]'s walk.
|
||||
#[derive(Default)]
|
||||
struct Level {
|
||||
/// `[`: commas separate elements (a loop in the parser), not operands.
|
||||
array: bool,
|
||||
/// Cost of the chains of the current statement or element.
|
||||
chain: usize,
|
||||
/// Comma operators so far in the current statement.
|
||||
commas: usize,
|
||||
/// `else` branches in a row: `if … else if … else if …` nests even across `;`.
|
||||
elses: usize,
|
||||
/// The next token starts a statement.
|
||||
fresh: bool,
|
||||
}
|
||||
|
||||
impl Level {
|
||||
fn boundary(&mut self) {
|
||||
self.chain = 0;
|
||||
self.commas = 0;
|
||||
self.fresh = true;
|
||||
}
|
||||
}
|
||||
|
||||
const ASSIGN: &[&str] = &["=", "+=", "-=", "*=", "/=", "%=", "**=", "<<=", ">>=", ">>>=", "&=", "|=", "^=", "&&=", "||=", "??="];
|
||||
|
||||
/// Can `t` end an expression statement (so a line break after it may end the statement)?
|
||||
fn ends_value(t: &Tok) -> bool {
|
||||
match t.kind {
|
||||
Kind::Num | Kind::Str | Kind::Template | Kind::Regex => true,
|
||||
Kind::Ident => !t.is_kw(DELIM_KW) && !t.is_kw(PREFIX_KW),
|
||||
Kind::Punct => matches!(t.text.as_str(), ")" | "]" | "}" | "++" | "--"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Can `t` only start a new statement after a value (automatic semicolon insertion)?
|
||||
fn starts_statement(t: &Tok) -> bool {
|
||||
match t.kind {
|
||||
Kind::Num | Kind::Str => true,
|
||||
Kind::Ident => !t.is_kw(&["in", "instanceof", "of"]),
|
||||
Kind::Punct => matches!(t.text.as_str(), "{" | "!" | "~" | "++" | "--"),
|
||||
Kind::Template | Kind::Regex => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bracket depth inside a template literal (its `${…}` substitutions) or a regex (its groups).
|
||||
fn inner_depth(text: &str) -> usize {
|
||||
let (mut d, mut max) = (0usize, 0usize);
|
||||
for c in text.chars() {
|
||||
match c {
|
||||
'(' | '[' | '{' => d += 1,
|
||||
')' | ']' | '}' => d = d.saturating_sub(1),
|
||||
_ => {}
|
||||
}
|
||||
let is_prefix = (t.kind == Kind::Punct && matches!(t.text.as_str(), "!" | "~" | "-" | "+")) || t.is_kw(&["typeof", "void", "delete"]);
|
||||
prefix = if is_prefix { prefix + 1 } else { 0 };
|
||||
max = max.max(depth + prefix);
|
||||
max = max.max(d);
|
||||
}
|
||||
max
|
||||
}
|
||||
|
||||
/// The code of a template literal's `${…}` substitutions (the outermost ones).
|
||||
fn substitutions(template: &str) -> Vec<String> {
|
||||
let c: Vec<char> = template.chars().collect();
|
||||
let mut out = vec![];
|
||||
let mut i = 1;
|
||||
while i < c.len() {
|
||||
match c[i] {
|
||||
'\\' => i += 2,
|
||||
'$' if c.get(i + 1) == Some(&'{') => {
|
||||
let start = i + 2;
|
||||
let (mut j, mut depth) = (start, 0usize);
|
||||
while j < c.len() {
|
||||
match c[j] {
|
||||
'{' => depth += 1,
|
||||
'}' if depth == 0 => break,
|
||||
'}' => depth -= 1,
|
||||
'\'' | '"' => {
|
||||
j = skip_string(&c, j);
|
||||
continue;
|
||||
}
|
||||
'`' => {
|
||||
j = skip_template(&c, j);
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
out.push(c.get(start..j.min(c.len())).unwrap_or_default().iter().collect());
|
||||
i = j + 1;
|
||||
}
|
||||
_ => i += 1,
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether `toks` nest deeper than [`MAX_NESTING`] levels: an estimate of the parser's stack
|
||||
/// use, summing brackets, runs of prefix operators and, per bracket level, the chains of the
|
||||
/// current statement (or array element) weighted by what each link costs the parser.
|
||||
fn too_nested(toks: &[Tok]) -> bool {
|
||||
too_nested_from(toks, 0)
|
||||
}
|
||||
|
||||
/// [`too_nested`] for code that already sits `base` deep (a template substitution).
|
||||
fn too_nested_from(toks: &[Tok], base: usize) -> bool {
|
||||
let mut levels = vec![Level::default()];
|
||||
let mut prefix = 0usize;
|
||||
let mut prev: Option<&Tok> = None;
|
||||
for t in toks {
|
||||
let p = t.kind == Kind::Punct;
|
||||
if let Some(l) = levels.last_mut() {
|
||||
if t.nl && prev.is_some_and(ends_value) && starts_statement(t) {
|
||||
l.boundary();
|
||||
}
|
||||
if l.fresh {
|
||||
if !t.is_kw(&["else"]) {
|
||||
l.elses = 0;
|
||||
}
|
||||
l.fresh = false;
|
||||
}
|
||||
}
|
||||
let mut inner = 0;
|
||||
if p && matches!(t.text.as_str(), "(" | "[" | "{") {
|
||||
// A call or index is a link in its chain too (`f()()()…`).
|
||||
if t.text != "{"
|
||||
&& let Some(l) = levels.last_mut()
|
||||
{
|
||||
l.chain = l.chain.saturating_add(LIGHT);
|
||||
}
|
||||
levels.push(Level { array: t.text == "[", ..Level::default() });
|
||||
} else if p && matches!(t.text.as_str(), ")" | "]" | "}") {
|
||||
if levels.len() > 1 {
|
||||
levels.pop();
|
||||
}
|
||||
} else if let Some(l) = levels.last_mut() {
|
||||
if t.is(";") {
|
||||
l.boundary();
|
||||
} else if t.is(",") {
|
||||
l.commas += usize::from(!l.array);
|
||||
l.chain = l.commas.saturating_mul(LIGHT);
|
||||
} else if t.is_kw(&["else"]) {
|
||||
l.elses += 1;
|
||||
} else if (p && (matches!(t.text.as_str(), "?" | ":" | "=>" | "**" | "||") || ASSIGN.contains(&t.text.as_str())))
|
||||
|| t.is_kw(&["if", "while", "for", "with", "do"])
|
||||
{
|
||||
l.chain = l.chain.saturating_add(HEAVY);
|
||||
} else if p || t.is_kw(&["in", "instanceof"]) {
|
||||
l.chain = l.chain.saturating_add(LIGHT);
|
||||
} else if matches!(t.kind, Kind::Template | Kind::Regex) {
|
||||
// A tagged template is a link like a call.
|
||||
l.chain = l.chain.saturating_add(LIGHT);
|
||||
inner = inner_depth(&t.text);
|
||||
}
|
||||
}
|
||||
let is_prefix = (p && matches!(t.text.as_str(), "!" | "~" | "-" | "+" | "++" | "--")) || t.is_kw(&["typeof", "void", "delete", "new", "await"]);
|
||||
prefix = if is_prefix { prefix + 1 } else { 0 };
|
||||
let mut cost = (levels.len() - 1 + prefix + inner).saturating_mul(LEVEL).saturating_add(base);
|
||||
for l in &levels {
|
||||
cost = cost.saturating_add(l.chain).saturating_add(l.elses.saturating_mul(HEAVY));
|
||||
}
|
||||
if cost > MAX_NESTING * LEVEL {
|
||||
return true;
|
||||
}
|
||||
// The code inside `${…}` is parsed like any other, one level deeper (the depth check
|
||||
// above bounds this recursion).
|
||||
if t.kind == Kind::Template
|
||||
&& t.text.contains("${")
|
||||
&& substitutions(&t.text)
|
||||
.iter()
|
||||
.any(|sub| too_nested_from(&tokenize(sub).0, cost.saturating_sub(inner.saturating_mul(LEVEL)).saturating_add(LEVEL)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
prev = Some(t);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// An error when `src` nests deeper than [`MAX_NESTING`] levels (before or after the rewrite).
|
||||
pub fn check_nesting(src: &str) -> Result<(), String> {
|
||||
let (toks, tail) = tokenize(src);
|
||||
if nesting(&toks) > MAX_NESTING {
|
||||
if too_nested(&toks) {
|
||||
return Err(too_deep());
|
||||
}
|
||||
let mut out = rewrite_seq(&build(toks));
|
||||
out.push_str(&tail);
|
||||
if nesting(&tokenize(&out).0) > MAX_NESTING {
|
||||
if too_nested(&tokenize(&out).0) {
|
||||
return Err(too_deep());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn too_deep() -> String {
|
||||
format!("Error: expression is nested too deeply (more than {MAX_NESTING} levels of brackets or chained operators)")
|
||||
format!("Error: expression is nested too deeply (more than {MAX_NESTING} levels of brackets, or too long a chain of operators)")
|
||||
}
|
||||
|
||||
/// Rewrite an expression's source for array-aware arithmetic. Line breaks are preserved.
|
||||
/// An expression nested deeper than [`MAX_NESTING`] becomes a `throw` of that error.
|
||||
pub fn rewrite(src: &str) -> String {
|
||||
let (toks, tail) = tokenize(src);
|
||||
if nesting(&toks) > MAX_NESTING {
|
||||
if too_nested(&toks) {
|
||||
return throw_too_deep();
|
||||
}
|
||||
let mut out = rewrite_seq(&build(toks));
|
||||
out.push_str(&tail);
|
||||
if nesting(&tokenize(&out).0) > MAX_NESTING {
|
||||
if too_nested(&tokenize(&out).0) {
|
||||
return throw_too_deep();
|
||||
}
|
||||
out
|
||||
|
|
@ -673,4 +848,60 @@ mod tests {
|
|||
assert!(crate::check_syntax(&ok).is_ok());
|
||||
assert!(check_nesting(&vec!["x"; 20].join(" + ")).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_chains_without_brackets_are_an_error_not_a_stack_overflow() {
|
||||
// Found by the expression fuzzer (#786): the parser also recurses on chains that need no
|
||||
// bracket, so a few hundred `?:`, `=>` or `if`, or a few thousand `.`/`<`/`()` links,
|
||||
// aborted the app with a stack overflow; so did 75 nested template substitutions or
|
||||
// regex groups (both hidden inside one token), and 6 000 prefix `new`/`++` the rewriter.
|
||||
let chains: [(&str, &str); 19] = [
|
||||
("x?x:", ""),
|
||||
("()=>", ""),
|
||||
("a=>", ""),
|
||||
("if(x)", ""),
|
||||
("if(x)x;else ", ""),
|
||||
("x=", ""),
|
||||
("x**", ""),
|
||||
("x||", ""),
|
||||
("x<", ""),
|
||||
("x,", ""),
|
||||
("x.y", ""),
|
||||
("f()", ""),
|
||||
("a:", ""),
|
||||
("new ", ""),
|
||||
("++", ""),
|
||||
("`${", "}`"),
|
||||
("s.replace(/(", ")/, '')"),
|
||||
("`${x??", "}`"),
|
||||
("{a:", "}"),
|
||||
];
|
||||
for (open, close) in chains {
|
||||
for n in [2_000, 100_000] {
|
||||
let src = open.repeat(n) + "1" + &close.repeat(n);
|
||||
assert!(rewrite(&src).starts_with("throw new RangeError("), "{open} × {n}");
|
||||
assert!(crate::check_syntax(&src).unwrap_err().contains("nested too deeply"), "{open} × {n}");
|
||||
}
|
||||
}
|
||||
// A chain inside one template substitution (the template is one token to the rewriter).
|
||||
let src = format!("`${{{}1}}`", "x??".repeat(100_000));
|
||||
assert!(crate::check_syntax(&src).unwrap_err().contains("nested too deeply"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_real_expressions_still_parse() {
|
||||
let lines = |n: usize, line: &str| vec![line; n].join("\n");
|
||||
let else_ifs = (0..200).map(|i| format!("if (time < {i}) {i}; else ")).collect::<String>() + "0";
|
||||
let numbers = format!("[{}][0]", (0..5000).map(|i| i.to_string()).collect::<Vec<_>>().join(", "));
|
||||
let points = format!("createPath([{}], [], [], true)", (0..500).map(|i| format!("[{i}, {}]", i * 2)).collect::<Vec<_>>().join(", "));
|
||||
let object = format!("var o = {{{}}}; o.k0", (0..200).map(|i| format!("k{i}: time > {i} ? {i} : -{i}")).collect::<Vec<_>>().join(", "));
|
||||
let program = "var x = 0\n".to_string() + &lines(300, "x = x + thisComp.layer(1).transform.position[0] * 2 - 1") + "\nx";
|
||||
let ifs = "var y = 0\n".to_string() + &lines(300, "if (time > 1) y = y + 1") + "\ny";
|
||||
let chained = vec!["thisComp.layer(1).transform.position.valueAtTime(time - 0.1)[0]"; 30].join(" + ");
|
||||
let template = "`${thisLayer.name}: ${Math.floor(time * 10) / 10} s, ${`${index}`} {}`".to_string();
|
||||
for src in [else_ifs, numbers, points, object, program, ifs, chained, template] {
|
||||
assert!(check_nesting(&src).is_ok(), "{}", &src[..80]);
|
||||
assert!(crate::check_syntax(&src).is_ok(), "{}", &src[..80]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,11 @@ const PRELUDE: &str = include_str!("prelude.js");
|
|||
/// Message of the error thrown when a request isn't answered yet (never shown to users).
|
||||
const PENDING: &str = "\u{1}effectcraft: pending host request";
|
||||
const MAX_SCRIPTS: usize = 4096;
|
||||
/// Loop iterations one script run may make (per function call): a run re-executes once per
|
||||
/// property it reads, so this bounds the time of every pass.
|
||||
pub const LOOP_LIMIT: u64 = 1_000_000;
|
||||
/// The error of an evaluation that ran out of time.
|
||||
pub const TIMED_OUT: &str = "Error: expression takes too long to evaluate";
|
||||
/// The part of boa's stack-size limit error the runtime recovers from (see [`run`]).
|
||||
const STACK_LIMIT: &str = "maximum stack size";
|
||||
|
||||
|
|
@ -24,6 +29,18 @@ const STACK_LIMIT: &str = "maximum stack size";
|
|||
pub struct Frame {
|
||||
pub memo: HashMap<Req, Resp>,
|
||||
pub misses: Vec<Req>,
|
||||
/// What this frame evaluates: an evaluation that needs itself is a cycle.
|
||||
pub owner: Option<Owner>,
|
||||
}
|
||||
|
||||
/// One expression evaluation: the property (comp, layer, uid), the comp time and the text.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Owner {
|
||||
pub comp: u64,
|
||||
pub layer: u64,
|
||||
pub uid: u64,
|
||||
pub time: effectcraft_time::Tick,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
|
|
@ -32,6 +49,14 @@ thread_local! {
|
|||
static RUNTIME: RefCell<Option<ManuallyDrop<Result<Runtime, String>>>> = const { RefCell::new(None) };
|
||||
/// Evaluation stack (nested when one expression reads another's value).
|
||||
pub static FRAMES: RefCell<Vec<Frame>> = const { RefCell::new(Vec::new()) };
|
||||
/// Script runs since the outermost evaluation on this thread began (nested ones included).
|
||||
pub static RUNS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
thread_local! {
|
||||
/// When the outermost evaluation on this thread began.
|
||||
pub static STARTED: std::cell::Cell<Option<std::time::Instant>> = const { std::cell::Cell::new(None) };
|
||||
}
|
||||
|
||||
struct Runtime {
|
||||
|
|
@ -209,7 +234,7 @@ impl Runtime {
|
|||
fn new() -> Result<Runtime, String> {
|
||||
let mut ctx = Context::default();
|
||||
let limits = ctx.runtime_limits_mut();
|
||||
limits.set_loop_iteration_limit(10_000_000);
|
||||
limits.set_loop_iteration_limit(LOOP_LIMIT);
|
||||
limits.set_recursion_limit(400);
|
||||
let natives: [(JsString, usize, fn(&JsValue, &[JsValue], &mut Context) -> JsResult<JsValue>); 4] = [
|
||||
(js_string!("__h"), 6, native_host),
|
||||
|
|
@ -270,7 +295,11 @@ impl Runtime {
|
|||
if let Err(e) = self.begin.call(&JsValue::undefined(), &args, ctx) {
|
||||
return Run::Done(Err(format_error(&e.to_string(), line_count(text))));
|
||||
}
|
||||
let result = script.evaluate(ctx).and_then(|v| self.finish.call(&JsValue::undefined(), &[v], ctx));
|
||||
let result = match evaluate_in_time(&script, ctx) {
|
||||
Some(r) => r.and_then(|v| self.finish.call(&JsValue::undefined(), &[v], ctx)),
|
||||
// Abandoned mid-run: the context is unusable now (see `run`).
|
||||
None => return Run::Done(Err(TIMED_OUT.into())),
|
||||
};
|
||||
let pending = FRAMES.with(|f| f.borrow().last().is_some_and(|fr| !fr.misses.is_empty()));
|
||||
if pending {
|
||||
return Run::Pending;
|
||||
|
|
@ -282,6 +311,54 @@ impl Runtime {
|
|||
}
|
||||
}
|
||||
|
||||
/// Run `script`, giving up (`None`) once the outermost evaluation is out of time
|
||||
/// ([`crate::MAX_TIME`]). boa's budgeted evaluation yields every few thousand instructions of
|
||||
/// the script and the functions it calls (not inside callbacks of native functions such as
|
||||
/// `Array.prototype.map`, which stay bounded by the loop limit per call).
|
||||
fn evaluate_in_time(script: &Script, ctx: &mut Context) -> Option<JsResult<JsValue>> {
|
||||
let mut run = std::pin::pin!(script.evaluate_async_with_budget(ctx, 20_000));
|
||||
let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
|
||||
loop {
|
||||
if let std::task::Poll::Ready(r) = run.as_mut().poll(&mut cx) {
|
||||
return Some(r);
|
||||
}
|
||||
if out_of_time() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Test hook: this thread's time budget (tests of the run-count budget lift it so that a
|
||||
/// loaded machine cannot reach the clock first).
|
||||
pub static TIME_LIMIT: std::cell::Cell<std::time::Duration> = const { std::cell::Cell::new(crate::MAX_TIME) };
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn max_time() -> std::time::Duration {
|
||||
#[cfg(test)]
|
||||
{
|
||||
TIME_LIMIT.with(|t| t.get())
|
||||
}
|
||||
#[cfg(not(test))]
|
||||
{
|
||||
crate::MAX_TIME
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the outermost evaluation on this thread has used up [`crate::MAX_TIME`].
|
||||
pub fn out_of_time() -> bool {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
STARTED.with(|s| s.get()).is_some_and(|t| t.elapsed() > max_time())
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy a JS value out of the heap.
|
||||
fn detach(v: &JsValue, ctx: &mut Context, depth: usize) -> Result<Out, String> {
|
||||
if v.is_undefined() {
|
||||
|
|
@ -413,6 +490,13 @@ pub fn run(text: &str, b: &Begin) -> Run {
|
|||
Err(e) => return Run::Done(Err(e.clone())),
|
||||
};
|
||||
match out {
|
||||
Run::Done(Err(e)) if e == TIMED_OUT => {
|
||||
// Abandoned mid-run: start the next evaluation on a fresh context.
|
||||
if let Some(old) = r.take() {
|
||||
drop(ManuallyDrop::into_inner(old));
|
||||
}
|
||||
return Run::Done(Err(e));
|
||||
}
|
||||
Run::Done(Err(e)) if !fresh && e.contains(STACK_LIMIT) => {
|
||||
// Dropped here, mid-thread, where boa's heap is alive (unlike at thread exit).
|
||||
if let Some(old) = r.take() {
|
||||
|
|
@ -500,6 +584,11 @@ pub fn to_value(out: &Out, current: &Value) -> Result<Value, String> {
|
|||
Value::Str(_) => Value::Str(text(out)),
|
||||
Value::Path(_) => match out {
|
||||
Out::Path { points, ins, outs, closed } => {
|
||||
// A vertex that isn't a number (`[[0, 0], [[1, 2], 0]]`) comes through as NaN, which
|
||||
// the rasterizer must never see.
|
||||
if points.iter().chain(ins).chain(outs).flatten().any(|c| !c.is_finite()) {
|
||||
return Err("Error: path points must be finite numbers".into());
|
||||
}
|
||||
let n = points.len();
|
||||
let fit = |v: &[[f64; 2]]| {
|
||||
let mut v = v.to_vec();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
use std::time::Instant;
|
||||
|
||||
mod fuzz;
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_keyframe::{Keyframe, ShapePath, TextDoc, Value};
|
||||
use effectcraft_project::build::{self, Ids};
|
||||
|
|
@ -383,11 +385,87 @@ fn circular_references_terminate() {
|
|||
f.set_expr(f.b, OPA, "thisComp.layer('A').transform.opacity");
|
||||
let start = Instant::now();
|
||||
let r = eval_property(&f.p, f.cid, f.a, OPA, 0.0);
|
||||
assert!(start.elapsed().as_secs_f64() < 10.0);
|
||||
// The cycle bottoms out at the depth limit and falls back to keyframed values.
|
||||
// Only a hang guard (the budgets stop it far sooner); generous for a loaded machine.
|
||||
assert!(start.elapsed().as_secs_f64() < 100.0, "{:?}", start.elapsed());
|
||||
// The cycle is caught when A needs itself again, and falls back to keyframed values.
|
||||
assert!(matches!(r, Ok(Value::Scalar(_))) || r.is_err(), "{r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_expression_needing_its_own_value_fails_fast() {
|
||||
// Found by the expression fuzzer (#786): rotation's `toWorld` reads the layer's transform,
|
||||
// which evaluates rotation's expression again, twice per level (layer-to-comp and world
|
||||
// matrices), so the cycle nested to `MAX_DEPTH` 2^16 times and the frame took 20 s.
|
||||
let mut f = fx();
|
||||
f.set_expr(f.a, ROT, "fromWorld(toWorld(anchorPoint))[0]");
|
||||
let start = Instant::now();
|
||||
let r = eval_property(&f.p, f.cid, f.a, ROT, 0.5);
|
||||
assert!(start.elapsed().as_secs_f64() < 100.0, "{:?}", start.elapsed());
|
||||
// The inner read falls back to the keyframed rotation, as in a standalone evaluation.
|
||||
assert_eq!(r, f.ev(f.a, ROT, "fromWorld(toWorld(anchorPoint))[0]", 0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fanning_out_reads_stop_at_a_budget() {
|
||||
// Two layers reading each other twice at other times: no cycle (the times differ), but
|
||||
// 2^16 evaluations at `MAX_DEPTH`; the evaluation stops after `MAX_RUNS` script runs.
|
||||
let mut f = fx();
|
||||
let text = |other: &str| format!("var r = thisComp.layer('{other}').transform.rotation; r.valueAtTime(time + 0.001) + r.valueAtTime(time + 0.002)");
|
||||
f.set_expr(f.a, ROT, &text("B"));
|
||||
f.set_expr(f.b, ROT, &text("A"));
|
||||
// Take the clock out of the race: on a loaded machine the time budget could otherwise stop
|
||||
// the evaluation before the run count does, with a different error.
|
||||
crate::runtime::TIME_LIMIT.with(|t| t.set(std::time::Duration::from_secs(3600)));
|
||||
let start = Instant::now();
|
||||
let r = eval_property(&f.p, f.cid, f.a, ROT, 0.5);
|
||||
crate::runtime::TIME_LIMIT.with(|t| t.set(crate::MAX_TIME));
|
||||
assert!(start.elapsed().as_secs_f64() < 300.0, "{:?}", start.elapsed());
|
||||
assert!(r.unwrap_err().contains("too many"));
|
||||
// The budget is per outermost evaluation: the next one starts afresh.
|
||||
assert_close!(f.num("time * 2", 1.0), 2.0);
|
||||
assert_close!(f.ev(f.b, OPA, "thisComp.layer('A').transform.opacity", 0.0).unwrap().as_f64(), 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loops_stop_at_a_million_iterations() {
|
||||
// Found by the expression fuzzer (#786): `for (…; i < 1e15; …)` ran ten million iterations
|
||||
// per run (seconds), once more for every property the script reads after it.
|
||||
let f = fx();
|
||||
assert_close!(f.num("var n = 0; while (n < 900000) n++; n", 0.0), 900_000.0);
|
||||
assert!(f.err("var n = 0; while (n < 2000000) n++; n").contains("iteration"));
|
||||
assert!(f.err("for (;;) {}").contains("iteration"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_running_script_stops_at_the_time_budget() {
|
||||
// Found by the expression fuzzer (#786): `smooth(NaN, 1e9)` made a million host reads in
|
||||
// one run (tens of seconds), and the loop limit counts per function call, so loops calling
|
||||
// functions that loop run for hours. The run is now abandoned after `MAX_TIME`.
|
||||
let f = fx();
|
||||
let start = Instant::now();
|
||||
let e = f.err("function g() { for (var i = 0; i < 900000; i++) {} }\nfor (var k = 0; k < 900000; k++) g()");
|
||||
assert!(e.contains("too long"), "{e}");
|
||||
assert!(start.elapsed() < crate::MAX_TIME * 12, "{:?}", start.elapsed());
|
||||
// The thread's runtime starts afresh.
|
||||
assert_close!(f.num("time * 2", 1.0), 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_with_non_numeric_points_is_an_error() {
|
||||
// Found by the expression fuzzer (#786): an array in a vertex became NaN and tripped the
|
||||
// rasterizer's clipping assertion when the mask was drawn.
|
||||
let mut f = fx();
|
||||
let text = "createPath([[0, 0], [[1, 2] * 2, 0], [100, 100]], [], [], true)";
|
||||
assert!(f.ev(f.a, "masks/#1/path", text, 0.0).unwrap_err().contains("finite"));
|
||||
assert!(f.ev(f.a, "masks/#1/path", "createPath([[0, 0], [1e400, 0], [100, 100]], [], [], true)", 0.0).is_err());
|
||||
f.set_expr(f.a, "masks/#1/path", text);
|
||||
let host = Expressions;
|
||||
let mut r = Renderer::new(&f.p, &NoFootage, RenderOpts { motion_blur: false, ..Default::default() });
|
||||
r.expr = Some(&host);
|
||||
r.comp_frame(f.cid, Tick::ZERO);
|
||||
assert!(f.ev(f.a, "masks/#1/path", "createPath([[0, 0], [10, 0], [10, 10]], [], [], true)", 0.0).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_and_property_reads_keep_the_runtime_usable() {
|
||||
// Errors thrown inside helpers, and the aborts every property read makes until the host
|
||||
|
|
@ -1002,7 +1080,7 @@ fn hundred_wiggles_are_fast() {
|
|||
let per_frame = start.elapsed().as_secs_f64() * 1000.0 / n as f64;
|
||||
println!("100 wiggle properties: {per_frame:.3} ms/frame (checksum {acc})");
|
||||
// Generous bound so unoptimised CI builds pass; release is far below.
|
||||
assert!(per_frame < 100.0, "{per_frame} ms/frame");
|
||||
assert!(per_frame < 1000.0, "{per_frame} ms/frame");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,361 @@
|
|||
//! An expression fuzzer (gaps G3, #786): any expression text must evaluate to a value or an
|
||||
//! error, never a panic, a stack overflow (which aborts the app), a hang or a runaway allocation
|
||||
//! (AGENTS.md "Never crash").
|
||||
//!
|
||||
//! Dependency-free, deterministic and seeded: real After Effects-style expressions (`wiggle`,
|
||||
//! `loopOut`, `valueAtTime`, `thisComp.layer(…)`, effects, masks, markers, text styles, paths…)
|
||||
//! are mutated character-wise (flips, deletions, duplications, splices with other seeds,
|
||||
//! truncation) and token-wise (punctuation, keywords and object-model names inserted, numbers
|
||||
//! swapped for extreme ones, a token repeated into deep nesting or a long operator chain), then
|
||||
//! syntax-checked, evaluated on a scalar, a vector, a text and a path property of the test comp,
|
||||
//! read through another layer's expression and rendered in a small frame. Inputs run on a worker
|
||||
//! thread with the app's default 2 MiB stack under `catch_unwind` and a deadline: a panic or a
|
||||
//! hang is reported with the iteration that reproduces it instead of taking the run down, and a
|
||||
//! stack overflow aborts the run, which fails it all the same (`EFFECTCRAFT_FUZZ_VERBOSE=1`
|
||||
//! prints every input before it runs).
|
||||
//!
|
||||
//! The default run is bounded (iterations and seconds) to stay fast in `cargo test`; a longer run:
|
||||
//! `EFFECTCRAFT_FUZZ_ITERS=200000 EFFECTCRAFT_FUZZ_SECS=600 cargo test -p effectcraft-expr fuzz_`
|
||||
//! (`EFFECTCRAFT_FUZZ_SEED` picks another sequence, `EFFECTCRAFT_FUZZ_START` the first iteration).
|
||||
//! The `mutate` + `eval_input` pair is what a cargo-fuzz target would call.
|
||||
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use effectcraft_render::{NoFootage, RenderOpts, Renderer};
|
||||
|
||||
use super::{Fx, OPA, POS, ROT, fx, secs};
|
||||
use crate::{Expressions, check_syntax, eval_property, eval_standalone};
|
||||
|
||||
/// splitmix64: tiny, good enough for picking mutations.
|
||||
struct Rng(u64);
|
||||
|
||||
impl Rng {
|
||||
fn next(&mut self) -> u64 {
|
||||
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
let mut z = self.0;
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
z ^ (z >> 31)
|
||||
}
|
||||
/// `0..n` (`0` when `n` is 0).
|
||||
fn below(&mut self, n: usize) -> usize {
|
||||
if n == 0 { 0 } else { (self.next() % n as u64) as usize }
|
||||
}
|
||||
fn chance(&mut self, one_in: usize) -> bool {
|
||||
self.below(one_in) == 0
|
||||
}
|
||||
fn pick<'a, T>(&mut self, xs: &'a [T]) -> &'a T {
|
||||
&xs[self.below(xs.len())]
|
||||
}
|
||||
}
|
||||
|
||||
/// Real expressions, in the shapes people write them (and the test comp can answer them).
|
||||
#[rustfmt::skip]
|
||||
const SEEDS: &[&str] = &[
|
||||
"time * 360",
|
||||
"value",
|
||||
"wiggle(2, 50)",
|
||||
"wiggle(5, 20, 3, 0.5, time * 2)",
|
||||
"temporalWiggle(2, 0.1)",
|
||||
"loopOut('cycle')",
|
||||
"loopOut('pingpong', 1)",
|
||||
"loopIn('offset')",
|
||||
"loopOutDuration('continue', 0.5)",
|
||||
"valueAtTime(time - 0.5)",
|
||||
"thisProperty.valueAtTime(time / 2) + velocityAtTime(time)[0]",
|
||||
"speedAtTime(0.5)",
|
||||
"smooth(0.2, 5)",
|
||||
"nearestKey(time).index",
|
||||
"numKeys > 1 ? key(numKeys).value : value",
|
||||
"thisComp.layer('A').transform.rotation.valueAtTime(0.25)",
|
||||
"thisComp.layer(\"B\").transform.position[0] + 10",
|
||||
"thisComp.layer(1).effect('Slider Control')('Slider') * 2",
|
||||
"effect(1).param(1) + effect(1)(1)",
|
||||
"comp('Other').layer(1).name",
|
||||
"comp('Other').width / thisComp.height",
|
||||
"thisLayer.index + thisComp.numLayers",
|
||||
"mask('Mask 1').maskOpacity",
|
||||
"mask('Mask 1').maskPath.points()[0]",
|
||||
"content('Rectangle 1').content('Rectangle Path 1').size[0]",
|
||||
"marker.numKeys > 0 ? marker.key(1).time : 0",
|
||||
"marker.nearestKey(time).comment",
|
||||
"thisComp.marker.key(1).time",
|
||||
"var r = sourceRectAtTime(time, false); [r.left + r.width / 2, r.top + r.height / 2]",
|
||||
"toComp([0, 0])",
|
||||
"fromWorld(toWorld(anchorPoint))",
|
||||
"lookAt([0, 0, 0], [100, 0, 100])",
|
||||
"value + [10, -5]",
|
||||
"[1, 2] * 2 - [0.5, 0.5] / 4",
|
||||
"x = value; x += [1, 0]; x",
|
||||
"linear(time, 0, 1, 0, 100)",
|
||||
"ease(time, 0, 2, [0, 0], [100, 50])",
|
||||
"easeIn(time, 0, 1, 0, 360) + easeOut(time, 1, 2, 0, 10)",
|
||||
"clamp(value * 2, 0, 100)",
|
||||
"length([0, 0], [3, 4]) + dot([1, 0], [0, 1]) + cross([1, 0, 0], [0, 1, 0])[2]",
|
||||
"normalize([3, 4])[0] * degreesToRadians(180)",
|
||||
"seedRandom(5, true); random(100)",
|
||||
"seedRandom(index, false); gaussRandom([10, 20])",
|
||||
"noise(time) * 100",
|
||||
"posterizeTime(12); wiggle(3, 30)",
|
||||
"framesToTime(timeToFrames(time) + 1)",
|
||||
"rgbToHsl([1, 0, 0, 1])[0] + hslToRgb([0.5, 1, 0.5, 1])[1]",
|
||||
"Math.sin(time * Math.PI * 2) * 50",
|
||||
"Math.round(value / 10) * 10",
|
||||
"var s = 0; for (var i = 0; i < 10; i++) { s += i; } s",
|
||||
"function f(a) { return a * 2 } f(value)",
|
||||
"var g = (a, b) => a - b; g(5, 3)",
|
||||
"if (time > 1) { 100 } else { 0 }",
|
||||
"try { thisComp.layer('Missing').name } catch (e) { 0 }",
|
||||
"'Frame ' + timeToFrames(time) + ' of ' + thisComp.duration",
|
||||
"text.sourceText + ' world'",
|
||||
"text.sourceText.style.setText('Hi there').setFontSize(20, 3, 5).setFauxBold(true, 0, 2)",
|
||||
"thisComp.layer('Title').text.sourceText.style.fontSize",
|
||||
"`${thisLayer.name}: ${Math.floor(time)}`",
|
||||
"createPath([[0, 0], [100, 0], [100, 100]], [], [], true)",
|
||||
"var p = thisProperty; p.pointOnPath(0.5)",
|
||||
"[thisComp.width, thisComp.height] / 2",
|
||||
"thisComp.frameDuration * 10",
|
||||
"inPoint + (outPoint - inPoint) * 0.5",
|
||||
"transform.opacity * 0.5 + [0][0]",
|
||||
];
|
||||
|
||||
/// What gets inserted: syntax, keywords, object-model names and odd characters.
|
||||
#[rustfmt::skip]
|
||||
const TOKENS: &[&str] = &[
|
||||
"(", ")", "[", "]", "{", "}", ",", ";", ".", "?.", "?", ":", "=", "==", "=>", "+", "-", "*", "/", "%", "**", "++", "--",
|
||||
"+=", "-=", "*=", "/=", "!", "~", "&&", "||", "??", "...", "'", "\"", "`", "${", "\\", "/*", "*/", "//", "\n", "\r", "\t",
|
||||
"\u{0}", "\u{2028}", "\u{feff}", "é", "\u{1F600}", "\u{fffd}", " in ", " of ", " new ", " typeof ", " void ", " delete ",
|
||||
"function", "return ", "var ", "let ", "const ", "if", "else", "for", "while", "do", "switch", "case ", "break", "try",
|
||||
"catch", "finally", "throw ", "class ", "this", "null", "undefined", "NaN", "Infinity", "true", "-0", "0x", "1e", "1n",
|
||||
"/a+/g", "time", "value", "index", "thisComp", "thisLayer", "thisProperty", "layer(", "comp(", "effect(", "mask(",
|
||||
"content(", "key(", "marker", "valueAtTime(", "wiggle(", "loopOut(", "sourceRectAtTime(", "toComp(", "text.sourceText",
|
||||
".style", ".setText(", "createPath(", "seedRandom(", "random(", "linear(", "__h(", "__begin", "__S", "arguments",
|
||||
];
|
||||
|
||||
/// Numbers swapped in for numeric literals.
|
||||
#[rustfmt::skip]
|
||||
const NUMBERS: &[&str] = &[
|
||||
"0", "-0", "1", "-1", "0.5", "2", "16", "17", "255", "256", "65536", "4294967295", "4294967296", "9007199254740993",
|
||||
"1e308", "-1e308", "1e-320", "1e400", "NaN", "Infinity", "-Infinity", "1e9", "1e15", "-2147483648", "0x7fffffff",
|
||||
];
|
||||
|
||||
/// A fragment that nests (or chains) once per repetition: what overflows a recursive parser.
|
||||
#[rustfmt::skip]
|
||||
const NESTS: &[(&str, &str)] = &[
|
||||
("(", ")"), ("[", "]"), ("{", "}"), ("-", ""), ("!", ""), ("~", ""), ("+", ""), ("++", ""), ("--", ""), ("typeof ", ""),
|
||||
("new ", ""), ("await ", ""), ("void ", ""), ("delete ", ""), ("x**", ""), ("x+", ""), ("x*", ""), ("x=", ""),
|
||||
("x?x:", ""), ("x&&", ""), ("x||", ""), ("x??", ""), ("x<", ""), ("x==", ""), ("x,", ""), ("()=>", ""), ("a=>", ""),
|
||||
("x.", "y"), ("f(", ")"), ("x[", "]"), ("`${", "}`"), ("function(){return ", "}"), ("{a:", "}"), ("if(x)", ""),
|
||||
("x.y", ""), ("f()", ""), ("x[0]", ""),
|
||||
];
|
||||
|
||||
/// One mutated expression from the seeds, decided by the iteration's own generator.
|
||||
fn mutate(r: &mut Rng) -> String {
|
||||
let mut s: Vec<char> = r.pick(SEEDS).chars().collect();
|
||||
if r.chance(4) {
|
||||
// Splice two expressions together.
|
||||
let other: Vec<char> = r.pick(SEEDS).chars().collect();
|
||||
let at = r.below(s.len() + 1);
|
||||
let glue: Vec<char> = r.pick(&[";\n", " + ", " * ", ", ", "(", "."]).chars().collect();
|
||||
s.splice(at..at, glue.into_iter().chain(other));
|
||||
}
|
||||
for _ in 0..1 + r.below(4) {
|
||||
let len = s.len();
|
||||
match r.below(10) {
|
||||
0 if len > 0 => {
|
||||
// A bit flip in the UTF-8 bytes (invalid sequences become U+FFFD).
|
||||
let mut b = s.iter().collect::<String>().into_bytes();
|
||||
let i = r.below(b.len());
|
||||
b[i] ^= 1 << r.below(8);
|
||||
s = String::from_utf8_lossy(&b).chars().collect();
|
||||
}
|
||||
1 if len > 0 => {
|
||||
let a = r.below(len);
|
||||
let e = (a + 1 + r.below(16)).min(len);
|
||||
s.drain(a..e);
|
||||
}
|
||||
2 if len > 0 => {
|
||||
let a = r.below(len);
|
||||
let e = (a + 1 + r.below(32)).min(len);
|
||||
let chunk = s[a..e].to_vec();
|
||||
let at = r.below(len + 1);
|
||||
s.splice(at..at, chunk);
|
||||
}
|
||||
3 | 4 => {
|
||||
let t: Vec<char> = r.pick(TOKENS).chars().collect();
|
||||
let at = r.below(len + 1);
|
||||
s.splice(at..at, t);
|
||||
}
|
||||
5 => {
|
||||
// An extreme number in place of a numeric literal (or anywhere).
|
||||
let starts: Vec<usize> = (0..len).filter(|&i| s[i].is_ascii_digit() && (i == 0 || !s[i - 1].is_ascii_alphanumeric())).collect();
|
||||
let n: Vec<char> = r.pick(NUMBERS).chars().collect();
|
||||
if starts.is_empty() {
|
||||
let at = r.below(len + 1);
|
||||
s.splice(at..at, n);
|
||||
} else {
|
||||
let a = *r.pick(&starts);
|
||||
let e = (a..len).find(|&i| !(s[i].is_ascii_alphanumeric() || s[i] == '.')).unwrap_or(len);
|
||||
s.splice(a..e, n);
|
||||
}
|
||||
}
|
||||
6 => {
|
||||
// Deep nesting or a long chain, closed or not.
|
||||
let (open, close) = *r.pick(NESTS);
|
||||
let depth = *r.pick(&[8, 40, 300, 5_000, 100_000]);
|
||||
let at = r.below(len + 1);
|
||||
let mut nest: Vec<char> = open.repeat(depth).chars().collect();
|
||||
nest.push('1');
|
||||
if !r.chance(4) {
|
||||
nest.extend(close.repeat(depth).chars());
|
||||
}
|
||||
s.splice(at..at, nest);
|
||||
}
|
||||
7 if len > 0 => s.truncate(r.below(len)),
|
||||
8 => {
|
||||
// A whole statement in front or behind.
|
||||
let other: Vec<char> = r.pick(SEEDS).chars().collect();
|
||||
if r.chance(2) {
|
||||
s.extend(";\n".chars().chain(other));
|
||||
} else {
|
||||
s.splice(0..0, other.into_iter().chain(";\n".chars()));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
s.into_iter().collect()
|
||||
}
|
||||
|
||||
/// Where an input is evaluated: a scalar, a vector, a text and a path property.
|
||||
const TARGETS: &[(usize, &str)] = &[(0, ROT), (0, POS), (2, "text/sourceText"), (0, "masks/#1/path"), (3, ROT), (1, OPA)];
|
||||
const TIMES: &[f64] = &[0.0, 0.5, 1.25, 4.0, -1.0, 1e6];
|
||||
|
||||
/// Syntax-check `text`, evaluate it on one property, read it from another layer's expression
|
||||
/// and render a small frame with it. `Err` only for what must never happen: an `internal error`
|
||||
/// (a panic caught by a guard).
|
||||
fn eval_input(f: &mut Fx, text: &str, target: usize, t: f64) -> Result<(), String> {
|
||||
internal(&check_syntax(text))?;
|
||||
let layers = [f.a, f.b, f.title, f.shape];
|
||||
let (li, path) = TARGETS[target % TARGETS.len()];
|
||||
let layer = layers[li];
|
||||
internal(&eval_standalone(&f.p, f.cid, layer, path, text, t))?;
|
||||
// As the property's own expression, read by B's rotation and drawn by the renderer.
|
||||
f.set_expr(layer, path, text);
|
||||
f.set_expr(f.b, ROT, &format!("var v = thisComp.layer({}).transform.rotation; v + 1", li + 1));
|
||||
internal(&eval_property(&f.p, f.cid, layer, path, t))?;
|
||||
internal(&eval_property(&f.p, f.cid, f.b, ROT, t))?;
|
||||
let host = Expressions;
|
||||
let mut r = Renderer::new(&f.p, &NoFootage, RenderOpts { motion_blur: false, scale: 0.25, ..Default::default() });
|
||||
r.expr = Some(&host);
|
||||
r.comp_frame(f.cid, secs(t));
|
||||
// Leave the fixture as it was.
|
||||
let l = f.layer_mut(layer);
|
||||
if let Some(p) = l.props.prop_mut(path) {
|
||||
p.expr = None;
|
||||
}
|
||||
if let Some(p) = f.layer_mut(f.b).props.prop_mut(ROT) {
|
||||
p.expr = None;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn internal<T>(r: &Result<T, String>) -> Result<(), String> {
|
||||
match r {
|
||||
Err(e) if e.contains("internal error") => Err(e.clone()),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
struct Job {
|
||||
text: String,
|
||||
target: usize,
|
||||
t: f64,
|
||||
}
|
||||
|
||||
/// A worker thread that keeps its expression runtime between inputs, like a render thread.
|
||||
struct Worker {
|
||||
jobs: Sender<Job>,
|
||||
results: Receiver<Result<(), String>>,
|
||||
}
|
||||
|
||||
impl Worker {
|
||||
fn start() -> Result<Worker, String> {
|
||||
let (jobs, rx) = channel::<Job>();
|
||||
let (tx, results) = channel();
|
||||
std::thread::Builder::new()
|
||||
.name("fuzz-expr".into())
|
||||
.stack_size(2 << 20)
|
||||
.spawn(move || {
|
||||
let mut f = fx();
|
||||
for job in rx {
|
||||
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| eval_input(&mut f, &job.text, job.target, job.t)));
|
||||
let r = r.unwrap_or_else(|p| {
|
||||
Err(format!(
|
||||
"panic: {}",
|
||||
p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default()
|
||||
))
|
||||
});
|
||||
let failed = r.is_err();
|
||||
if tx.send(r).is_err() || failed {
|
||||
break; // after a panic the fixture and runtime may be half-updated: start over
|
||||
}
|
||||
}
|
||||
})
|
||||
.map_err(|e| format!("couldn't start the fuzz thread: {e}"))?;
|
||||
Ok(Worker { jobs, results })
|
||||
}
|
||||
|
||||
/// Run one input: a panic becomes its message, a hang a timeout (the worker is then dropped).
|
||||
fn run(&self, job: Job, deadline: Duration) -> Result<(), String> {
|
||||
self.jobs.send(job).map_err(|_| "the fuzz worker exited".to_string())?;
|
||||
self.results.recv_timeout(deadline).unwrap_or_else(|_| Err(format!("hang: no result within {deadline:?}")))
|
||||
}
|
||||
}
|
||||
|
||||
fn env_u64(name: &str, default: u64) -> u64 {
|
||||
std::env::var(name).ok().and_then(|v| v.trim().parse().ok()).unwrap_or(default)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuzz_expressions_never_crash() {
|
||||
let seed = env_u64("EFFECTCRAFT_FUZZ_SEED", 0x00EC_E8E2);
|
||||
let start = env_u64("EFFECTCRAFT_FUZZ_START", 0);
|
||||
let iters = env_u64("EFFECTCRAFT_FUZZ_ITERS", 1_500);
|
||||
let budget = Duration::from_secs(env_u64("EFFECTCRAFT_FUZZ_SECS", 5));
|
||||
let verbose = env_u64("EFFECTCRAFT_FUZZ_VERBOSE", 0) != 0;
|
||||
// Every evaluation gives up after `MAX_TIME`, and an input is evaluated a handful of times.
|
||||
let deadline = crate::MAX_TIME * 24;
|
||||
let mut worker = Worker::start().unwrap();
|
||||
// The unmutated seeds evaluate everywhere without crashing.
|
||||
for (i, s) in SEEDS.iter().enumerate() {
|
||||
assert_eq!(worker.run(Job { text: s.to_string(), target: i, t: 0.5 }, deadline), Ok(()), "{s}");
|
||||
}
|
||||
let began = Instant::now();
|
||||
let mut failures = vec![];
|
||||
let mut ran = 0;
|
||||
for i in start..start + iters {
|
||||
if began.elapsed() > budget {
|
||||
break;
|
||||
}
|
||||
let mut r = Rng(seed ^ i.wrapping_mul(0xD6E8_FEB8_6659_FD93));
|
||||
let text = mutate(&mut r);
|
||||
let target = r.below(TARGETS.len());
|
||||
let t = *r.pick(TIMES);
|
||||
if verbose {
|
||||
eprintln!("iteration {i}: target {target}, t {t}, {} chars: {:?}", text.len(), text.chars().take(300).collect::<String>());
|
||||
}
|
||||
ran += 1;
|
||||
if let Err(e) = worker.run(Job { text: text.clone(), target, t }, deadline) {
|
||||
let head: String = text.chars().take(200).collect();
|
||||
failures.push(format!("iteration {i} (seed {seed:#x}, target {target}, t {t}, {} chars): {e}\n input starts: {head:?}", text.len()));
|
||||
if failures.len() >= 5 {
|
||||
break;
|
||||
}
|
||||
worker = Worker::start().unwrap();
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "expressions crashed on {} of {ran} fuzz inputs:\n{}", failures.len(), failures.join("\n"));
|
||||
eprintln!("fuzz_expressions: {ran} inputs in {:?}, no crashes", began.elapsed());
|
||||
}
|
||||
|
|
@ -43,6 +43,9 @@ const UPLOAD_BUDGET: usize = 1536 << 20;
|
|||
/// Working textures kept for reuse once their last image is dropped (bytes).
|
||||
const POOL_BUDGET: usize = 768 << 20;
|
||||
|
||||
/// Readback staging buffers kept for reuse across sizes (bytes).
|
||||
const STAGING_BUDGET: u64 = 256 << 20;
|
||||
|
||||
/// A GPU image: premultiplied RGBA f32 in a texture (cheap to clone; immutable once written).
|
||||
/// Working images come from the context's texture pool and go back to it when the last clone
|
||||
/// is dropped.
|
||||
|
|
@ -774,6 +777,12 @@ impl GpuContext {
|
|||
self.unavailable.push((entry, "dropped by a test".into()));
|
||||
}
|
||||
|
||||
/// Bytes of readback staging buffers kept for reuse (tests).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn staging_bytes(&self) -> u64 {
|
||||
self.staging.lock().map(|s| s.iter().map(|(k, v)| k * v.len() as u64).sum()).unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn can_readback(&self) -> bool {
|
||||
self.readbacks.check().is_ok() && (self.can_wait() || self.deferred.is_some())
|
||||
}
|
||||
|
|
@ -1153,9 +1162,15 @@ impl<'g> Enc<'g> {
|
|||
}
|
||||
out.copy_from_slice(&bytes);
|
||||
// Keep a few staging buffers per size (frames read back at the same size every time).
|
||||
// Layers whose bounds change every frame read back at a new size each time: drop the
|
||||
// other sizes once over budget, or mapped host memory grows for the whole render (#2081).
|
||||
if let Ok(mut s) = self.g.staging.lock() {
|
||||
let held: u64 = s.iter().map(|(k, v)| k.saturating_mul(v.len() as u64)).sum();
|
||||
if held.saturating_add(size) > STAGING_BUDGET {
|
||||
s.retain(|k, _| *k == size);
|
||||
}
|
||||
let v = s.entry(size).or_default();
|
||||
if v.len() < 2 && size <= 256 << 20 {
|
||||
if v.len() < 2 && size <= STAGING_BUDGET {
|
||||
v.push(buf);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -145,6 +145,21 @@ impl effectcraft_render::FxTarget for GpuFx<'_, '_> {
|
|||
let out = f(Buf { img, offset: b.offset, scale: b.scale });
|
||||
self.b = self.e.g.upload_image(&out.img).map(|img| GBuf { img, offset: out.offset, scale: out.scale });
|
||||
}
|
||||
|
||||
/// GBuf::cover, like `pad`: a bigger texture with the old one copied into place, no
|
||||
/// readback. A region the device can't allocate leaves the buffer as it is.
|
||||
fn cover(&mut self, size: [f64; 2]) {
|
||||
let Some(b) = self.b.take() else { return };
|
||||
let out = match effectcraft_effects::cover_plan(b.offset, b.scale, size, b.img.width, b.img.height) {
|
||||
Some((dx, dy, w, h)) if self.e.g.fits(w, h) => {
|
||||
let img = self.e.image(w, h);
|
||||
self.e.copy_into(&b.img, &img, dx, dy);
|
||||
GBuf { img, offset: [b.offset[0] + dx as f64, b.offset[1] + dy as f64], scale: b.scale }
|
||||
}
|
||||
_ => b,
|
||||
};
|
||||
self.b = Some(out);
|
||||
}
|
||||
}
|
||||
|
||||
fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue